Credentials and integrations

Verify webhooks

Authenticate webhook bytes, validate freshness, prevent replay, acknowledge safely, and process events idempotently.

A webhook endpoint is public by design. Anyone who finds the URL can POST convincing JSON at it. So the payload itself has to prove which provider sent it.

If your webhook marks invoices as paid, this is not optional. An unverified webhook is a free “mark as paid” button on the internet.

Verify the signature over the raw bytes

Most providers send an HMAC in a header: a hash of the body computed with a shared secret. You recompute it over the body you received and compare.

Here’s the check in Node.js:

import { createHmac, timingSafeEqual } from 'node:crypto'

function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = createHmac('sha256', secret).update(rawBody).digest()
  const received = Buffer.from(signatureHeader, 'hex')
  return expected.length === received.length &&
    timingSafeEqual(expected, received)
}

The raw body part is where implementations break. If your framework parses the JSON first and you re-serialize it, key order or whitespace changes and every signature fails. Or worse, you end up verifying bytes the provider never signed. Capture the body before any parser touches it.

timingSafeEqual matters too. A plain comparison that returns early on the first wrong byte leaks the correct signature through response timing. Slowly, but it does.

Freshness and replay

A valid signature proves origin and integrity. It doesn’t prove the event is new. Providers deliver the same event twice, and they retry when your response times out.

So check the timestamp tolerance, store event IDs, and make processing idempotent:

INSERT INTO webhook_events (event_id) VALUES ('evt_1PqR8s')
ON CONFLICT DO NOTHING;
-- zero rows -> already processed: acknowledge and stop

Zero rows inserted means you’ve seen this event. Acknowledge it and do nothing else. Without this, a duplicate payment.succeeded ships the order twice.

Acknowledge, then work

Respond with success only after you’ve durably accepted the event, for example after writing it to a table or a queue. If the processing is slow, do it asynchronously afterwards.

This cuts provider retries without pretending an in-memory queue can survive a process crash. If your process dies between the 200 and the work, the event is still in the table.

Then test the paths that must fail:

# unsigned payload aimed straight at the endpoint
curl -i -X POST https://api.example.com/webhooks/payments \
  -H "Content-Type: application/json" \
  -d '{"type":"payment.succeeded","id":"evt_fake"}'
# HTTP/1.1 401 Unauthorized — and no order was fulfilled

Check the second half of that comment. The 401 is good. The database showing no fulfilled order is the proof.

One more rule. Don’t log signatures or complete payloads. The webhook log needs the event ID and the outcome, not the customer data inside the event.

Try this on your own webhook: send a valid, a modified, an expired, a duplicate, an unsigned, and a wrong-key payload. Save each result. Prove exactly one side effect happened, and that the duplicate got a safe success response without doing anything twice.

Lesson completed