Skip to content

Overview & signatures

Webhooks tell your system when something happens: a payment arrives, a card is declined, a mandate is debited. Each is an HTTPS POST to your URL, signed so you can check it came from CatholicPay.

In the portal, under Developers → Webhooks, an admin sets:

  • A URL for test and one for live. It must be public HTTPS: private, loopback and link-local addresses are refused, and redirects are not followed.
  • Each mode’s signing secret. It is shown once: store it with your keys.
  • Which events you want. None chosen means all.

Send yourself a test event from the same page to check your endpoint.

POST /hooks/catholicpay HTTP/1.1
Content-Type: application/json
X-CatholicPay-Event: account_number.paid
X-CatholicPay-Delivery: 7d2f1a7e-4c1b-4e8e-9a3c-2f5b8c1d9e40
X-CatholicPay-Signature: t=1762765200,v1=5f8a…
{
"id": "7d2f1a7e-4c1b-4e8e-9a3c-2f5b8c1d9e40",
"event": "account_number.paid",
"mode": "LIVE",
"created_at": "2026-11-10T09:00:00Z",
"data": { … }
}

data depends on the event: see the event reference.

X-CatholicPay-Signature is t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> with your signing secret. To verify:

  1. Take the raw request body exactly as received, before any JSON parsing.
  2. Compute the HMAC over t, a dot, and the raw body.
  3. Compare with v1 in constant time.
  4. Refuse a t more than five minutes from now, so an old webhook cannot be replayed.
import crypto from 'node:crypto';
export function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const got = Buffer.from(parts.v1 ?? '', 'hex');
const exp = Buffer.from(want, 'hex');
return got.length === exp.length && crypto.timingSafeEqual(got, exp);
}

Answer with any 2xx within 10 seconds, then do the work (queue it). Any other status, or no answer in time, is a failed delivery. Failed deliveries are retried after 1 and 5 minutes, then 15 minutes, 1, 3, 6, 12 and 24 hours.

  • A delivery’s id is the same on every retry. Record the IDs you have handled and skip one you have seen.
  • Events can arrive out of order. When order matters, fetch the object (or verify the payment) for its current state.
  • Don’t give value on a webhook alone: verify the payment by its reference.

From the portal you can see every delivery and its attempts, send one again, or replay deliveries by mode, event and time. Deliveries are kept for 90 days.