Webhooks
Webhooks are user-defined HTTP callbacks that notify external systems when an event occurs. Instead of polling an API repeatedly, a consumer registers a URL with a provider. When the event fires, the provider makes an HTTP request to that URL with a payload describing what happened.
Webhooks are the backbone of event-driven integrations. Payment processors use them to report successful charges, Git platforms use them to trigger continuous integration, and SaaS products use them to keep customer systems in sync. They are simple in concept but require careful design to be reliable and secure.
This guide covers both sides of webhooks: sending events as a provider and receiving events as a consumer. You will learn how to sign payloads with HMAC, implement retries, enforce idempotency, verify signatures, and design for delivery guarantees.
A well-designed webhook event is a snapshot, not a command. The payload should describe what happened and include enough information for the receiver to act, but it should not assume the receiver will perform a specific action. Include the event type, a timestamp, a unique event identifier, and the relevant resource data.
| 1 | { |
| 2 | "id": "evt_9c8f7e6d5b4a3210", |
| 3 | "type": "invoice.paid", |
| 4 | "api_version": "2025-01-01", |
| 5 | "created_at": "2026-07-20T14:32:10Z", |
| 6 | "data": { |
| 7 | "object": { |
| 8 | "id": "inv_1234567890", |
| 9 | "amount_due": 19900, |
| 10 | "currency": "usd", |
| 11 | "status": "paid", |
| 12 | "customer_id": "cus_abcdef", |
| 13 | "paid_at": "2026-07-20T14:32:05Z" |
| 14 | } |
| 15 | }, |
| 16 | "livemode": true, |
| 17 | "pending_webhooks": 0, |
| 18 | "request_id": "req_zx12qw34er56ty" |
| 19 | } |
info
Signature verification is the primary defense against forged webhook requests. The provider computes a hash-based message authentication code over the raw request body using a shared secret. The receiver recomputes the signature and compares it to the value in the request header.
Always sign the raw body bytes, not a parsed or re-serialized object. JSON parsers may change whitespace or key order, which would invalidate the signature. Use a constant-time comparison function to prevent timing attacks.
| 1 | import crypto from "crypto"; |
| 2 | |
| 3 | const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!; |
| 4 | |
| 5 | export function signPayload(payload: string, timestamp: number): string { |
| 6 | const signedPayload = `${timestamp}.${payload}`; |
| 7 | return crypto |
| 8 | .createHmac("sha256", WEBHOOK_SECRET) |
| 9 | .update(signedPayload) |
| 10 | .digest("hex"); |
| 11 | } |
| 12 | |
| 13 | export function verifySignature( |
| 14 | payload: string, |
| 15 | signature: string, |
| 16 | timestamp: number, |
| 17 | toleranceSeconds = 300 |
| 18 | ): boolean { |
| 19 | const now = Math.floor(Date.now() / 1000); |
| 20 | if (Math.abs(now - timestamp) > toleranceSeconds) { |
| 21 | throw new Error("Webhook timestamp outside tolerance"); |
| 22 | } |
| 23 | |
| 24 | const expected = signPayload(payload, timestamp); |
| 25 | return crypto.timingSafeEqual( |
| 26 | Buffer.from(expected, "hex"), |
| 27 | Buffer.from(signature, "hex") |
| 28 | ); |
| 29 | } |
danger
The sender, or provider, is responsible for delivering events reliably. Store every webhook attempt in a queue so failures can be retried. Use exponential backoff with jitter to avoid thundering herds. Stop retrying after a maximum number of attempts and mark the endpoint as disabled if it fails repeatedly.
| 1 | interface WebhookSubscription { |
| 2 | id: string; |
| 3 | url: string; |
| 4 | secret: string; |
| 5 | events: string[]; |
| 6 | status: "active" | "disabled"; |
| 7 | } |
| 8 | |
| 9 | async function deliverEvent( |
| 10 | subscription: WebhookSubscription, |
| 11 | event: WebhookEvent |
| 12 | ): Promise<void> { |
| 13 | const payload = JSON.stringify(event); |
| 14 | const timestamp = Math.floor(Date.now() / 1000); |
| 15 | const signature = signPayload(payload, timestamp, subscription.secret); |
| 16 | |
| 17 | try { |
| 18 | const response = await fetch(subscription.url, { |
| 19 | method: "POST", |
| 20 | headers: { |
| 21 | "Content-Type": "application/json", |
| 22 | "X-Webhook-Signature": `t=${timestamp},v1=${signature}`, |
| 23 | "X-Webhook-Id": event.id, |
| 24 | "User-Agent": "ForgeLearn-Webhook/1.0", |
| 25 | }, |
| 26 | body: payload, |
| 27 | signal: AbortSignal.timeout(30000), |
| 28 | }); |
| 29 | |
| 30 | await recordAttempt(subscription.id, event.id, response.status, "success"); |
| 31 | |
| 32 | if (response.status >= 500 || response.status === 429) { |
| 33 | throw new Error(`Retryable status: ${response.status}`); |
| 34 | } |
| 35 | } catch (err) { |
| 36 | await recordAttempt(subscription.id, event.id, 0, "failure", err.message); |
| 37 | await scheduleRetry(subscription.id, event.id); |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | async function scheduleRetry(subscriptionId: string, eventId: string) { |
| 42 | const attempts = await getAttemptCount(subscriptionId, eventId); |
| 43 | if (attempts >= 10) { |
| 44 | await disableSubscription(subscriptionId); |
| 45 | return; |
| 46 | } |
| 47 | const delayMs = Math.min(1000 * 2 ** attempts, 86400000) + Math.random() * 1000; |
| 48 | await enqueueRetry(subscriptionId, eventId, delayMs); |
| 49 | } |
best practice
The receiver must verify the signature, parse the event, and acknowledge quickly. Return a 2xx response as soon as you have accepted the event for processing. Do long-running work asynchronously to avoid timeouts that trigger unnecessary retries.
| 1 | import express from "express"; |
| 2 | import rawBody from "raw-body"; |
| 3 | |
| 4 | const app = express(); |
| 5 | |
| 6 | app.post("/webhooks/payments", async (req, res) => { |
| 7 | try { |
| 8 | const body = await rawBody(req); |
| 9 | const signature = req.headers["x-webhook-signature"] as string; |
| 10 | const eventId = req.headers["x-webhook-id"] as string; |
| 11 | |
| 12 | if (!signature || !eventId) { |
| 13 | return res.status(400).json({ error: "Missing signature or event id" }); |
| 14 | } |
| 15 | |
| 16 | const parsed = parseSignatureHeader(signature); |
| 17 | const isValid = verifySignature( |
| 18 | body.toString(), |
| 19 | parsed.signature, |
| 20 | parsed.timestamp |
| 21 | ); |
| 22 | if (!isValid) { |
| 23 | return res.status(401).json({ error: "Invalid signature" }); |
| 24 | } |
| 25 | |
| 26 | // Idempotency check |
| 27 | if (await isEventProcessed(eventId)) { |
| 28 | return res.status(200).json({ status: "already processed" }); |
| 29 | } |
| 30 | |
| 31 | // Acknowledge immediately, process asynchronously |
| 32 | await enqueueWebhookProcessing(eventId, body.toString()); |
| 33 | return res.status(202).json({ status: "accepted" }); |
| 34 | } catch (err) { |
| 35 | console.error("Webhook handling failed", err); |
| 36 | return res.status(500).json({ error: "Internal error" }); |
| 37 | } |
| 38 | }); |
warning
Webhooks are delivered at-least-once in practice. Network glitches, timeouts, and retries all cause the same event to arrive multiple times. Receivers must deduplicate using the event identifier. Store processed event IDs with a TTL long enough to cover your retry window.
| 1 | import { Redis } from "ioredis"; |
| 2 | |
| 3 | const redis = new Redis(process.env.REDIS_URL); |
| 4 | |
| 5 | async function isEventProcessed(eventId: string): Promise<boolean> { |
| 6 | const result = await redis.set( |
| 7 | `webhook:event:${eventId}`, |
| 8 | Date.now().toString(), |
| 9 | "NX", |
| 10 | "EX", |
| 11 | 86400 * 7 // keep for 7 days |
| 12 | ); |
| 13 | return result === null; // key already existed |
| 14 | } |
| 15 | |
| 16 | async function markEventProcessed(eventId: string): Promise<void> { |
| 17 | await redis.set( |
| 18 | `webhook:event:${eventId}`, |
| 19 | Date.now().toString(), |
| 20 | "EX", |
| 21 | 86400 * 7 |
| 22 | ); |
| 23 | } |
best practice
Webhook systems choose between at-most-once, at-least-once, and exactly-once delivery. At-most-once may lose events during outages. At-least-once is the most common choice because it ensures events are not lost while remaining simple to implement. Exactly-once is theoretically impossible over an unreliable network, but idempotency makes it effectively achievable.
| Guarantee | Behavior | Requirement |
|---|---|---|
| At-most-once | No retries on failure | Tolerates lost events |
| At-least-once | Retries until success | Receiver deduplicates |
| Exactly-once | No duplicates, no loss | Idempotency + durable logs |
note
Webhooks open an inbound HTTP path into your system. Treat it with the same rigor as any public API. Validate signatures, use TLS, restrict IPs when possible, and monitor for unusual patterns.
| Control | Sender | Receiver |
|---|---|---|
| TLS | Require HTTPS URLs | Terminate TLS at edge |
| Signature | Sign with HMAC-SHA256 | Verify before processing |
| Timestamp | Include event time | Reject old requests |
| IP allowlist | Publish egress IPs | Filter by IP if possible |
| Retries | Exponential backoff | Ack fast, process async |
danger