Skip to content
abdullah.

Architecture / Engineering guide

Reliable webhook integration: retries, idempotency, and recovery.

A practical checklist for building webhook workflows that survive duplicates, delays, reordering, and provider failures.

By Muhammad Abdullah · Published September 27, 2026 · Updated September 30, 2026 · 6 min read

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.

  1. 01 · Provider → EndpointPOST signed eventRaw body + signature + timestamp
  2. 02 · Endpoint → EndpointVerify signature and timestampReject before any side effect
  3. 03 · Endpoint → DatabaseInsert event ID (unique)Duplicate → nothing inserted
  4. 04 · Endpoint → Provider2xx acknowledgementBefore slow work begins
  5. 05 · Database → WorkerClaim received event
  6. 06 · Worker → ProviderFetch current object stateWhen the event alone is not enough
  7. 07 · Worker → DatabaseApply change, mark processedSame transaction
  8. 08 · Worker → DatabaseOn error: attempts + 1, schedule retryBackoff with jitter, bounded
  9. 09 · Operator → WorkerReplay from dead-letterAfter the cause is fixed
Solid: request path. Dashed: responses and async hand-offs. Red: rejection and retry. Violet: operator action.

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.

app/api/webhooks/route.tsTypeScript
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.

webhook_events.sqlPostgreSQL
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

  1. subscription.created
  2. subscription.updated → active

Received by you

  1. subscription.updated → active
  2. subscription.created
Delivered in the opposite order. Compare the object’s version or 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.

Event lifecycleEvery event has a visible state
  1. 01receivedRecorded under a unique event ID
  2. 02processingClaimed by one worker✕ error: to 04 failed
  3. 03processedChange applied in the same transaction
  4. 04failedError and attempt count stored↺ back to 05 retry scheduled
  5. 05retry scheduledExponential backoff with jitter↺ attempt + 1: back to 02 processing✕ max attempts: to 06 dead_letter
  6. 06dead_letterAfter the maximum attempts◆ after fix: to 07 operator replay
  7. 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

  1. 01Attempt 1

    On delivery

  2. 02Attempt 2

    After about 1 minute

  3. 03Attempt 3

    After about 5 minutes

  4. 04Attempt 4

    After about 30 minutes

  5. 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

ScenarioWhat you seeHandle it by
Secret rotatedA sudden spike of signature failuresAccepting both the old and new secret during the rotation window
Handler too slowThe provider marks deliveries failed and retries themAcknowledging first and processing asynchronously
Duplicate deliveryThe same event ID twiceA unique constraint; skip side effects on conflict
Event before the object existsLookup or foreign-key failureFetching the object from the provider API, or retrying later
Poison eventThe same event fails on every attemptBounded retries, then dead-letter with the error attached
Provider outageNo events for an unusual periodA last-event-age alert and a backfill after recovery
Bug shipped in the handlerMany events failing at onceFixing 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.

See the approach in working products

WEBHOOK REVIEW

Need a webhook integration built or reviewed?

Share the provider, what is failing or missing, and how events reach your system today. I’ll help map the verification, idempotency, and recovery path.