Sync vs async APIs: 202, push channels and webhooks
- Choose between a synchronous response and
202 Accepted+ a status URL for long-running work, and write the polling contract - Compare polling, long-polling, Server-Sent Events and WebSockets by direction, connection cost and reconnect behaviour
- Verify a webhook: an HMAC over the raw body and timestamp, compared by the platform, inside a replay window
- Make a webhook handler idempotent under at-least-once delivery: dedupe by event id, record it only after the work succeeds, answer duplicates with
2xx
Read
Don't make them wait — 202 Accepted and a status URL
Flag"Export all orders as CSV" takes four minutes. Answering it inside one request means a connection held open for four minutes through every proxy, load balancer and serverless timeout between client and server — and if any of them gives up, the client gets an error while the work may well finish anyway.
HTTP's answer is to split accepted from done. RFC 9110 defines 202 Accepted: the request
"has been accepted for processing, but the processing has not been completed", and the response
"ought to describe the request's current status and point to (or embed) a status monitor". A
local API, for real:
$ curl -i --json '{"month":"2026-09"}' http://localhost:4123/reports
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /reports/jobs/7
Retry-After: 2
…
{"jobId":"7","status":"queued"}
$ curl -s http://localhost:4123/reports/jobs/7
{"jobId":"7","status":"running"}
$ curl -s http://localhost:4123/reports/jobs/7
{"jobId":"7","status":"succeeded","resultUrl":"/reports/7"}
The contract has three parts, and the client depends on all of them:
- Accept fast, with
202and the status URL (here inLocationand the body'sjobId). Do the work later, off the request path — a queue and a worker. - A status resource the client can
GETas often as it likes:queued→running→succeeded(with where to find the result) orfailed(with why). - A hint for how often to ask. This API sends
Retry-After: 2; RFC 9110 defines that header's meaning on503and redirects, so on a202it's a convention your docs must state. Some APIs put a suggested interval in the body instead.
A polling client, then:
async function waitForReport(statusUrl: string): Promise<string> {
for (;;) {
const res = await fetch(statusUrl);
if (res.ok) {
const job = await res.json();
if (job.status === "succeeded") return job.resultUrl;
if (job.status === "failed") throw new Error(job.error);
} else if (res.status !== 429 && res.status !== 503) {
throw new Error(`status check failed: ${res.status}`); // a 404 won't fix itself
}
const hint = res.headers.get("Retry-After") ?? "";
const seconds = /^\d+$/.test(hint) ? Number(hint) : 2; // an HTTP date → the default
await new Promise((r) => setTimeout(r, seconds * 1000));
}
}
Verifying a webhook — HMAC over the raw bytes
FlagA webhook endpoint is a public URL that changes your data when it's called: "payment succeeded →
mark the order paid". Anyone who finds the URL can POST to it. Providers therefore sign each
delivery with a secret only you and they share, and your first job is to check that signature.
The common scheme: HMAC-SHA-256
GitHub's version: the X-Hub-Signature-256 header is sha256= followed by the hex HMAC-SHA-256 of
the payload, keyed with your webhook secret. GitHub's docs give a test vector — secret
It's a Secret to Everybody, payload Hello, World!, signature
757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 — and this Web Crypto version
reproduces it:
const encoder = new TextEncoder();
async function verifyGitHubSignature(secret: string, rawBody: string, header: string | null) {
if (!header?.startsWith("sha256=")) return false;
const given = hexToBytes(header.slice("sha256=".length)); // null if it isn't hex
if (!given) return false;
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
return crypto.subtle.verify("HMAC", key, given, encoder.encode(rawBody));
}
crypto.subtle.verify recomputes the HMAC and compares it for you. If you compute the hex yourself
and compare strings, GitHub's docs say "never use a plain == operator" — use a constant-time
comparison such as Node's crypto.timingSafeEqual.
Sign the bytes you received, not the object you parsed
The signature covers the exact bytes of the body. Re-serialising changes them:
const raw = '{"id": "evt_1", "type": "payment.succeeded"}'; // 44 characters, with spaces
JSON.stringify(JSON.parse(raw)); // 41 characters, without
Verified against the HMAC of raw, the re-serialised string fails. That's why Stripe's docs warn
that "any manipulation to the raw body of the request causes the verification to fail" — and why a
framework that parses JSON before your handler runs must be told to hand this one route the raw
body.
Add a timestamp, and the signature expires
A signature alone can be replayed: capture one valid delivery, send it again tomorrow. Stripe
signs timestamp + "." + body and sends the timestamp alongside (Stripe-Signature: t=…,v1=…).
Because the timestamp is inside the signature, an attacker can't change it, and the receiver
rejects deliveries older than a tolerance — five minutes by default in Stripe's libraries, which
also warn that a tolerance of 0 disables the check entirely.
Then acknowledge quickly
Providers time out slow endpoints and treat that as a failure: GitHub expects a 2xx within
10 seconds. Verify, record the event, return 2xx — and do the slow work (emails, invoices) from a
queue afterwards. The next example is what "record the event" has to handle.
Replays, racing duplicates, and the queue behind the 202
FlagThe webhook handler from the last example is correct for one delivery at a time. Production delivers neither one at a time nor only from the provider.
A retry is not a replay — tell them apart by id and time
Stripe generates a new timestamp and a new signature for every delivery attempt. So:
- Deduping on the signature (or on the whole header) treats each retry as a new event — the duplicate gets processed. Dedupe on the event id.
- A captured delivery replayed by an attacker carries an old timestamp, and changing it breaks the signature. The tolerance window (Stripe's libraries default to five minutes) is what rejects it — before the id check, and without storing anything.
Order the checks: verify the signature over timestamp.rawBody → reject a stale timestamp → then
dedupe by id → then do the work. Each check only trusts what the previous one proved.
Two copies at the same moment
if (await processedEvents.has(event.id)) return 200; // both deliveries see "no"...
await markOrderPaid(event.data.orderId); // ...and both do the work
await processedEvents.add(event.id);
A retry can overlap a slow first attempt, and two workers can pull the same message. Check-then-act
is a race. Let the database decide instead: insert the event id first into a table with a unique
key and a status (processing), let the losing insert fail, and run the work in the same
transaction as marking it done — or roll the row back if the work fails, so a retry can try
again. The rule from the last example still holds: nothing is "done" until the work is.
The queue between accepted and done
The same shape sits behind every 202 Accepted: the request handler writes a job to a queue (a
table, Redis, a managed queue) and returns; a worker picks the job up later. Most queues also
deliver at least once — a worker that crashes mid-job, or acknowledges too late, gets the job
redelivered. So the worker needs the same discipline as the webhook handler:
- an idempotency key per job (the job id, or the client's
Idempotency-Keyfrom the request that created it), checked under a unique constraint; - side effects that are safe to repeat, or recorded together with the key;
- a retry limit, then a dead-letter place for jobs that keep failing, so one poisoned message doesn't block the queue or retry forever;
- the job's status written where the polling endpoint can read it.
That's the end-to-end picture: the client's Idempotency-Key makes its POST safe to retry, the
202 hands back a status URL, the queue delivers at least once, and every consumer — worker or
webhook handler — dedupes by id and records success only after the work.
Quiz
"Export all orders as CSV" takes about four minutes. Which API shape fits?
FlagPer RFC 9110, what should a 202 Accepted response contain?
A browser dashboard shows order counts that the server pushes as they change. The browser never sends anything back on that channel. What's the simplest fit?
FlagAn SSE connection drops in the middle of a stream. What happens next, by default?
FlagAn Express app with app.use(express.json()) verifies webhooks by computing the HMAC of JSON.stringify(req.body). Genuine deliveries fail verification. Why?
A provider signs timestamp + "." + body and sends the timestamp in the header. Why does that stop a replayed delivery when a signature over the body alone doesn't?
A provider retries failed deliveries with a fresh timestamp and signature each time. Which value should your handler dedupe on?
FlagYour handler receives an event id it has already applied. What should it answer?
FlagFill in the blank
Accept the export and say where to check on it.
FlagHTTP/1.1 Accepted : /exports/jobs/7 Content-Type: application/json {"jobId":"7","status":"queued"}
A server-sent event stream: the response's media type, and the browser API that reads it.
Flag// server res.writeHead(200, { "Content-Type": "text/" }); // browser const source = new ("/orders/stream"); source.onmessage = (e) => render(JSON.parse(e.data));
Sign-check the exact bytes, then reject stale deliveries.
Flagconst rawBody = await request.text(); // the bytes exactly as received const signedPayload = `${timestamp}.${}`; if (!(await verifyHmacSha256Hex(secret, signedPayload, signature))) return 400; if (nowSeconds - timestamp > ) return 400; const event = JSON.parse(rawBody); // only now is it worth parsing
Dedupe on the right key, and record it at the right moment.
Flagif (processed.has(event.)) return 200; await markOrderPaid(event.data.orderId); processed.(event.id); return 200;
Predict the output
const raw = '{"id": "evt_1", "type": "payment.succeeded"}';
const signature = await hmacHex(secret, raw); // what the provider sent
const body = JSON.parse(raw); // what the framework hands you
console.log(await verifyHex(secret, JSON.stringify(body), signature));async function handle(event) {
if (processed.has(event.id)) return 200;
processed.add(event.id);
try { await markOrderPaid(event.data.orderId); } // throws the first time: DB down
catch { return 500; }
return 200;
}
console.log(await handle(event), await handle(event)); // the provider retriesasync function handle(event) {
if (await processedEvents.has(event.id)) return 200; // a database read
await markOrderPaid(event.data.orderId);
await processedEvents.add(event.id);
return 200;
}
// A retry arrives while the first delivery is still running:
await Promise.all([handle(event), handle(event)]);Challenge
Make the payment webhook safe to deliver twice
FlagThe payment provider calls POST /webhooks/payments whenever a payment succeeds, and
handleWebhook marks the order paid. Each delivery carries
webhook-signature: t=1792219294,v1=5257a869e7ec…
where v1 is the hex HMAC-SHA-256, keyed with the endpoint secret, of the string
"<t>.<raw request body>". The provider retries any delivery that doesn't get a 2xx — with a
new timestamp and a new signature each time — so the same event can arrive several times, and
a captured delivery could be sent again by anyone.
Support tickets this week:
- Payments whose webhook body was pretty-printed are never marked paid: every delivery is
400. - A security review replayed a two-week-old delivery and it was accepted.
- Some orders get two "payment received" emails.
- During yesterday's database blip, deliveries failed with
500, and our ingestion queue re-ran them a minute later — the very same stored requests. Every re-run was answered200, yet those orders are still unpaid.
signature.ts already parses the header and checks an HMAC (verifyHmacSha256Hex, which lets Web
Crypto do the comparison). The decisions are yours.
Our ingestion queue hands handleWebhook one delivery at a time, so two copies of an event never
run at once here and an in-memory processed set is enough. With several workers it wouldn't be:
that's the check-then-act race from the reading, fixed with a unique insert in the database.
Your task
Edit webhook-handler.ts so the tests pass:
- A delivery whose
v1signature matches"<t>.<raw body>"is applied once and answered200— whatever whitespace the body has - A wrong signature, an edited body or a missing header gets
400, and nothing is applied - A correctly signed delivery more than
toleranceSecondsold gets400, and nothing is applied - A retry of an applied event — same
id, new timestamp and signature — gets200without applying it again - If
applythrows, the answer is500and the event is not recorded, so a later delivery applies it
Further reading
- RFC 9110: HTTP Semantics (202 Accepted, Retry-After)
- RFC 6202: Long Polling and Streaming in Bidirectional HTTP
- HTML Standard: Server-sent events
- MDN: Using server-sent events
- MDN: The WebSocket API
- RFC 6455: The WebSocket Protocol
- GitHub Docs: Validating webhook deliveries
- GitHub Docs: Best practices for using webhooks
- Stripe Docs: Receive webhook events (signatures, retries, duplicates)
- MDN: SubtleCrypto.verify()