Webhooks Are Product Promises
When you ship a webhook, you are making a reliability contract with every developer who builds on it. Here is what that contract requires.
A webhook is not just an API feature — it is a promise to every developer who builds a system on top of it. You are telling them: this event will fire when the thing happens, the payload will have a stable shape, and if your server is down, we will try again. When webhooks fail silently — no retries, no signing, no delivery logs — the damage is invisible until a customer notices their Slack notification never arrived, or their accounting system missed an order.
What the Minimum Viable Webhook Implementation Requires
Three things make a webhook trustworthy:
Signing. The receiver cannot verify the sender without a signature. Without it, any party on the internet can POST to your webhook endpoint with a crafted payload and trigger your automation. Signing uses a shared secret (HMAC-SHA256) to create a signature over the raw request body. The receiver recomputes the signature and rejects requests that do not match.
Retry with backoff. The receiver's server will sometimes be unavailable — deploys, rate limits, timeouts. A webhook that fires once and gives up is a webhook that will silently miss events. Exponential backoff with a cap (e.g., retry at 1s, 5s, 30s, 5m, 30m, giving up after 24h) is the standard.
Idempotency keys. Because retries happen, the receiver may process the same event more than once. Every webhook payload should include an event ID. Receivers use it to deduplicate: store the event ID on first processing, skip on subsequent deliveries.
Webhook Signature Verification
The sender signs the payload; the receiver verifies it. Both sides use the same shared secret.
Sender (Node.js):
import crypto from "crypto";
function signPayload(payload: string, secret: string): string {
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}
async function deliverWebhook(endpoint: string, event: WebhookEvent, secret: string) {
const body = JSON.stringify(event);
const signature = signPayload(body, secret);
const timestamp = Date.now().toString();
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": `sha256=${signature}`,
"X-Webhook-Timestamp": timestamp,
"X-Webhook-Event-Id": event.id,
},
body,
});
if (!res.ok) {
throw new Error(`Delivery failed: ${res.status}`);
}
}
Receiver (Express):
import crypto from "crypto";
import express from "express";
const app = express();
// Use raw body for verification — JSON middleware breaks signature
app.post(
"/webhooks/incoming",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-webhook-signature"] as string;
const timestamp = req.headers["x-webhook-timestamp"] as string;
const eventId = req.headers["x-webhook-event-id"] as string;
// Reject old timestamps to prevent replay attacks
const age = Date.now() - Number(timestamp);
if (age > 5 * 60 * 1000) {
return res.status(400).json({ error: "Timestamp too old" });
}
// Verify signature over raw body
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(req.body)
.digest("hex");
const received = signature.replace("sha256=", "");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.status(401).json({ error: "Invalid signature" });
}
// Deduplicate using event ID
if (processedEventIds.has(eventId)) {
return res.status(200).json({ status: "already_processed" });
}
processedEventIds.add(eventId);
const event = JSON.parse(req.body.toString());
// Handle the event
processEvent(event);
res.status(200).json({ status: "ok" });
}
);
The timestamp check prevents replay attacks — an attacker capturing a legitimate request and replaying it hours later. crypto.timingSafeEqual prevents timing attacks on the signature comparison.
Payload Shape Stability
When you change a webhook payload, every receiver that has built against the old shape breaks silently. The developer does not get a compile error — they get missing data at runtime, usually in production.
Treat webhook payload schemas the same as public API response schemas: additive changes only (new optional fields are safe), never remove or rename fields without a versioned migration path. Include a version or schemaVersion field in every event so receivers can handle multiple versions during a transition.
type WebhookEvent = {
id: string; // unique event ID for idempotency
type: string; // e.g., "order.created"
version: "1.0"; // schema version
createdAt: string; // ISO timestamp
data: Record<string, unknown>; // event-specific payload
};
Why Webhook Design Is UX
Your webhook consumers are developers. Their experience of your product is shaped by how reliable, predictable, and debuggable your webhooks are. A webhook that fires inconsistently forces them to build polling as a fallback — extra code, extra cost, a worse architecture.
The developer experience checklist for a webhook system:
- Delivery logs in the dashboard (what fired, when, what the response was)
- Manual replay UI for failed deliveries
- Test events that can be sent to any endpoint without triggering real side effects
- Clear documentation of every event type and its payload schema
Stripe's webhook implementation is the reference. Delivery logs are available in the dashboard. Payloads are signed. Event types are documented with example payloads. Retries happen automatically with backoff. Every one of those features is a developer experience decision that reflects on the product.
The promise is simple: build on our webhooks and they will work. The implementation is what backs that promise.