← All notes
API Development6 min

A signature window is not a retry window

Most webhook tutorials collapse three clocks into one sentence: verify the signature, reject old timestamps, and ignore duplicates. Those are three different windows. A five-minute skew check is anti-replay for a captured request. Stripe and Shopify keep retrying legitimate deliveries for hours or days. The store that remembers event ids has to outlive that retry horizon — and any manual resend you click from a dashboard — or a day-two retry looks brand new again.

Three clocks on the same endpoint

Signature freshness answers a security question: if someone captured a valid POST, how long can they replay it before your verifier rejects the signed timestamp? Stripe's default tolerance is on the order of five minutes. That bound is about clock skew and stolen payloads. It is not a statement about how long the provider will keep trying.

Retry horizon answers an operations question: if your endpoint returned 500 or timed out, how long will the platform keep delivering the same event? Live Stripe retries stretch across days. Shopify's documented retry schedule is measured in hours with a fixed attempt count. Those deliveries are authentic. Their signed timestamps are fresh enough for each attempt, or the provider would not send them.

Dedup retention answers a third question: how long do you remember that you already accepted this event id? If that memory expires before the provider's last retry — or before someone hits Resend in the dashboard — the handler runs the side effect again. At a florist that prints tickets, again means two tickets.

What the five-minute check actually buys you

HMAC verification proves the body was signed with a shared secret. Adding a signed timestamp, and rejecting skew beyond a short window, shrinks how long a captured request stays acceptable. Inside that window a replay still validates cryptographically — which is why event-id dedup still matters even when skew checks are correct.

We keep the skew check tight on purpose. Widening it to match a three-day retry policy would also widen the capture-replay window. The freshness gate and the retry policy are not supposed to be the same number. Mixing them is how teams either reject good retries or leave stale signed bodies acceptable for days.

Secret rotation is a separate concern again. Overlap windows keep verification from dropping valid traffic while a new signing secret rolls out. They do not replace skew checks or event-id stores. Each control closes a different failure mode.

Size the dedup store to the longer clock

Key on the provider's stable delivery id when you have one — Stripe's event id, Shopify's webhook id header — and also keep business-level idempotency on the order id for side effects that must stay unique regardless of how many event shapes arrive. Delivery-id dedup stops reprocessing the same notification. Order-id idempotency stops reprinting the same arrangement when two different events both mean print.

Persist those keys in durable storage with a unique constraint, not a process-local set. Multiple receiver instances and redeploys will otherwise each believe they are first. Set retention at least as long as that provider's full retry window, plus a grace period for manual dashboard resends and your own queue lag.

A Redis TTL copied from the signature tolerance is the classic footgun. Five minutes feels consistent with the skew docs. It is catastrophically short against a provider that retries tomorrow. When the key evaporates, the next authentic retry inserts cleanly and your downstream job runs twice.

Manual replay is still a delivery

Dashboards let operators resend events after the automatic retry window closes. Those resends usually carry the same event id. If your handler is idempotent on that id, a resend is a no-op with a 2xx — which is what you want when someone is fishing for a missed print after an outage.

If the operator needs a true re-run — the ticket never printed because the printer was offline after you acknowledged the event — that is a separate tool: a controlled reprocess that bypasses delivery-id dedup under an audited reason, still guarded by order-id idempotency or an explicit void/reprint path. Collapsing resend and reprocess into one button is how you get surprise duplicates.

Document which button does which. On-call should not have to guess whether Resend means try delivery again or force the side effect again.

What we write into the integration notes

For each webhook provider on a project we record three numbers: signature skew tolerance, documented automatic retry horizon, and dedup retention we actually configured. They sit next to the secret-rotation overlap and the reconciliation scan interval — the boring table that keeps the print pipeline calm.

Acknowledgment stays cheap: verify, persist the raw event under its id, enqueue work, return 2xx. Slow PDF and print work happens after accept. That keeps provider timeouts from manufacturing extra retries you then have to dedupe.

Reconciliation still covers silence — the event that never arrived. Signature windows, retry windows, and dedup TTLs only govern traffic that showed up. The gap scan against the source API remains the backstop for missing Valentine's orders.

Have something to build?

Tell us what you're working on and we'll tell you honestly whether we're the right fit.

Work with us