At-Least-Once vs. Exactly-Once Webhook Delivery Guarantees
If you've ever processed a webhook twice and charged a customer double, or spent hours debugging why an inventory count drifted, you've already encountered the most misunderstood concept in distributed systems: delivery guarantees.
Webhooks operate on at-least-once delivery. Exactly-once delivery is a proven impossibility in distributed computing, not a feature any vendor can ship. What the industry calls "exactly-once" is really at-least-once delivery combined with idempotent processing on the receiver side.
Understanding this distinction isn't academic. It determines how you architect your webhook consumers, what failure modes you plan for, and whether a duplicate event silently corrupts your data or gets handled gracefully.
Why Exactly-Once Delivery Is Impossible
The impossibility of exactly-once delivery isn't something that can be overcome. It's a mathematical constraint rooted in the Two Generals Problem and the FLP impossibility result (Fischer, Lynch, Patterson, 1985).
The core issue is deceptively simple. When a webhook sender doesn't receive an acknowledgment, it cannot distinguish between four scenarios:
- The message was lost in transit
- The message was delivered, but the acknowledgment was lost
- The receiver crashed before processing
- The network is just slow
In scenarios 2 and 4, the event was already delivered. Retrying creates a duplicate. But in scenarios 1 and 3, not retrying means data loss. Any reliable system must retry — which means duplicates are inevitable.
Exactly-once delivery is an ideal that can't be achieved due to a fundamental constraint of distributed computing that cannot be worked around, only accommodated through application-level strategies.
The Three Delivery Guarantees Compared
Every webhook system makes a tradeoff between reliability and simplicity. Here's how the three guarantee types stack up:
| At-Most-Once | At-Least-Once | Exactly-Once Processing | |
|---|---|---|---|
| How it works | Fire-and-forget. No retries. | Persist + retry until acknowledged. | At-least-once delivery + idempotent consumer. |
| Operational tradeoff | Simplest to implement; accepts data loss. | Reliable delivery; must handle duplicates. | Most reliable; requires receiver-side logic. |
| Implementation pattern | Single HTTP call, ignore failures. | Retry queue with exponential backoff. | Dedup table + transactional processing. |
| Fit: outbound webhooks | Low-value notifications only. | Standard for most providers (Stripe, Shopify). | Requires consumer cooperation — provider can't guarantee alone. |
| Fit: inbound webhooks | Acceptable for idempotent operations. | Default choice for reliable ingestion. | Needed for financial or stateful operations. |
| Common failure mode | Silent data loss. | Duplicate processing, double charges, inflated counts. | Dedup window expiry, stale cache eviction. |
Exactly-once is a processing guarantee, not a delivery guarantee. It requires active participation from both the sender and the receiver. No infrastructure layer alone can provide it.
When At-Least-Once Is Enough
Not every webhook needs idempotency handling. At-least-once delivery without additional deduplication is perfectly fine when:
- Cache invalidation: Receiving "product updated" twice just refreshes the cache twice — no harm done.
- Logging and analytics: A duplicate log entry or analytics event is usually negligible.
- Non-financial state syncs: If your handler fetches the latest state from an API on each webhook (rather than applying a delta), processing twice produces the same result naturally.
This aligns with the industry pattern of designing systems that report current state rather than mutating state with incremental changes. When your handler reads current state instead of applying deltas, receiving the same webhook twice has no adverse effect.
When You Need Idempotency
Idempotent processing becomes critical when duplicates cause real damage:
- Financial transactions: Charging a customer twice, issuing duplicate refunds, or double-crediting an account.
- Inventory management: Decrementing stock counts twice on a single order.
- User communications: Sending duplicate emails, SMS messages, or push notifications.
- Any operation with external side effects: Calling a third-party API, triggering a workflow, or writing to a system that doesn't handle duplicates gracefully.
If your webhook handler mutates state or triggers irreversible actions, you need idempotency.
How Duplicates Actually Happen: Stripe and Shopify Examples
Understanding where duplicates come from helps you design defenses against them.
Stripe may send the same event more than once due to network issues, retries triggered by slow endpoint responses, or your endpoint returning a non-2xx status after it has already processed the event successfully. Every Stripe event carries an event.id (e.g., evt_1NB4kC2eZvKYlo2CKLmBxSJn) specifically for deduplication.
Shopify duplicates arise from network timeouts, slow responses, and infrastructure issues. They also note that multiple webhook subscriptions for the same topic will generate separate legitimate webhooks — these aren't duplicates and will have different X-Shopify-Event-Id headers. Each webhook includes this header specifically so apps can deduplicate.
Implementing Idempotent Webhook Processing
The pattern is consistent regardless of provider. Extract a unique event identifier, check if you've already processed it, and use database constraints to handle race conditions:
async function handleWebhook(req, res) {
// Extract the unique event ID from the provider
const eventId =
req.headers["x-shopify-event-id"] || // Shopify
req.body.id || // Stripe (evt_XXXX)
req.headers["x-event-id"]; // Generic
// Check if already processed
const alreadyProcessed = await db.query(
"SELECT 1 FROM processed_events WHERE event_id = $1",
[eventId]
);
if (alreadyProcessed.rows.length > 0) {
return res.status(200).send("OK"); // Acknowledge safely
}
try {
await db.transaction(async (tx) => {
// Insert with unique constraint — catches race conditions
await tx.query(
`INSERT INTO processed_events (event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (event_id) DO NOTHING`,
[eventId]
);
// Process business logic inside the same transaction
await processBusinessLogic(tx, req.body);
});
return res.status(200).send("OK");
} catch (err) {
if (err.code === "23505") {
// Unique violation — another instance already processed this
return res.status(200).send("OK");
}
throw err;
}
}
Two details matter here. First, the dedup check and the business logic must happen inside the same database transaction. Without this, a crash between the check and the insert creates a window for duplicates. Second, the ON CONFLICT DO NOTHING clause (or catching the unique constraint violation) handles the race condition where two instances of your handler process the same event simultaneously.
Remember to periodically clean up your processed_events table. Stripe's idempotency keys, for example, are valid for 24 hours — a reasonable baseline for your own dedup window.
Choosing the right idempotency key
The pattern above is only as reliable as the key you deduplicate on, and there are two kinds of key with different guarantees.
A provider event ID (evt_... from Stripe, X-Shopify-Event-Id from Shopify) deduplicates exact re-deliveries of the same event. It catches retry storms and re-sends, but it does not catch the case where a provider emits two different events (with two different IDs) for the same underlying action.
A business key derived from the payload (a payment intent ID, an order ID, an idempotency key you set on the original request) deduplicates on the operation itself. Two events with different event IDs that both represent "charge pi_123 succeeded" collapse to a single business key, so you process the charge once.
Use the provider event ID as your default. Fall back to a business key when the same action can reach you through more than one event, or when you also need protection against duplicates generated upstream of the provider.
Deduplicating with Redis for high-volume endpoints
A database round trip on every event is fine at moderate volume. At high volume, a Redis SET with NX (set only if absent) and EX (expiry) gives you an atomic check-and-claim in a single command:
async function claimEvent(eventId) {
// NX = only set if the key doesn't exist; EX = expire after the dedup window
const claimed = await redis.set(`webhook:${eventId}`, "1", "NX", "EX", 86400);
return claimed === "OK"; // true = first time we've seen this event
}
async function handleWebhook(req, res) {
const eventId = req.headers["x-shopify-event-id"] || req.body.id;
if (!(await claimEvent(eventId))) {
return res.status(200).send("OK"); // already claimed — safe to drop
}
await processBusinessLogic(req.body);
return res.status(200).send("OK");
}
Treat Redis as a fast filter, not the source of truth. Because keys expire and Redis can evict under memory pressure, a duplicate that arrives after eviction slips through. For anything with financial or stateful side effects, keep the database constraint from the previous pattern as the authoritative backstop and use Redis to absorb the bulk of the traffic before it hits the database. For a fuller comparison of database, Redis, and in-memory storage trade-offs, see How to Implement Webhook Idempotency.
Natural idempotency: let your schema do the work
The most robust approach avoids a separate dedup table entirely by making the business write itself idempotent. If the record you create already has a unique business key, an upsert makes reprocessing a no-op:
INSERT INTO orders (order_id, customer_id, amount, status)
VALUES ($1, $2, $3, 'paid')
ON CONFLICT (order_id) DO NOTHING;
Here the orders.order_id unique constraint is your deduplication mechanism. There is no dedup window to tune and no cleanup job to run, because the constraint lives as long as the data does. When your handler's main effect is a single insert or update keyed on a business identifier, prefer this over a dedicated processed_events table.
What Vendors Actually Mean by "Exactly-Once"
When webhook infrastructure vendors claim "exactly-once delivery," they typically mean a layered defense system, not a violation of distributed systems theory. Here's what those layers actually catch — and miss:
| Layer | What it catches | What it misses |
|---|---|---|
| Infrastructure deduplication | Retry storms, near-simultaneous duplicates | Late duplicates outside the dedup window |
| Idempotency key support | Exact re-deliveries of the same event | Provider-side duplicates with different event IDs |
| Application-level idempotency | Everything — last line of defense | Nothing, if implemented correctly |
| Ordering controls | Out-of-order processing | Events that arrive out-of-order from the source |
| Retry logic + circuit breakers | Transient and extended destination failures | Intermittent failures between retry attempts |
The critical takeaway is that "exactly-once" claims depend entirely on system boundaries. A vendor can guarantee exactly-once within their infrastructure — deduplicating retries before they reach your endpoint. But they cannot control what happens after your endpoint receives the event. If your handler crashes after returning a 200 but before committing to your database, the event is lost from your system's perspective, and no amount of infrastructure-level dedup can fix that.
This is why exactly-once processing requires endpoint participation. Vendors claiming "magical" exactly-once guarantees without discussing receiver-side responsibilities are, at best, describing only one layer of the defense model.
A Buyer's Framework for Choosing Your Guarantee Level
When evaluating webhook infrastructure or designing your own consumers, use this decision framework:
Start with at-least-once delivery. It's the industry standard and the right default. Then layer on additional guarantees based on your use case:
- Are your handlers naturally idempotent? If processing the same event twice produces the same result (e.g., setting a status to "shipped" rather than incrementing a counter), at-least-once is sufficient without additional work.
- Do you process financial transactions or manage inventory? Add application-level idempotency using the dedup pattern above. This is non-negotiable.
- Does event ordering matter? If processing "order.updated" before "order.created" would corrupt your state, you need ordering controls — either at the infrastructure level or by checking sequence numbers/timestamps in your handler.
- Do you receive high-volume webhooks from multiple providers? Consider infrastructure-level deduplication (configurable dedup windows can catch the bulk of duplicates) to reduce load on your application-level dedup layer.
- Do you need to recover from outages? Ensure your provider supports event replay and recovery so you can backfill missed events without re-triggering already-processed ones.
Conclusion
The distributed systems community settled this debate decades ago: exactly-once delivery is impossible, and exactly-once processing is an application-level concern that requires your active participation. Every major webhook provider — Stripe, Shopify, and the infrastructure layers in between — operates on at-least-once delivery.
This isn't a limitation to work around. It's a design constraint to build with. Accept that duplicates will arrive, implement idempotent processing where it matters, and use infrastructure-level deduplication to reduce the noise. The combination of at-least-once delivery with idempotent consumers is the correct architecture for reliable webhook processing in distributed systems, not a compromise.
FAQs
What is at-least-once webhook delivery?
At-least-once delivery means the system guarantees every webhook event will be delivered at least one time, retrying on failure until the receiver acknowledges it. The tradeoff is that duplicate deliveries are possible and expected — your application must handle them safely using idempotent processing.
Is exactly-once webhook delivery possible?
Exactly-once delivery is impossible in distributed systems due to fundamental constraints like the Two Generals Problem. However, exactly-once processing is achievable by combining at-least-once delivery with idempotent event handlers. This is the approach used by Stripe, Kafka, AWS, and Hookdeck.
How do I handle duplicate webhook deliveries?
Implement idempotent webhook handlers using a unique identifier from the event (such as a payment ID, order ID, or the x-hookdeck-eventid header). Before processing, check if the event has already been handled. Use database unique constraints, Redis-based deduplication, or an idempotency key cache to prevent duplicate side effects.
What delivery guarantee does Hookdeck provide?
Hookdeck provides at-least-once delivery with infrastructure-level features to help you achieve exactly-once processing. This includes automatic retries with configurable backoff, event deduplication rules, idempotency headers on every event, dead-letter queue management via Issues, and durable event persistence.
How does idempotency relate to delivery guarantees?
Idempotency bridges the gap between at-least-once delivery and exactly-once processing. Since at-least-once delivery means duplicates are expected, idempotent handlers ensure that processing the same event multiple times produces the same result as processing it once — giving you the reliability of guaranteed delivery without the risk of duplicate side effects.
What happens when a webhook delivery fails?
When delivery fails, the system retries based on the configured retry policy (exponential or linear backoff). If all retries are exhausted, the event moves to a dead-letter queue for manual investigation and recovery. With Hookdeck, failed events are tracked as Issues with full payload visibility, and can be retried individually or in bulk after the root cause is fixed.
Webhook infrastructure, managed for you
Hookdeck handles ingestion, delivery, observability, and error recovery — so you don't have to.