How a webhook works
A webhook is an HTTP request that a provider sends to a URL you register whenever a chosen event happens, such as a payment succeeding. Your endpoint receives a JSON description of the event and answers with a 2xx status to confirm it arrived.
Setup happens once. You register an HTTPS endpoint with the provider, choose the event types you want, and store the signing secret the provider gives you. From then on, the provider pushes events to you instead of waiting for you to ask.
A delivery usually carries an event ID, an event type, a timestamp and the affected object. Header names vary by provider.
POST /webhooks/payments HTTP/1.1
Host: shop.example.com
Content-Type: application/json
X-Webhook-Timestamp: 1767222000
X-Webhook-Signature: 5f2b9c...
{
"id": "evt_7Hq2Lk",
"type": "payment.succeeded",
"created": 1767222000,
"data": { "payment_id": "pay_4410", "amount": 2500, "currency": "usd" }
}Verify the signature before trusting the payload
A webhook endpoint is a public URL, so anyone can send it a request. Providers sign each delivery so you can tell theirs apart. They compute an HMAC (a keyed hash such as HMAC-SHA256) over the body, often with a timestamp, using a secret only you and they know.
The header names and the exact signed string differ between providers, so follow each provider's documentation. Keep the secret in server-side configuration and rotate it if it leaks.
Your endpoint recomputes the HMAC and compares it with the signature header. Three details decide whether that check holds:
- Compute the HMAC over the raw body bytes exactly as received. Parsing the JSON and serializing it again can change whitespace or key order, and the signature will no longer match.
- Compare signatures in constant time, with a function such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python. An ordinary string comparison can leak, through its timing, how many leading characters matched.
- If the signature is missing or doesn't match, reject the delivery with a 4xx status and don't process it.
import crypto from 'node:crypto';
// rawBody is a Buffer holding the body exactly as received, before JSON parsing.
function isValidSignature(rawBody, timestamp, signatureHex, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody)
.digest();
const received = Buffer.from(signatureHex, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}Reject old deliveries to stop replays
A valid signature proves the provider produced the content. It doesn't prove the request is fresh: someone who captures a delivery can send the same bytes again later, and the signature still matches.
When the provider includes a timestamp in the signed content, reject deliveries whose timestamp is more than a few minutes from your server's clock. Keep server clocks synchronized with NTP so honest deliveries pass.
A tolerance window still allows a replay inside it, so also record the IDs of events you have processed and ignore repeats. The same record handles the provider's own retries.
Respond quickly, then process asynchronously
Providers wait only a short time for your response, and they treat a timeout or a non-2xx status as a failed delivery. Slow work inside the request handler, such as sending email or calling other APIs, turns into timeouts and repeated deliveries.
Keep the handler short. Verify the signature, check the timestamp, store the event or put it on a queue, and return 200 or 204. A background worker then does the processing.
Return an error status only when you want the provider to try again, for example when you couldn't store the event.
Expect retries, duplicates and events out of order
Providers retry failed deliveries, typically with growing delays over hours or days. That makes delivery at-least-once: the same event can arrive twice, for example when your response was lost after you had processed the event.
Make the handler idempotent by using the event ID as an idempotency key. A unique constraint on the stored ID turns a duplicate into a no-op instead of a second shipment or a second email.
Arrival order isn't reliable either: a refund event can arrive before the payment event it refers to. Compare timestamps or fetch the current state from the API before acting, and reconcile periodically in case an outage outlasted the retry schedule.
Webhooks or polling
Polling means calling the API on a schedule to ask whether anything changed. It is simple and needs no public endpoint, but it spends requests when nothing changed, counts against rate limits, and delays updates by up to one interval.
Webhooks deliver changes within moments and send nothing when nothing happens. In return you run a public HTTPS endpoint and handle signatures, retries and outages. Many integrations combine the two: a webhook triggers the work, an API call fetches current state, and an occasional poll catches anything missed.
For streaming updates to a browser or app, WebSockets and server-sent events keep one connection open instead of sending separate requests.