Skip to content

Webhooks: signing, replay protection, retries, idempotent receivers

A webhook arrives at least once and from anyone: check its bytes, its time and its id, then answer fast.

Predict first

Before you read: predict

ExamHall sends attempt.graded webhooks to a university's LMS and retries on the Standard Webhooks example schedule, 10 attempts over about 3 days. The LMS checks each delivery with this handler, where secret holds the right key:

ExamHall sends this body, byte for byte, and signs it at every attempt:

{
type": "attempt.graded",
timestamp": "2026-06-15T09:42:10Z",
data": {
attempt": "ECON-201-0042",
score": 78
  }
}

What happens to this delivery over the next 3 days?

Code for this question
const event = JSON.parse(raw);const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${JSON.stringify(event)}`;const expected = 'v1,' + createHmac('sha256', secret).update(signed).digest('base64');if (!timingSafeEqual(Buffer.from(headers['webhook-signature']), Buffer.from(expected))) return 401;await gradebook.post(event.data);return 200;
Pick one

The 30-second version

The sender repeats a webhook until it gets a 2xx, so one event can arrive twice. It signs the event id, the attempt's timestamp and the exact body with HMAC. Verify over the raw bytes, compare in constant time, refuse an old timestamp, skip an id you have seen, and answer before slow work.

A webhook arrives at least once and from anyone: check its bytes, its time and its id, then answer fast.

How it works

Step 1 of 11

At least once. Only a 2xx counts. A timeout, a reset or any other status is a failure, retried on a schedule spanning days.
In the kitchen
  • the haulier brings a cask back until the gatekeeper signs the docket → retries until a 2xx (steps 1, 2)

Analogy: A bonded whisky warehouse's goods-in gate

At the goods-in gate In the webhook
the haulier brings a cask back until the gatekeeper signs the docket retries until a 2xx (steps 1, 2)
the distillery's seal goes over the bung as filled, stamped with the cask number and the sealing date HMAC over id, time and body (step 3)
the gatekeeper checks the seal on the cask that arrived; spirit poured into a fresh cask carries no valid seal raw bytes (step 4)
a cask whose seal is dated last year is turned away old timestamp (step 6)
the ledger already lists the cask number: sign the docket, book nothing seen id (step 7)
the gatekeeper signs at once, and gauging waits for the warehouse team answer, then work (step 8)

In real terms: trust a webhook only once its bytes, its time and its id check out, and answer before the slow work.

Breaks down when: a skilled forger can copy a cask seal; an HMAC cannot be made without the secret, so the secret is the whole defence.

Contrasting case: a queue between your own services. No stranger can post to it, so the signature checks fall away; repeats and slow consumers stay.

Examples, run for real

4.1 ExamHall EXH-3670 → MR !3970: one signature per event, then one per attempt

ExamHall signed each attempt.graded delivery once, when the grade was released, and sent that same request at every retry (EXH-3670). On the first morning of finals a university's LMS was down for 40 minutes; it refuses timestamps more than 5 minutes from its clock. MR !3970 signs each attempt again with its own time.

4.1 EXH-3670, signed once per event: grades released while the LMS was down 300 · answered 503 while down 887 · refused as stale after it was back 2113 · grades landed 0 of 300
4.1 MR !3970, signed at every attempt: answered 503 while down 887 · refused as stale 0 · grades landed 300 of 300, each once · the last at 2:51:10 after the outage began

The id stays, the timestamp moves (steps 2, 6): an old signed request is refused, a re-signed attempt lands.

4.2 Proof domain, chat: a lost answer

A chat platform posts message.created to a translation bot. The bot's 200 for one message is lost when its proxy restarts.

4.2 attempt 1 at 0:00:00: signature ok · id new · translation queued · answer lost (reset)
4.2 attempt 2 at 0:00:05: id seen · answer 200 · translations 1

The bot answers the retry with 200 and translates nothing twice (step 7).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A chat platform posts thin member.joined and member.left events to a community bot, which keeps each channel's member list. An MR adds a set of seen values so that a repeat changes nothing. In one minute the bot receives: member.joined for user u7, whose 200 is lost on the way, so the platform sends it again 5 s later; then member.left for u7; then, in the same second, member.joined for u7 again. Each body is {"type":"member.joined","data":{"user":"u7"}} or the same with member.left.

Which value should the MR store in the set?

Pick one

Question 2 of 6

ExamHall's attempt.graded webhooks reach a university's LMS, which hands each grade to its gradebook through a job queue; the queue's worker posts each job once. ExamHall waits 30 s for an answer. During finals week, three things happen: ExamHall's staging copy, restored from a production backup, sends some production event ids signed with the staging secret, before production sends them; the LMS may restart between any two steps of its handler, once; and the gradebook once takes 40 s to accept a grade. A claim fails when its id is already claimed, and the handler then answers 200 and stops.

Which handler order posts every real grade exactly once, posts no staging grade, and never makes ExamHall time out?

Pick one

Question 3 of 6

ExamHall rotates a university's webhook secret. For 24 hours each delivery's webhook-signature header carries two signatures, the old one, a space, then the new one. The LMS has already switched to the new secret, and checks each delivery with this handler:

What does the LMS answer to the deliveries in those 24 hours?

Code for this question
const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${raw}`;const expected = 'v1,' + createHmac('sha256', secret).update(signed).digest('base64');if (!timingSafeEqual(Buffer.from(headers['webhook-signature']), Buffer.from(expected))) return 401;await gradebook.post(JSON.parse(raw).data);return 200;
Pick one

