Why production webhooks fail in surprising ways
A webhook is an HTTP callback, but designing it like an ordinary synchronous API route creates fragile integrations. The sender may retry after a timeout, deliver the same event more than once, send related events out of order, or redeliver an old event during recovery. Your application can also commit a business change and crash before returning a response, causing a perfectly legitimate retry.
The useful mental model is an untrusted, at-least-once message delivered over HTTP. A successful receiver does not promise that the business workflow has finished. It promises that the delivery was authenticated, validated, and stored durably enough for asynchronous processing. Stripe explicitly advises handling duplicate events, processing asynchronously, returning a 2xx before complex work, and not depending on event order in its webhook guidance.
Write the integration contract before the handler
- Which event types and schema versions are accepted?
- Which header or payload field is the provider's stable delivery identifier?
- How is the signature computed, and does the scheme include a signed timestamp?
- What response codes and timeouts trigger provider retries?
- Can events arrive more than once or out of order?
- How long can a delivery be retried or manually replayed?
- Which API can reconcile current state when an event is missing?
These answers are provider-specific. Do not make one generic verifier accept Stripe, GitHub, a CRM, and an internal service through guessed header names. Use a small provider adapter that normalizes only after authentication.
A reliable webhook architecture separates ingress from effects
- Bound the request: accept HTTPS, enforce method and content type, cap body size, and apply an endpoint-level traffic limit.
- Authenticate bytes: verify the provider's signature against the untouched raw body before parsing or trusting fields.
- Validate the envelope: parse JSON once, allowlist event types, validate the minimal schema, and extract the provider delivery ID.
- Persist once: insert into a durable inbox protected by a unique constraint on provider and delivery ID.
- Acknowledge: return the provider's expected success response immediately after the transaction commits.
- Process asynchronously: let workers apply idempotent domain changes, retry transient failures, and quarantine poison events.
- Reconcile: periodically compare local state with the provider's source-of-truth API.
| Boundary | It should guarantee | It should not do |
|---|---|---|
| HTTP ingress | Authenticity, size limits, schema gate, durable receipt | Call slow dependencies or perform the full workflow |
| Inbox | Deduplication, status, attempts, audit timestamps | Become an unbounded archive of sensitive payloads |
| Worker | Idempotent effects, bounded retries, observable outcomes | Assume arrival order equals business order |
| Reconciler | Repair missed or irrecoverable deliveries | Blindly overwrite newer local decisions |
If the receiver must also publish to Kafka, RabbitMQ, or another broker, avoid a database-then-broker dual write. Store a dispatch record in the same database transaction and publish it asynchronously, following the Node.js transactional outbox pattern.
Verify webhook signatures over the raw request body
Signature verification answers two questions: did a holder of the shared secret create this delivery, and were the signed bytes changed in transit? It does not make the payload correct for your business domain, guarantee uniqueness, or prove that a newer event has not already been processed.
GitHub signs the payload with HMAC-SHA256 and sends the result in X-Hub-Signature-256. Its validation documentation requires verification before processing and recommends a timing-safe comparison. HMAC itself is standardized in RFC 2104. A minimal GitHub-specific TypeScript verifier can look like this:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyGitHubSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret: string,
): boolean {
if (!signatureHeader?.startsWith("sha256=")) return false;
const hex = signatureHeader.slice("sha256=".length);
if (!/^[0-9a-f]{64}$/i.test(hex)) return false;
const received = Buffer.from(hex, "hex");
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest();
return received.length === expected.length &&
timingSafeEqual(received, expected);
}The length check matters because Node.js crypto.timingSafeEqual requires equal-length inputs. Keep the surrounding parsing and error path uniform; the function alone cannot make unrelated code timing-safe.
Provider SDKs are safer than invented compatibility
Stripe's scheme signs a timestamp and payload and supports secret rotation. Use its official library with the exact raw bytes, signature header, and endpoint secret:
const event = stripe.webhooks.constructEvent(
rawBody,
request.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET!,
);Stripe documents that JSON parsing, whitespace changes, key reordering, or encoding changes can break verification; see its raw-body troubleshooting guide. Configure framework body parsing so the webhook route receives a bounded Buffer. Parse JSON only after verification. Store production and test secrets separately, support overlapping secrets during rotation when the provider does, and never log a secret or complete signature.
Persist a durable, deduplicated webhook inbox
Use the sender's stable delivery ID when one exists. GitHub recommends X-GitHub-Delivery and keeps it the same for a redelivery, according to its webhook best practices. For Stripe, log processed event IDs; its documentation also explains the separate-object case where event type plus object ID may be the relevant semantic duplicate.
Enforce deduplication in the database, not with a check-then-insert in application code. Concurrent deliveries can both pass a prior lookup. PostgreSQL's INSERT ... ON CONFLICT uses the unique constraint as the concurrency boundary:
CREATE TABLE webhook_inbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
provider text NOT NULL,
delivery_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
last_error_code text,
UNIQUE (provider, delivery_id)
);
INSERT INTO webhook_inbox
(provider, delivery_id, event_type, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, delivery_id) DO NOTHING
RETURNING id;If the insert returns a row, the event is pending. If it returns nothing, fetch only the existing status needed for metrics and return the same successful acknowledgement; a duplicate should not repeat the effect. Keep the transaction small. If the database commit fails, return a retryable failure according to the provider's documented behavior.
Payloads may contain personal, financial, or confidential data. Store only what replay and audit actually require, encrypt according to your threat model, restrict operator access, and apply a documented retention policy. For some integrations, a payload hash plus normalized fields and provider object ID is enough; the worker can fetch current state from the provider.
Process retries, ordering, and poison events deliberately
Claim work with bounded concurrency
A database-backed worker can claim pending rows with FOR UPDATE SKIP LOCKED, mark ownership with a lease, and process a bounded batch. A managed queue can provide similar leasing and visibility-timeout semantics. In either case, cap concurrency per dependency and tenant so one noisy integration cannot exhaust database connections or third-party rate limits. Our Node.js background-jobs guide covers worker shutdown, backpressure, and retry operations in more depth.
Classify failures instead of retrying everything
- Transient: dependency timeout, temporary rate limit, or short database outage. Retry with exponential backoff, random jitter, and a maximum delay.
- Permanent: unsupported event, invalid post-verification schema, or deleted destination. Mark ignored or failed with a safe reason.
- Ambiguous: a downstream call timed out after it may have committed. Reconcile by idempotency key or read current state before retrying.
- Poison event: repeated deterministic failure. Move it to a quarantined state and alert; do not let it block the queue.
Set an attempt and age budget based on the business process, not a fashionable retry count. A replay tool should record the operator, reason, selected handler version, and outcome. Replaying must use the stored verified event or a freshly fetched provider object—never an edited payload silently passed off as the original.
Do not infer business order from arrival order
Stripe states that it does not guarantee events arrive in generation order. Network retries and parallel delivery create the same issue with many providers. Prefer handlers that converge on current state: fetch the authoritative object, compare a provider version when available, or apply an explicit domain state machine. Serialize by aggregate only when the key and ordering contract are trustworthy. A timestamp alone is usually not a complete conflict-resolution strategy.
Run a scheduled reconciliation job for high-value state such as subscriptions, payments, fulfilment, permissions, or CRM ownership. The webhook provides low-latency notification; reconciliation provides eventual repair.
Harden the webhook endpoint as a public trust boundary
- Require HTTPS and keep certificate validation enabled.
- Use a distinct, high-entropy signing secret per provider, environment, and endpoint.
- Load secrets from server-side environment variables or a secret manager; our secret-management guide explains the deployment boundary.
- Apply body-size, header-size, connection, and request-time limits before expensive work.
- Verify signatures before JSON parsing, schema validation, tenant lookup, or side effects.
- Enforce signed timestamp freshness when the provider's scheme supports it, while keeping server clocks synchronized.
- Allowlist event types and validate the minimal schema required by each handler.
- Use provider IP ranges only as defence in depth; ranges change and proxies can obscure the peer address.
- Never put API keys in the webhook URL, log raw sensitive payloads, or expose internal exception details.
- Separate ingress credentials from downstream API credentials and grant workers the minimum domain permissions.
For multi-tenant integrations, map the endpoint or provider account to the correct secret before trusting any tenant identifier inside the payload. Secret rotation should accept both old and new secrets only for a bounded overlap, record which key version verified the request, and remove the old secret on schedule.
Observe the delivery lifecycle, not only HTTP status
Instrument each transition with low-cardinality provider and event-type labels: requests received, signature failures, schema rejects, new inbox rows, duplicates, acknowledgement latency, oldest pending age, claim latency, processing duration, retry count, quarantined events, reconciliation drift, and replay outcomes. Avoid delivery IDs, object IDs, customer IDs, or full URLs as metric labels.
Correlate a safe delivery ID hash from ingress through worker logs and traces. Alert on pending age and reconciliation mismatches as well as error rate; a handler can return perfect 2xx responses while its worker is stalled. Dashboards should make it possible to answer: Was the event received? Was it authentic? Was it deduplicated? Which handler version ran? What domain state changed? Can it be safely retried?
Teams building payment, SaaS, ecommerce, CRM, or partner integrations can use Endurance Softwares' repository-supported Node.js backend and webhook development services for receiver architecture, implementation, testing, and operational rollout.
Test failure timing, not just valid JSON
- Verify official provider fixtures and independently computed signature test vectors.
- Mutate one payload byte, remove the signature, use the wrong secret, and submit malformed encodings.
- Send an oversized body and confirm rejection happens before buffering excessive data.
- Deliver the same ID concurrently and assert one inbox row and one domain effect.
- Send related events in reverse order and confirm the final state converges correctly.
- Crash after the inbox commit but before the HTTP response; the retry must deduplicate.
- Crash a worker after a downstream effect but before marking success; the retry must remain safe.
- Simulate dependency timeouts, rate limits, permanent validation errors, and a poison event.
- Exercise secret rotation with both keys during overlap and rejection after retirement.
- Pause workers, build a backlog, resume them, and verify bounded recovery without dependency overload.
- Replay an event through the operator workflow and confirm the audit record and permissions.
- Delete or delay a delivery, then prove reconciliation repairs the missing state.
Use provider CLIs or dashboard redelivery tools for end-to-end staging tests, but keep deterministic local fixtures in version control without real secrets or customer payloads. Contract tests should pin the fields your handler needs and tolerate documented additive fields.
Production webhook checklist
✓ Document provider signatures, IDs, retries, and ordering
✓ Verify the bounded raw body before parsing
✓ Keep secrets server-side and rotate them safely
✓ Allowlist event types and validate minimal schemas
✓ Commit a unique durable inbox row before 2xx
✓ Make domain effects idempotent independently
✓ Process asynchronously with bounded concurrency
✓ Classify retries and quarantine poison events
✓ Provide audited replay and reconciliation paths
✓ Monitor pending age, duplicates, drift, and outcomes
Make integrations recoverable by design
A production webhook system is not a clever route handler. It is a small message-processing platform with explicit trust, durability, idempotency, and repair boundaries.
Discuss your integration architecture