Treat every webhook as an untrusted, repeatable event. Verify authenticity, acknowledge quickly, make processing idempotent, store enough context to retry safely, and expose failures to an operator. Delivery order and one-time execution should never be assumed unless the provider explicitly guarantees them.
- 01 · Provider → EndpointPOST signed eventRaw body + signature + timestamp
- 02 · Endpoint → EndpointVerify signature and timestampReject before any side effect
- 03 · Endpoint → DatabaseInsert event ID (unique)Duplicate → nothing inserted
- 04 · Endpoint → Provider2xx acknowledgementBefore slow work begins
- 05 · Database → WorkerClaim received event
- 06 · Worker → ProviderFetch current object stateWhen the event alone is not enough
- 07 · Worker → DatabaseApply change, mark processedSame transaction
- 08 · Worker → DatabaseOn error: attempts + 1, schedule retryBackoff with jitter, bounded
- 09 · Operator → WorkerReplay from dead-letterAfter the cause is fixed
Verify and acknowledge
Validate the provider signature against the raw request body, reject stale or invalid requests, and return the required success response quickly. Move slow work out of the request path when the platform supports it.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export async function POST(request: Request) {
// Verify the exact bytes that were signed — before JSON parsing.
const rawBody = await request.text();
const timestamp = request.headers.get("x-webhook-timestamp") ?? "";
const signature = request.headers.get("x-webhook-signature") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
return new Response("stale or missing timestamp", { status: 400 });
}
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const valid =
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!valid) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(rawBody);
await recordAndEnqueue(event); // idempotent insert, then background work
return new Response(null, { status: 204 });
}Header names and the exact signed string differ by provider. Where the provider ships an SDK helper — for example Stripe’s constructEvent — use it instead of hand-rolled verification.
Make processing idempotent
Use the provider event ID or a stable business key to prevent duplicate effects. Store processing state and design database writes so a retry cannot charge, notify, or mutate the same record twice.
create table webhook_events (
provider text not null,
event_id text not null,
event_type text not null,
received_at timestamptz not null default now(),
status text not null default 'received', -- received | processed | failed | dead_letter
attempts int not null default 0,
last_error text,
primary key (provider, event_id)
);
-- Record the event once. A duplicate delivery inserts nothing.
insert into webhook_events (provider, event_id, event_type)
values ($1, $2, $3)
on conflict (provider, event_id) do nothing
returning event_id;
-- No row returned → already seen: acknowledge and skip side effects.Apply the business change and mark the row processed in the same transaction, or make the downstream write idempotent too; otherwise a crash between the two can still double-apply.
Expect delay and reordering
Fetch current provider state when an event alone is insufficient. Model transitions explicitly and avoid assuming that creation, update, and completion events arrive once or in sequence.
Sent by the provider
subscription.createdsubscription.updated → active
Received by you
subscription.updated → activesubscription.created
updated time with what you stored: ignore older events, and fetch the object from the provider API when it does not exist locally yet.Build an operator recovery path
Record sanitized payload metadata, attempt count, error, and next action. Use bounded retries, a dead-letter state, alerting, replay controls, and reconciliation for events that cannot be processed automatically.
- 01receivedRecorded under a unique event ID
- 02processingClaimed by one worker✕ error: to 04 failed
- 03processedChange applied in the same transaction
- 04failedError and attempt count stored↺ back to 05 retry scheduled
- 05retry scheduledExponential backoff with jitter↺ attempt + 1: back to 02 processing✕ max attempts: to 06 dead_letter
- 06dead_letterAfter the maximum attempts◆ after fix: to 07 operator replay
- 07operator replayOne event or a batch, after the fix◆ replay: to 02 processing
- Success path
- Retry path
- Failure
- Operator recovery
Success, failure, and recovery paths all end in a recorded state — nothing disappears.
Retry and backoff
- 01Attempt 1
On delivery
- 02Attempt 2
After about 1 minute
- 03Attempt 3
After about 5 minutes
- 04Attempt 4
After about 30 minutes
- 05Attempt 5
After about 2 hours, then dead-letter
An example schedule, not a rule — add jitter, and tune the numbers to the provider’s own retry window.
Handle provider outages
When a provider is down or stops sending events, nothing arrives to fail — the risk is silence. Track the age of the last event per provider and alert when it exceeds what normal traffic allows.
Retry behavior differs by provider: Stripe documents automatic retries for up to three days, while GitHub does not automatically redeliver failed deliveries. Check the current documentation for each provider you depend on.
After recovery, backfill by listing recent objects or events through the provider API and passing them through the same idempotent path, so replayed and live events cannot double-apply. Keep the product usable in the meantime and mark provider-dependent data as possibly stale.
Failure scenarios
| Scenario | What you see | Handle it by |
|---|---|---|
| Secret rotated | A sudden spike of signature failures | Accepting both the old and new secret during the rotation window |
| Handler too slow | The provider marks deliveries failed and retries them | Acknowledging first and processing asynchronously |
| Duplicate delivery | The same event ID twice | A unique constraint; skip side effects on conflict |
| Event before the object exists | Lookup or foreign-key failure | Fetching the object from the provider API, or retrying later |
| Poison event | The same event fails on every attempt | Bounded retries, then dead-letter with the error attached |
| Provider outage | No events for an unusual period | A last-event-age alert and a backfill after recovery |
| Bug shipped in the handler | Many events failing at once | Fixing the cause, then replaying from dead-letter |
Each scenario maps to a control in the checklist below.
Implementation checklist
- Verify the signature against the raw body with a constant-time comparison
- Reject stale timestamps to limit replay attacks
- Return a 2xx quickly and move slow work to a job or queue
- Record the event ID under a unique constraint before side effects
- Make downstream writes idempotent as well
- Treat delivery order as unknown; compare versions or fetch current state
- Bound retries with exponential backoff and jitter
- Keep a dead-letter state with payload metadata and the last error
- Give an operator a way to replay one event or a batch
- Alert on failure spikes and on silence (last-event age)
- Log sanitized metadata — never secrets or full payment data
- Test with provider test events, duplicated deliveries, and reordered events
Common webhook mistakes
- Parsing and re-serializing JSON before verifying, which breaks the signature
- Doing slow work before responding, which causes timeouts and duplicate retries
- Using delivery order as business order
- Treating a 2xx response as proof that the work succeeded
- Retrying forever with no dead-letter state
- Having no way to replay events after fixing a bug
- Logging full payloads that contain personal data or secrets
Primary references
Provider behavior changes; check these before relying on a specific number.

