Fundamentals
Webhooks and long-running operations
Work that takes minutes should return 202 at once and finish in the background. Clients then poll an operation or receive a signed webhook when it is done.
Some work takes seconds, minutes or hours. Video transcoding, bulk exports and provisioning are examples. Holding an HTTP connection open that long is fragile. Instead, the server accepts the request, returns 202 Accepted, and does the work in the background. The client learns the result in one of two ways: by polling an operation resource, or by receiving a webhook, which is an HTTP call from the server to the client.
Concept
Before you start, a queue holds work until a worker picks it up. An HMAC is a keyed hash of a message, which proves the message came from someone who knows the key. See message queues and crypto basics.
The big idea
The async job pattern has three steps.
- The client sends
POST /v1/exports. The server validates it, puts a job on a queue, and returns 202 with an operation such asop_42. - The client polls
GET /v1/operations/op_42with backoff, or waits for a webhook. - When the job finishes, the operation holds the result or a link to it.
Webhooks flip the reliability problem. Now your server is the client, calling an endpoint you don't control. It may be slow, down or wrong.
Walking through it
A receiver must prove a webhook came from you and wasn't replayed. The sender signs the timestamp and body with a shared secret. The receiver recomputes the signature and compares.
import crypto from "node:crypto"
function sign(body, secret, ts) {
const v1 = crypto.createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex")
return `t=${ts},v1=${v1}`
}
function verifyWebhook(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
const ts = Number(parts.t)
if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false // replay protection
const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex")
const given = Buffer.from(parts.v1 ?? "", "hex")
const want = Buffer.from(expected, "hex")
return given.length === want.length && crypto.timingSafeEqual(given, want)
}
const secret = "whsec_demo"
const body = '{"type":"export.completed","id":"evt_1"}'
const now = Math.floor(Date.now() / 1000)
console.log("valid:", verifyWebhook(body, sign(body, secret, now), secret))
console.log("tampered:", verifyWebhook(body + " ", sign(body, secret, now), secret))Output:
valid: true
tampered: falseThe receiver checks that the timestamp is recent, so a captured request can't be replayed later. It compares signatures with timingSafeEqual, which takes the same time whether the first byte or the last byte differs.
Webhook delivery follows four rules that Stripe, GitHub and Shopify all use:
- Retry on failure. Keep retrying non-2xx or timed-out deliveries for hours or days, then alert the customer.
- Include an event ID. Delivery is at least once, so receivers must deduplicate.
- Don't promise order. Send timestamps or versions so receivers can ignore stale events.
- Respond fast. The receiver should queue the event and return 200. Doing the work inline makes it slow and triggers retries.
Common mistakes
- Comparing signatures with
===. A plain comparison can leak timing information. Use a constant-time comparison. - Skipping the timestamp check. Without it, anyone who captures one valid request can replay it forever.
- Doing heavy work before returning 200. The sender sees a timeout, retries, and the work runs twice.
Check yourself
- Why does the receiver check the timestamp? It blocks replays. A captured request is rejected after the tolerance window, even though its signature is still valid.
- Why does the webhook need an event ID? Delivery is at least once, so the same event may arrive more than once. The receiver uses the ID to ignore duplicates.
Key takeaways
- Long work returns 202 and an operation the client can poll.
- Webhooks are signed, retried, deduplicated and unordered.
- Verify the signature in constant time and reject old timestamps.
- Receivers should queue events and return 200 quickly.
Further learning
The idempotency page explains how to make the receiving side safe to run twice.
Related
API design
An API is a contract that outlives the code behind it. Design the styles, methods, pagination, errors and async flows so changes stay additive.
Message queues
A durable buffer between the code that creates work and the code that does it. Start here for the map of queue concepts.
Idempotency and retries
Making operations safe to retry, with idempotency keys, deduplicating consumers and jittered backoff, so at-least-once delivery never means a double charge.