HTTP & APIs in Practice
HTTP & APIs in Practice

Sync vs async APIs: 202, push channels and webhooks

Full · ~40 min
After this unit you can
  • 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

Simple
1 / 2

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:

  1. Accept fast, with 202 and the status URL (here in Location and the body's jobId). Do the work later, off the request path — a queue and a worker.
  2. A status resource the client can GET as often as it likes: queued → running → succeeded (with where to find the result) or failed (with why).
  3. A hint for how often to ask. This API sends Retry-After: 2; RFC 9110 defines that header's meaning on 503 and redirects, so on a 202 it'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));
  }
}

Medium
1 / 2

Verifying a webhook — HMAC over the raw bytes

Flag

A 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.

Complex

Replays, racing duplicates, and the queue behind the 202

Flag

The 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-Key from 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?

Flag

Per RFC 9110, what should a 202 Accepted response contain?

Flag

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?

Flag

An SSE connection drops in the middle of a stream. What happens next, by default?

Flag

An Express app with app.use(express.json()) verifies webhooks by computing the HMAC of JSON.stringify(req.body). Genuine deliveries fail verification. Why?

Flag

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?

Flag

A provider retries failed deliveries with a fresh timestamp and signature each time. Which value should your handler dedupe on?

Flag

Your handler receives an event id it has already applied. What should it answer?

Flag

Fill in the blank

Accept the export and say where to check on it.

Flag
HTTP/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.

Flag
const 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.

Flag
if (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));
Flag
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 retries
Flag
async 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)]);
Flag

Challenge

Make the payment webhook safe to deliver twice

Flag

The 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 answered 200, 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 v1 signature matches "<t>.<raw body>" is applied once and answered 200 — 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 toleranceSeconds old gets 400, and nothing is applied
  • A retry of an applied event — same id, new timestamp and signature — gets 200 without applying it again
  • If apply throws, the answer is 500 and the event is not recorded, so a later delivery applies it

Loading the in-browser editor…

Further reading