Idempotent Webhook Handlers with Delivery IDs and Replay Protection

This guide, part of Webhook-Triggered Rebuilds, makes a webhook handler safe against the three ways CMS deliveries misbehave in production: the same event delivered twice, an old event replayed by an attacker, and events arriving in a different order from the changes they describe.

CMS platforms deliver webhooks at least once, not exactly once. If your endpoint times out, returns an error or the connection drops after processing, the CMS retries, and your handler sees the same event again. Signatures prove an event came from the CMS, but not that it is new: a captured, validly signed request can be replayed later. And deliveries are concurrent, so the event for revision 7 of an entry can arrive after the event for revision 8. A handler that is correct under all three is one you never have to think about again.

Three checks that make a handler idempotentAfter signature verification, the handler rejects events whose signed timestamp is outside the replay window, skips delivery ids it has already processed, and ignores events whose entry revision is older than the last one processed.Signature validTimestamp inwindow?RejectreplayDelivery idseen?200, skipduplicateRevision newerthan stored?200, skipstale eventProcessstore id + revisionnoyesyesnonoyes
Replay window, delivery id and revision each catch a different failure; together they make retries harmless.

The Problem

An e-commerce site’s webhook handler purged CDN tags and triggered regeneration for each product publish. During a CDN slowdown, the handler took longer than the CMS’s 10-second timeout, so the CMS retried each webhook up to three times. Every retry ran the full purge and regeneration again, which slowed the handler further and caused more retries, until the CDN’s purge API rate-limited the site and nothing was purged for 20 minutes. Separately, a security review showed that a signed webhook captured from a log could be replayed indefinitely to force expensive purges, because the signature carried no timestamp check.

How Idempotency Works for Webhooks

Three mechanisms, applied in order after the signature check:

Replay window. When the CMS includes a timestamp in the signed content, as Sanity’s signature header and several others do, reject events whose timestamp is more than a few minutes old. That makes captured requests worthless shortly after capture. Where the CMS signs only the body, add your own timestamp header in the webhook configuration if the platform supports templated headers, or rely on the next two checks.

Delivery id. Many platforms send a unique id per delivery or per event, in a header or in the body. Store processed ids for a day; if an id repeats, return success without doing anything. Returning 2xx matters: the CMS will keep retrying anything else.

Revision ordering. Events describe entry states, and a later revision supersedes an earlier one. Store the highest revision processed per entry; ignore events for older revisions. Use the CMS’s revision number where it exists, sys.revision in Contentful, _rev changes in Sanity, updatedAt elsewhere, as a monotonic marker.

Idempotency signals by CMSWhether each platform's webhooks carry a signed timestamp, a delivery or event id, and a revision marker usable for ordering.CMSSigned timestampDelivery / event idRevision markerContentfulvia custom headersin headerssys.revisionSanityt= in signature headeridempotency key header_rev / _updatedAtStoryblokconfigurablein some payloadsstory version / published_atStrapisecret header onlynot standardupdatedAtDirectussecret header onlynot standarddate_updated
Where a signal is missing, the other two still make retries safe; check your plan and version, since options differ.

Implementation

The handler below uses Redis for the processed-id set and the per-entry revision map. The checks are cheap, so they run synchronously before the handler acknowledges the event; the actual work runs in a queue.

TypeScript
// app/api/cms-webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import Redis from "ioredis";
import { enqueue } from "@/lib/queue";

const redis = new Redis(process.env.REDIS_URL ?? "");
const REPLAY_WINDOW_S = 300;

interface NormalizedEvent {
  deliveryId: string | null;
  entryId: string;
  revision: number;
  event: "publish" | "unpublish" | "delete";
}

function verify(raw: string, header: string | null): { ok: boolean; timestamp?: number } {
  // Header format "t=<unix>,v1=<hex hmac of `${t}.${raw}`>" (common convention; adapt per CMS)
  const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=") as [string, string]));
  const t = Number(parts.t);
  const expected = createHmac("sha256", process.env.CMS_WEBHOOK_SECRET ?? "").update(`${parts.t}.${raw}`).digest("hex");
  const a = Buffer.from(parts.v1 ?? "", "hex");
  const b = Buffer.from(expected, "hex");
  return { ok: a.length === b.length && timingSafeEqual(a, b), timestamp: t };
}

