|$ curl https://forge-ai.dev/api/markdown?path=docs/backend/webhooks
$cat docs/webhooks.md
updated Recently·25 min read·published

Webhooks

BackendIntegrationIntermediate🎯Free Tools
Introduction

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.

Event Design

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.

webhook-event.json
JSON
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

Version your event payload with an api_version field. When you need to make breaking changes, introduce a new event version and let consumers opt in, rather than silently changing the shape.
HMAC Signatures

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.

webhook-signatures.ts
TypeScript
1import crypto from "crypto";
2
3const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
4
5export 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
13export 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

Never roll your own signature scheme. Use HMAC-SHA256 with a timestamped payload. Expose the secret only through a secure dashboard and rotate it periodically.
Sender Implementation

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.

webhook-sender.ts
TypeScript
1interface WebhookSubscription {
2 id: string;
3 url: string;
4 secret: string;
5 events: string[];
6 status: "active" | "disabled";
7}
8
9async 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
41async 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

Accept only 2xx status codes as successful delivery. Treat 3xx redirects with caution and 4xx errors as terminal unless they are rate-limit responses. Never retry on signatures that fail verification on your own sending side.
Receiver Implementation

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.

webhook-receiver.ts
TypeScript
1import express from "express";
2import rawBody from "raw-body";
3
4const app = express();
5
6app.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

Respond quickly. A slow receiver causes the provider to retry, which can create duplicate work and strain both systems. Use a queue or background job for anything that takes longer than a few hundred milliseconds.
Idempotency and Deduplication

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.

webhook-idempotency.ts
TypeScript
1import { Redis } from "ioredis";
2
3const redis = new Redis(process.env.REDIS_URL);
4
5async 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
16async 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

Make your webhook handlers idempotent at the business level too. If processing an event creates a record, use an upsert keyed by the event ID or a natural unique key rather than a plain insert.
Delivery Guarantees

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.

GuaranteeBehaviorRequirement
At-most-onceNo retries on failureTolerates lost events
At-least-onceRetries until successReceiver deduplicates
Exactly-onceNo duplicates, no lossIdempotency + durable logs
📝

note

Audit logs are essential for webhook debugging. Providers should log every delivery attempt with payload size, status code, and latency. Consumers should log receipt, verification, and processing outcomes.
Security Checklist

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.

ControlSenderReceiver
TLSRequire HTTPS URLsTerminate TLS at edge
SignatureSign with HMAC-SHA256Verify before processing
TimestampInclude event timeReject old requests
IP allowlistPublish egress IPsFilter by IP if possible
RetriesExponential backoffAck fast, process async

danger

Never process webhook events without signature verification. If a provider does not support signatures, treat the integration as low-trust and add independent validation before acting on events.
$Blueprint — Engineering Documentation·Section ID: BE-04·Revision: 1.0