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.
Set up
Section titled “Set up”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.
What you receive
Section titled “What you receive”POST /hooks/catholicpay HTTP/1.1Content-Type: application/jsonX-CatholicPay-Event: account_number.paidX-CatholicPay-Delivery: 7d2f1a7e-4c1b-4e8e-9a3c-2f5b8c1d9e40X-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.
Verify the signature
Section titled “Verify the signature”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:
- Take the raw request body exactly as received, before any JSON parsing.
- Compute the HMAC over
t, a dot, and the raw body. - Compare with
v1in constant time. - Refuse a
tmore 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);}import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts.get("t", "0")) if not t or abs(time.time() - t) > 300: return False want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(want, parts.get("v1", ""))function verify(string $secret, string $header, string $rawBody): bool { parse_str(str_replace(',', '&', $header), $p); $t = (int) ($p['t'] ?? 0); if (!$t || abs(time() - $t) > 300) return false; $want = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($want, $p['v1'] ?? '');}func verify(secret, header string, rawBody []byte) bool { var t int64 var v1 string for _, p := range strings.Split(header, ",") { k, v, _ := strings.Cut(p, "=") switch k { case "t": t, _ = strconv.ParseInt(v, 10, 64) case "v1": v1 = v } } if t == 0 || math.Abs(float64(time.Now().Unix()-t)) > 300 { return false } m := hmac.New(sha256.New, []byte(secret)) fmt.Fprintf(m, "%d.", t) m.Write(rawBody) return hmac.Equal([]byte(hex.EncodeToString(m.Sum(nil))), []byte(v1))}Respond quickly, then work
Section titled “Respond quickly, then work”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.
Handle duplicates and order
Section titled “Handle duplicates and order”- A delivery’s
idis 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.
Missed some?
Section titled “Missed some?”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.