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…c9e1

Respond 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 in Protected-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 /events as 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.