Webhooks
A webhook is an HTTPS POST we send to your server when something happens: a payment succeeds, a payout is paid… Add endpoints in Dashboard → Webhooks (test and live have separate endpoints).
Events
| Type | When |
|---|---|
payment.succeeded |
The money was received. Fulfil the order. |
payment.failed |
The payment did not go through. |
payment.requires_review |
We are checking the payment with the provider. Wait. |
checkout_session.completed |
A hosted checkout page was paid. |
payout.paid |
A payout reached the phone. |
payout.failed |
A payout failed; the amount is back in your balance. |
ping |
Sent by the "Send test" button. |
Payload
{
"id": "evt_8Qm2pXc4Lw0sVb7nTr5yKa9d",
"object": "event",
"type": "payment.succeeded",
"livemode": false,
"created_at": "2026-11-02T10:15:04.112Z",
"data": {
"object": { "id": "pay_3hX9…", "object": "payment", "status": "succeeded", "amount": 5000, "net": 4862, "reference": "order-1042", "…": "…" }
}
}
data.object is the same object as GET /v1/payments/{id} returns.
Verifying the signature
Every request carries:
Patronise-Signature: t=1767312000,v1=5f2c9a…e91a
Patronise-Event-Id: evt_8Qm2pXc4Lw0sVb7nTr5yKa9d
v1 is the hex HMAC-SHA256 of "<t>.<raw request body>", keyed with your endpoint's signing secret (whsec_…). To verify:
- Read the raw body before any JSON parsing.
- Split the header; take
tandv1. - Reject if
tis more than 5 minutes away from now. - Compute
HMAC_SHA256(secret, t + "." + rawBody)and compare it tov1in constant time.
Node.js (Express)
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/webhooks/patronise', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Patronise-Signature') || ''
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const expected = crypto
.createHmac('sha256', process.env.PATRONISE_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
const valid = fresh && parts.v1 && expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
if (!valid) return res.status(400).send('bad signature')
const event = JSON.parse(req.body)
if (event.type === 'payment.succeeded') {
// markOrderPaid(event.data.object.reference) — make it idempotent
}
res.sendStatus(200)
})
PHP
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_PATRONISE_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $raw, getenv('PATRONISE_WEBHOOK_SECRET'));
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($raw, true);
// if ($event['type'] === 'payment.succeeded') { … }
http_response_code(200);
Python (Flask)
import hmac, hashlib, os, time
from flask import request, abort
@app.post("/webhooks/patronise")
def patronise_webhook():
raw = request.get_data()
sig = dict(p.split("=", 1) for p in request.headers.get("Patronise-Signature", "").split(","))
expected = hmac.new(os.environ["PATRONISE_WEBHOOK_SECRET"].encode(),
f"{sig.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
if abs(time.time() - int(sig.get("t", 0))) > 300 or not hmac.compare_digest(expected, sig.get("v1", "")):
abort(400)
event = request.get_json()
return "", 200
Delivery rules
- Answer with any
2xxwithin 10 seconds. Do slow work after answering. - If you answer anything else, or time out, we retry with growing delays (30 s, 1 min, 2 min… up to 6 h between tries) for about 24 hours.
- The same event can arrive more than once, and events can arrive out of order. Use
idto skip duplicates, and trust thestatusindata.object. - Every delivery is listed in Dashboard → Webhooks → History, with the HTTP code your server returned and a Resend button.
- To test locally, expose your machine with
cloudflared tunnel --url http://localhost:3000orngrok http 3000.