Question 4 of 6

A chat platform posts message.created webhooks to a moderation bot, waits 30 s for each answer and retries on the Standard Webhooks example schedule. After a signature check that passes, the bot runs:

A long message makes moderation.scan take 45 s; every other call returns at once.

How many verdicts does the bot post for that message?

Code for this question
if (seen.has(headers['webhook-id'])) return 200;const verdict = await moderation.scan(JSON.parse(raw).data);seen.add(headers['webhook-id']);await channel.post(verdict);return 200;
Pick one

Question 5 of 6

A university's LMS was down for four days of finals week, and ExamHall's sender gave up on 120 attempt.graded events. The LMS had posted 30 of those grades before it went down, but its answers never reached ExamHall. On Monday the LMS admin presses Replay for the 120 failed messages. The LMS refuses a timestamp more than 5 minutes from its clock and skips an id it has seen.

How many grades does the replay post?

Pick one

Question 6 of 6

A chat bot's team answers 410 Gone to every webhook during a 2-hour maintenance window, to mean "not now". The chat platform follows the Standard Webhooks status handling. 18 messages are posted in the channel, 12 during the window and 6 after it.

How many of the 18 reach the bot?

Pick one

Go deeper

Pro tip
  1. When asked what each webhook check is for, name what it stops: the timestamp stops replays, the id stops repeats, the signature stops strangers, and a fast answer stops needless retries. When: the interviewer asks about payment webhooks. Status: SELF; not run.
Source

Valid as of 2026-10-12. Every rule of the sender model is quoted from the specification in lib/hooks.mjs (R1–R5).

  • Standard Webhooks specification 1.0.0 (named in the curriculum row): https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md, read as raw text from raw.githubusercontent.com · standard-webhooks-spec.txt. "The content to be signed is therefore: msg_id.timestamp.payload."; "it's important to make sure that the payload sent is the same as the payload signed"; "many webhook consumers often accidentally parse the body as json, and then serialize it again, which can cause for failed verification"; "use a constant time comparison function to compare the calculated with the expected signature"; "Make sure to verify the webhook-timestamp header has a timestamp that is within some allowable tolerance of the current timestamp to prevent replay attacks."; "Use the webhook-id header as an idempotency key to prevent accidentally processing the same webhook more than once"; "Every time an attempt is retried the timestamp of the attempt is updated, while the timestamp of the original event remains the same."; "it remains the same no matter how many times a webhook that has failed is retried"; "A webhook delivery is considered successful if it was responded to with a 2xx status code (status codes 200-299), and it is considered a failure in any other scenario."; "Sender should disable the webhook endpoint, and stop sending it messages."; "A recommended request timeout value for webhooks is somewhere between 15 and 30s."; "It's recommended to retry delivery following a retry schedule spanning multiple days, with an exponential backoff."; "it's immensely important to give consumers a way to manually replay specific webhooks or failures within a range"; "The signature header is a space delimited list of signatures associated with this webhook." Supports steps 1–4 and 6–11.
  • Standard Webhooks site (named in the curriculum row as standardwebhooks.com): https://www.standardwebhooks.com/ · standard-webhooks-site.txt. "Every webhook implementation needs to protect themselves and their users from SSRF, spoofing, and replay attacks." Supports the lane note (SSRF belongs to sec-ssrf).
  • RFC 2104, HMAC: Keyed-Hashing for Message Authentication (named in the curriculum row): https://www.rfc-editor.org/rfc/rfc2104.txt · rfc2104.txt. "This document describes HMAC, a mechanism for message authentication using cryptographic hash functions." Supports step 3.
  • Node.js v24 documentation, Crypto, crypto.timingSafeEqual(a, b): https://nodejs.org/docs/latest-v24.x/api/crypto.html · node-crypto.txt. "This function does not leak timing information that would allow an attacker to guess one of the values."; "An error is thrown if a and b have different byte lengths." Not named by the row; it keys nothing.

Checked against the tools and docs as of 2026-10-12.