Webhooks
Webhooks
Signed HTTPS deliveries of your events to your server, retried for about three days.
Setting up an endpoint
In the dashboard, open Developers › Webhooks and add an HTTPS URL. Subscribe to every event type or choose some. The signing secret (whsec_…) is shown once; store it next to your API key. Up to 5 endpoints per business and environment.
Send a test event from the dashboard to check your endpoint. It is a webhook.test event, signed like any other, that is never recorded in the Events API.
What a delivery looks like
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: ProtectedPay-Webhooks/1.0
Protected-Pay-Event-Id: evt_m4qz7rk2v6hc3xna5tjw2pde
Protected-Pay-Event-Type: payment.protected
Protected-Pay-Api-Version: 2026-10-01
Protected-Pay-Signature: t=1791727541,v1=5f2b…c9e1Respond with any 2xx within 10 seconds. Anything else, a timeout, or a redirect (redirects are never followed) is a failure. The response body is not read.
Verifying signatures
Protected-Pay-Signature is t=<unix seconds>,v1=<hex>: an HMAC-SHA256 under your signing secret of the timestamp, a period, and the raw request body. Verify it against the body exactly as received, before parsing it, and reject timestamps more than 5 minutes from now.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (a string), before any JSON parsing.
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
let timestamp = null;
const signatures = [];
for (const part of (signatureHeader ?? '').split(',')) {
const [key, value] = part.split('=', 2);
if (key === 't' && /^\d+$/.test(value ?? '')) timestamp = Number(value);
if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return signatures.some(
(s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}While a rolled secret is still valid (up to 24 hours) the header carries one v1 per secret. Accept the request if any of them matches.
Retries and failure
A failed delivery is retried 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours, and 24 hours after each failure: nine attempts over about three days. Then it is abandoned. You can resend any delivery from the dashboard; a resend is one more attempt of the same event.
If five deliveries in a row are abandoned, the endpoint is disabled and marked failing in the dashboard. It stays disabled until you enable it again; any successful delivery resets the count.
Best practices
- Deliveries are at least once and unordered. Deduplicate by the event
id(also inProtected-Pay-Event-Id). - Order your own processing by
occurredAt, not by arrival. - Treat an event as a signal: fetch the payment or intent for its current state before acting.
- Respond quickly and do slow work afterwards, so deliveries do not time out.
- Poll
GET /eventsas a backstop if your endpoint was down for longer than the retry window.
Destination rules
Endpoint URLs must be https on port 443, with a public DNS name: no IP addresses, credentials in the URL, localhost, single-label, or internal names. The name is resolved before every delivery, and a delivery to any address that is not public is refused.