Webhooks
Signed, retried delivery you can bet a business on — and the signature-verification code to paste into your server, in three languages.
The envelope
{
"id": "evt_2Ay0Zt7Rm9Kq",
"type": "payment.confirmed",
"created": 1787825400,
"api_version": "2026-08-27",
"data": {
"id": "pi_7Fk2Qd9RmTs4Vb",
"object": "payment_intent",
"status": "confirmed",
"amount": "10000",
"currency": "USD",
"received_amount": "100043100",
"metadata": { "order_id": "1234" }
}
}idis your idempotency handle. Delivery is at-least-once and unordered. Dedupe on it before doing any work; every retry of an event re-sends the same id.createdis unix seconds, not milliseconds.api_versionis pinned per endpoint at creation, so adding a field to the payload can never break your parser.- Amounts are strings. Parse them with a big-integer or decimal type.
Verify the signature
Every delivery carries a header of the form:
Payaider-Signature: t=1787824800,v1=5b2a9f6c1d7e4038ab5c9d2e1f0a3b4c...where v1 = HMAC_SHA256(endpoint_secret, "<t>.<raw_body>") — Stripe’s exact scheme, so the code you may already have works unchanged.
- Verify against the raw body.
JSON.stringify(JSON.parse(body))changes the bytes and every check fails. - Compare in constant time —
timingSafeEqual,hash_equals,compare_digest— never==. - Reject a timestamp more than 5 minutes old or ahead. Never set the tolerance to 0.
- Ignore any scheme that is not
v1, so a futurev2cannot be downgraded past you. - Accept a list of secrets. Rolling a secret gives you a 24-hour dual-secret overlap; one
v1element is emitted per active secret. - A retry gets a fresh signature with a fresh timestamp.
Node.js
const crypto = require('node:crypto');
// Express: mount the route with express.raw({ type: 'application/json' }) so that
// req.body is the RAW bytes. JSON.stringify(JSON.parse(body)) changes the bytes and
// every signature check against it fails.
function verifyPayaiderSignature(header, rawBody, secrets, toleranceSeconds) {
const tolerance = toleranceSeconds === undefined ? 300 : toleranceSeconds;
let timestamp = null;
const signatures = [];
for (const element of String(header || '').split(',')) {
const separator = element.indexOf('=');
if (separator === -1) continue;
const key = element.slice(0, separator).trim();
const value = element.slice(separator + 1).trim();
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value); // every other scheme is ignored
}
if (timestamp === null || !/^[0-9]+$/.test(timestamp) || signatures.length === 0) return false;
// Replay protection: reject a signature that is more than 5 minutes old or ahead.
const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
if (age > tolerance) return false;
const signedPayload = Buffer.concat([
Buffer.from(timestamp + '.', 'utf8'),
Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'),
]);
let valid = false;
for (const secret of secrets) {
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'),
'utf8',
);
for (const candidate of signatures) {
const received = Buffer.from(candidate, 'utf8');
// Constant time, and no early return: the work must not reveal which one matched.
if (expected.length === received.length && crypto.timingSafeEqual(expected, received)) {
valid = true;
}
}
}
return valid;
}
// app.post('/webhooks/payaider', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyPayaiderSignature(req.get('Payaider-Signature'), req.body, [process.env.PAYAIDER_WEBHOOK_SECRET])) {
// return res.status(400).send('bad signature');
// }
// const event = JSON.parse(req.body.toString('utf8'));
// // Delivery is at-least-once and unordered: dedupe on event.id before doing any work,
// // and fulfil on 'payment.confirmed' only — never on 'payment.detected'.
// if (alreadyProcessed(event.id)) return res.sendStatus(200);
// if (event.type === 'payment.confirmed') fulfillOrder(event.data.metadata.order_id);
// res.sendStatus(200); // ACK fast, then work asynchronously: the timeout is 10s.
// });
PHP
<?php
// $raw_body = file_get_contents('php://input'); // RAW body — never re-encode the JSON.
function payaider_verify_signature(string $header, string $raw_body, array $secrets, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $element) {
$pair = explode('=', trim($element), 2);
if (count($pair) !== 2) {
continue;
}
if ($pair[0] === 't') {
$timestamp = $pair[1];
} elseif ($pair[0] === 'v1') {
$signatures[] = $pair[1]; // every other scheme is ignored
}
}
if ($timestamp === null || !ctype_digit($timestamp) || count($signatures) === 0) {
return false;
}
// Replay protection: 5-minute tolerance, in both directions.
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$signed_payload = $timestamp . '.' . $raw_body;
$valid = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $signed_payload, $secret);
foreach ($signatures as $candidate) {
// hash_equals is constant time. No early break: keep the work uniform.
if (hash_equals($expected, $candidate)) {
$valid = true;
}
}
}
return $valid;
}
// $ok = payaider_verify_signature(
// $_SERVER['HTTP_PAYAIDER_SIGNATURE'] ?? '',
// file_get_contents('php://input'),
// [getenv('PAYAIDER_WEBHOOK_SECRET')]
// );
// if (!$ok) { http_response_code(400); exit('bad signature'); }
// $event = json_decode(file_get_contents('php://input'), true);
// Delivery is at-least-once and unordered: dedupe on $event['id'] before doing any work,
// and fulfil on 'payment.confirmed' only — never on 'payment.detected'.
Python
import hashlib
import hmac
import time
def verify_payaider_signature(header: str, raw_body: bytes, secrets: list[str], tolerance: int = 300) -> bool:
timestamp = None
signatures = []
for element in (header or "").split(","):
key, separator, value = element.strip().partition("=")
if not separator:
continue
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value) # every other scheme is ignored
if timestamp is None or not timestamp.isdigit() or not signatures:
return False
# Replay protection: 5-minute tolerance, in both directions.
if abs(int(time.time()) - int(timestamp)) > tolerance:
return False
signed_payload = timestamp.encode("utf-8") + b"." + raw_body
valid = False
for secret in secrets:
expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
for candidate in signatures:
# compare_digest is constant time. No early return: keep the work uniform.
if hmac.compare_digest(expected, candidate):
valid = True
return valid
# Flask / FastAPI: read the RAW body (request.get_data() / await request.body()) —
# never json.dumps(json.loads(body)), which changes the bytes and breaks the signature.
#
# if not verify_payaider_signature(request.headers.get("Payaider-Signature", ""),
# request.get_data(), [os.environ["PAYAIDER_WEBHOOK_SECRET"]]):
# return "bad signature", 400
# event = request.get_json()
# # Delivery is at-least-once and unordered: dedupe on event["id"] before doing any work,
# # and fulfil on "payment.confirmed" only — never on "payment.detected".
Every event
| Event | What to do |
|---|---|
payment.created | Informational or review-queue. |
payment.detected | Do not ship. 0-conf, can still be reorged out. |
payment.confirming | Informational or review-queue. |
payment.confirmed | Fulfil here. |
payment.underpaid | Informational or review-queue. |
payment.overpaid | Fulfil here. |
payment.paid_late | Informational or review-queue. |
payment.expired | Informational or review-queue. |
payment.canceled | Informational or review-queue. |
payment.failed | Informational or review-queue. |
Reserved for Phase 2 and unable to fire today: refund.created, refund.succeeded, refund.failed. They exist in the catalogue so their names are stable — do not build against them yet.
Retries
Only a 2xx counts as delivered. A redirect is a failure, as are 4xx, 5xx, TLS errors and timeouts. Failed deliveries are retried up to 8 attempts over about three days:
| Attempt | Scheduled | Elapsed |
|---|---|---|
| 1 | immediately | 0 |
| 2 | +1 minute after the previous attempt | < 1h |
| 3 | +5 minutes after the previous attempt | < 1h |
| 4 | +30 minutes after the previous attempt | ~1h |
| 5 | +2 hours after the previous attempt | ~3h |
| 6 | +6 hours after the previous attempt | ~9h |
| 7 | +12 hours after the previous attempt | ~21h |
| 8 | +24 hours after the previous attempt | ~45h |
- Respond within 10 seconds. ACK first and do your work asynchronously.
- After sustained failure the endpoint is disabled and you are emailed. The events stay queryable.
- Manual redelivery never cancels the automatic schedule, so both can arrive — which is fine, because you dedupe on
event.id. - Ordering is not guaranteed and duplicates can occur. Re-read the payment over REST as your recovery path.
The delivery log
curl "https://api.payaider.com/v1/webhook_endpoints/we_6Bd4Lq2Xs7Hf/deliveries?limit=25" \
-H "Authorization: Bearer sk_test_YOUR_KEY"Every attempt, with the status code, latency, and a truncated copy of your own response — which is usually enough to see the stack trace your handler threw.
Egress rules you should know about
- DNS is resolved on every attempt and private IP ranges are blocked, so a rebinding attack cannot turn your webhook URL into a probe of our network.
- Redirects are never followed. Point us at the final URL.
- The connection timeout is 10 seconds.
- In live mode the URL must be
https. Test mode allowshttpso a tunnel can reach your laptop.