Almost every webhook provider's documentation includes some version of the sentence "webhooks may be delivered more than once, your endpoint should be idempotent," and almost every team reads it, nods, and then builds an endpoint that isn't actually idempotent, because duplicate deliveries are rare enough in testing that the gap never gets caught until production.
Step 1: Identify what makes an event actually unique
Before you can deduplicate anything, you need a reliable identifier for "this is the same event, delivered twice" versus "this is a genuinely new event." Most webhook providers include an event ID in the payload or headers specifically for this purpose. If yours doesn't, you'll need to construct a synthetic key from a combination of fields (resource ID plus event type plus a timestamp, for example) that's stable across redeliveries of the same underlying event but distinct across genuinely different events.
Step 2: Store seen event IDs with a reasonable expiration window
A simple, effective pattern is a lookup table (a database table, a Redis set, whatever fits your stack) that records event IDs you've already processed, with a time-to-live long enough to cover the realistic redelivery window your provider uses. Most providers redeliver within minutes to a few hours if the original delivery failed, so a TTL of 24 to 48 hours covers the overwhelming majority of real-world duplicate scenarios without keeping an unbounded, ever-growing table of every event ID you've ever seen.
Step 3: Check before processing, not after
The check-and-record step needs to happen atomically, before your endpoint does any actual work, not as an afterthought once processing has already started. A common mistake is processing the event first and only recording it as "seen" at the very end, which means a duplicate delivery that arrives while the first one is still processing can slip through and get processed twice anyway. Using an atomic check-and-set operation (most key-value stores support this natively) closes that race condition cleanly.
Step 4: Return a success response for duplicates, not an error
When your endpoint detects a duplicate, respond with the same success status you'd return for a normal successful processing, not an error. From the webhook provider's perspective, a duplicate that gets acknowledged with a success response stops the redelivery cycle, which is exactly what you want. Returning an error for a duplicate can actually make things worse, since some providers will interpret an error response as "this delivery failed" and schedule yet another redelivery attempt.
Step 5: Handle out-of-order delivery separately from duplicate delivery
Deduplication and ordering are related but distinct problems, and it's worth not conflating them. A provider might deliver events out of order even without any duplicates at all, particularly across multiple event types for the same resource. If your processing logic depends on strict ordering (an "updated" event needing to be processed after the "created" event for the same resource, for example), you need separate logic for that, typically a sequence number or timestamp comparison that lets you detect and handle an out-of-order arrival, distinct from the deduplication check that handles a genuinely repeated delivery.
Step 6: Test this with your provider's actual redelivery behavior, not a synthetic mock
If your webhook provider offers a way to trigger a manual redelivery from their dashboard or API, use it during development rather than only simulating duplicates yourself. Real redelivery behavior sometimes differs from what you'd assume, including details like whether the redelivered payload is byte-identical to the original or has updated metadata fields, which can affect whether your deduplication key construction actually works as intended. A local tunneling tool such as ngrok makes this kind of testing much easier, since it gives your webhook provider a real public URL that forwards straight to your local development environment, letting you trigger manual redeliveries and watch your deduplication logic handle them in real time instead of guessing from logs after the fact.
When the redelivered payload isn't byte-identical
Step 6 hints at a problem that trips up a lot of implementations once they hit it for real: some providers do not resend the exact same payload on redelivery. The event ID stays the same, but a field like a computed total, a status string, or a last-updated timestamp inside the payload can differ slightly from what was sent the first time. If your deduplication logic assumes the two payloads will always match byte-for-byte, and you've built any kind of "verify the resend matches what we already have" check, that check will fail on legitimate redeliveries and create false alarms.
The fix is to treat the event ID, not the payload contents, as the sole source of truth for identity. Once you've confirmed you've already processed a given event ID, discard the redelivered payload entirely rather than comparing it against anything. Don't attempt to merge or reconcile the two versions, and don't log a warning every time the bytes differ, since that is expected provider behavior in many systems rather than a sign of a bug on either end.
There's one edge case worth handling deliberately: if a provider is willing to send materially different data under the same event ID, it's worth asking whether that ID is actually meant to represent "the same occurrence of this event" or something looser, like "the same object, however it currently looks." Reading your provider's specific documentation on this point up front saves you from designing a deduplication key around an assumption that doesn't hold for that particular integration.
Designing the lookup table so it doesn't become the bottleneck
A naive deduplication table works fine at low volume and quietly turns into a performance problem once event volume climbs. A table that's never pruned grows without bound, and if you're checking every incoming event against it with an unindexed or poorly indexed lookup, that check becomes the slowest part of your entire pipeline, not the actual processing logic it was meant to protect.
A few things keep this from happening. Index the event ID column specifically, and nothing else, since that's the only field this table needs to be fast at looking up. Enforce your TTL with an actual expiration mechanism rather than relying on a periodic manual cleanup job you might forget to run, whether that's Redis's native key expiration or a scheduled deletion job against a database table's timestamp column. And if you're running a database-backed table rather than an in-memory store, partition or shard it by time window (a table per day, for example) so that expiring old entries is a matter of dropping a partition rather than running a slow row-by-row delete against a table with millions of entries.
For lower-volume integrations, there's a simpler option that avoids building a separate lookup table at all: a unique constraint on the event ID column of the table where you're already storing processed results. Let the insert fail on a duplicate, catch that specific constraint violation in your application code, and treat it as your "already processed, return success" signal. This piggybacks deduplication onto storage you need anyway rather than maintaining a second system, and for integrations handling dozens or hundreds of events a day rather than thousands per second, it's genuinely the more maintainable choice. The tradeoff shows up only at higher volume, where a dedicated, indexed, TTL-bound table (or an in-memory store built for exactly this kind of check) scales more predictably than leaning on a relational database's constraint-violation path as a control flow mechanism.
Why this matters more as integrations multiply
A single flaky webhook integration with a small blast radius is annoying but survivable without rigorous deduplication. The math changes once you're running a dozen integrations, each one an independent source of potential duplicate or out-of-order delivery, feeding into shared downstream systems like a billing pipeline or an inventory count. At that scale, an unhandled duplicate in even one integration can silently double-count a transaction or an inventory adjustment, and the debugging cost of tracing that back to its source, across a dozen possible integrations, is dramatically higher than the cost of building the deduplication layer properly up front.
This is one piece of a broader reliability pattern worth building into any data automation system: handling timing edge cases deliberately rather than assuming the happy path is the only path, whether that's a scheduled job crossing a DST boundary or a webhook endpoint receiving a delivery twice. Both failure modes are individually rare and collectively inevitable at any real scale.
For reference on the specifics, Stripe's webhook documentation is a well-written example of how a major provider documents redelivery and idempotency expectations, and is worth reading even if you don't use Stripe specifically, since most providers follow a similar pattern. Redis's documentation on SETNX and atomic operations covers the specific primitive most teams reach for when building the check-and-set deduplication logic described in Step 3.
If your team is dealing with a growing web of third-party integrations and duplicate or out-of-order events are becoming a recurring source of data quality incidents, 137Foundry's data integration guide covers this pattern as part of a broader approach to building integrations that stay reliable as they scale.
Top comments (0)