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 not a feature any vendor can ship — it's a proven impossibility in distributed computing. 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 not a delivery guarantee. It's a processing 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.
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 not a compromise — it's the correct architecture for reliable webhook processing in distributed systems.
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.