Signing Webhooks With HMAC, and Verifying Them

Webhooks flip the direction of trust. Normally your server authenticates callers; with webhooks, you are the caller, POSTing payment events to a customer's endpoint, and now they need to know the event genuinely came from you and not from anyone who guessed the URL. KibiPay signs every outbound webhook so a receiver can verify it — and interestingly, it signs them differently from how it authenticates inbound API calls.
Symmetric here, asymmetric there
Inbound API requests use asymmetric Ed25519 signatures, because many independent clients each hold their own private key and you only ever store their public keys. Webhook delivery is the mirror situation: there are two parties — KibiPay and one subscriber — who can share a secret. So webhooks are signed with HMAC-SHA256 using a per-subscription shared secret. Same RFC 9421 message-signature machinery, opposite key model, chosen to fit the trust topology of each direction. It's a nice illustration that "sign your requests" isn't one decision; the algorithm follows from who holds what.
What a signed delivery looks like
Each webhook POST carries the same RFC 9421 header set as the inbound API, computed with HMAC:
Date— the delivery timestamp.Content-Digest—sha-256=:<base64>:over the raw JSON body bytes.Signature-Input— the covered components plus a key id (fixed aswh1) andalg="hmac-sha256".Signature— the HMAC, base64, labelledsig1.
Two extra headers ride along: Content-Type: application/json, and X-Event-Id carrying the event's unique id. The signature base is built over the POST method, the digest of the compact JSON body, the receiver's host, and the date — so a receiver who recomputes the HMAC over what they actually received will only match if the body, destination and timing are all intact.
How a receiver verifies
Verification is the reverse of signing and uses the same shared secret: recompute the SHA-256 digest of the received body and check it against Content-Digest; rebuild the signature base from the request; compute the HMAC with your copy of the secret; and compare it against the Signature header using a constant-time comparison. The platform's own verifier pins the expected algorithm to hmac-sha256 rather than trusting the header — the same "don't let the message choose its own algorithm" discipline used on the inbound side.
Freshness and de-duplication
Two mechanisms guard against stale or repeated deliveries. First, verification enforces the same ±300-second Date skew window as the API, so a webhook captured and replayed much later simply fails the freshness check. Second, the X-Event-Id header lets a receiver de-duplicate: because webhook delivery retries, the same event may legitimately arrive more than once, and a receiver should treat the event id as the thing to process-once. (The one-time nonce burn used on inbound API requests is an inbound-side control; webhook freshness rests on the date window plus event-id de-duplication.)
Delivery that doesn't give up
Signing is only useful if the event actually arrives, so delivery retries on failure with an exponential backoff schedule — roughly 5 seconds, then 30, 2 minutes, 10 minutes, an hour, six hours, a day — before a delivery is finally dead-lettered. Every attempt is signed afresh with a current timestamp, so a delivery that succeeds on the fourth try an hour later still presents a valid, in-window signature. The event payload itself is a compact record — an event id and type, when it occurred, the tenant, and a data block with the payment's id, scheme, amount, currency, scheme reference, status and any reason — covering the full lifecycle from payment.created through payment.settled, payment.returned, payment.reversed and the rest.
The result is a webhook a customer can trust as much as an API response: verifiably from KibiPay, unaltered, recent, and safe to process exactly once.
The verification checklist
For anyone building a receiver, the verification steps compose into a short, strict checklist, and skipping any one of them reopens a hole. Recompute the SHA-256 of the exact bytes you received and compare against Content-Digest — this catches any tampering with the payload. Rebuild the signature base from the request and recompute the HMAC with your copy of the shared secret, comparing against Signature with a constant-time comparison so you don't leak timing information. Check the Date is within the allowed skew so an old capture can't be replayed much later. And treat X-Event-Id as the de-duplication key, because retries mean the same event legitimately arrives more than once and your handler must be safe to run twice. Reject anything that fails any step. Notice how each check defends a different property — integrity, authenticity, freshness, at-least-once-but-process-once — and only together do they let you treat a webhook with the same confidence as a response to a call you made yourself. That symmetry with the inbound API is intentional: verifying a KibiPay webhook uses the same mental model as KibiPay verifying your requests.