· 6 min read
Webhooks you can trust: HMAC, retries and idempotency
Webhook authenticity beats webhook speed. How we sign deliveries, why the retry ladder is 1m→12h, and what your handler should do to be boring and correct.
In payment infrastructure, a webhook that lies is worse than a webhook that never arrives. If your fulfillment trusts an unauthenticated POST, anyone who can reach your endpoint ships free VPS. This post explains the three mechanisms that make ZeroKYC Pay webhooks safe to automate against.
1. Signatures: HMAC-SHA256 with a timestamp
Every delivery carries X-ZKP-Signature and X-ZKP-Timestamp. The signature is HMAC-SHA256(secret, timestamp + "." + raw_body) with your endpoint secret. Two details are load-bearing:
- Raw body. The signature covers bytes, not meaning. Parse JSON only after verification, or your framework's re-serialization will break legitimate deliveries.
- Timestamp window. Signed requests stay valid for 5 minutes. Without the window, an attacker who once captures a legitimate webhook could replay it forever; with it, replays die quietly.
Verify in constant time (timingSafeEqual, hash_equals, compare_digest) — the comparison itself is a side channel otherwise.
2. Retries: honesty about the internet
Endpoints have deploys, restarts and 2 a.m. incidents. Treating one failed delivery as lost money would be a lie about how networks behave, so deliveries retry on a ladder: 1m → 5m → 30m → 2h → 12h, then park as failed with a manual retry button in the console.
The flip side is at-least-once semantics: the same event may arrive twice. That is why every event carries a globally unique id — store it, dedupe on it, and delivery ambiguity disappears.
delivery contract
success → any 2xx within seconds
retry → 1m, 5m, 30m, 2h, 12h, then failed + manual retry
order → per-event, retries may interleave with new events
truth → GET /v1/invoices/{id} beats any webhook payload3. Rescan: the chain gets the last word
Webhooks are hints; the ledger is the chain. A rescan job re-checks every pending and detecting invoice every 10 minutes, so a watcher's missed block heals itself and the confirmation arrives late — but arrives. For merchants this means one rule: react to payment.confirmed, use payment.detected for UX only, and when in doubt, ask the API.
Boring is the feature
A webhook handler that verifies the signature, enforces the window, dedupes on event id and treats the API as the source of truth is boring — and boring handlers are what you want processing money. The full contract, with copy-paste snippets for Node, PHP and Python, lives in the webhooks reference and HMAC verification guide.