export async function POST(req: Request): Promise<Response> {
  const raw = await req.text();
  const { ok, timestamp } = verify(raw, req.headers.get("x-cms-signature"));
  if (!ok) return new Response("invalid signature", { status: 401 });
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > REPLAY_WINDOW_S) {
    return new Response("stale or replayed event", { status: 401 });
  }

  const evt = JSON.parse(raw) as NormalizedEvent;

  // 1. Delivery id: first writer wins, duplicates are acknowledged and skipped.
  if (evt.deliveryId) {
    const fresh = await redis.set(`wh:delivery:${evt.deliveryId}`, "1", "EX", 86_400, "NX");
    if (!fresh) return Response.json({ duplicate: true }, { status: 200 });
  }

  // 2. Revision ordering: only newer revisions change anything (atomic compare-and-set in Lua).
  const newer = (await redis.eval(
    `local cur = tonumber(redis.call('GET', KEYS[1]) or '-1')
     if tonumber(ARGV[1]) > cur then redis.call('SET', KEYS[1], ARGV[1], 'EX', 2592000) return 1 end
     return 0`,
    1,
    `wh:rev:${evt.entryId}`,
    String(evt.revision),
  )) as number;
  if (!newer && evt.event === "publish") return Response.json({ stale: true }, { status: 200 });

  // 3. Acknowledge fast, work later.
  await enqueue({ entryId: evt.entryId, event: evt.event, revision: evt.revision });
  return Response.json({ queued: true }, { status: 202 });
}

Unpublish and delete events bypass the revision check here because they may carry no new revision; handle them idempotently in the worker instead, since unpublishing an already unpublished entry changes nothing. The worker itself should also be idempotent: revalidating a tag twice or purging a key twice is harmless, which is one reason tag-based invalidation is a good fit for at-least-once delivery.

Normalizing payloads before the checks

The handler above works on a NormalizedEvent, not on the raw CMS payload. A small adapter per CMS maps the platform’s fields into that shape: where the delivery id lives, which field holds the revision, and how the event type is named. Keeping the checks platform-independent pays off the first time a second CMS is added, or when a CMS changes its payload format between API versions: only the adapter changes, and the adapter can be tested with stored fixtures from each platform. Verify the signature before running the adapter, so malformed or hostile payloads never reach parsing code that might throw or behave unexpectedly.

Configuration Reference

Setting Value Why
Replay window 5 min Longer than clock skew and CMS retry delays, short enough to limit replays.
Processed id TTL 24 h Covers every CMS retry schedule.
Revision key TTL 30 days Old entries rarely receive late events; keys expire eventually.
Response for duplicates 200 Stops CMS retries.
Response after queueing 202 Fast acknowledgement; work happens asynchronously.
Timeout budget under 2 s Well inside every CMS’s webhook timeout.

Gotchas & Edge Cases

  • Returning errors for duplicates. A 409 or 400 for a duplicate delivery makes the CMS retry it again. Acknowledge duplicates with 2xx.
  • Checking the timestamp outside the signature. A timestamp in an unsigned header can be changed by an attacker. Only trust timestamps that are part of the signed material.
  • Clock skew. A replay window smaller than the skew between the CMS and your servers rejects valid events. Keep servers on NTP and the window at several minutes.
  • Revision numbers that reset. Copying content between environments or restoring backups can reset revision counters. Include the environment in the revision key, and reset the stored revisions after a restore.
  • Doing the work before acknowledging. Slow processing is the root cause of most retry storms. Verify, deduplicate, enqueue, respond, in that order.

Worked Example

The e-commerce team moved purging and regeneration into a queue worker and added the three checks. Webhook responses dropped from several seconds to about 40 milliseconds, so the CMS stopped retrying. During the next CDN slowdown, the worker’s queue grew for a few minutes and then drained, with each product purged exactly once. The security review’s replay test now fails with a 401 after five minutes, and the dashboard shows the duplicate rate, which hovered around two percent of deliveries, as a normal background figure rather than a source of incidents.

Purge API calls during a CDN slowdownCDN purge calls made during a 20-minute CDN slowdown by the original handler that retried with every webhook delivery, and by the idempotent handler with a queue.Original handler1840 callsIdempotent + queue212 calls212 distinct products were published during the slowdown.
Retries multiplied the original handler's work; the idempotent handler purged each product once.

Rollout Checklist

  • Find out which idempotency signals your CMS sends, per the table above.
  • Verify the signature over the raw body, including the timestamp where it is signed.
  • Deduplicate by delivery id and order by revision before doing any work.
  • Acknowledge within two seconds and process in a queue.
  • Make the worker’s actions idempotent, preferring tag invalidation over counters or appends.

Frequently Asked Questions

What if my CMS sends no delivery id?

Derive one: a hash of the entry id, event type and revision identifies the event well enough for deduplication. Combined with revision ordering, it makes duplicates harmless.

How long should processed ids be kept?

At least as long as the CMS keeps retrying, which is minutes to hours on most platforms; a day is a comfortable default that costs little memory.

Is Redis required?

Any store with atomic set-if-absent and expiry works, including a database table with a unique constraint or an edge key-value store with conditional writes. Redis is simply the most common choice for this pattern.

Can idempotency replace debouncing?

No. Idempotency makes the same event safe to process twice; debouncing makes many different events produce one build. Most pipelines need both, as described in the debouncing guide.

How do I test replay protection?

Capture a valid signed request in staging, wait longer than the replay window, and send it again: it must be rejected. Then send it within the window twice: the first is processed, the second acknowledged as a duplicate.