A webhook sender can deliver an event again when it does not receive a successful response. If the receiver has already committed its work, that retry must not repeat the same business operation.
Stripe explicitly documents duplicate deliveries and does not guarantee event ordering. Those are useful failure cases to design for, even when integrating a different provider; check that provider's own delivery contract. Source: Stripe webhook documentation.
This walkthrough uses a fictional message-receiving service. The SQL illustrates a PostgreSQL design; the handler is pseudocode, not a drop-in SDK integration or a report of production results.
1. Define what counts as a duplicate
Separate three identifiers:
- Delivery attempt: one HTTP request.
- Event ID: the logical event being delivered.
- Business object ID: the message, order, or other resource the event describes.
A retry should resolve to the same event key. For this example, use (source, account_id, event_id), where source also distinguishes test and production environments.
Derive the account scope from trusted endpoint configuration or authenticated event context. Validate required identifiers before processing. Verify authenticity using the provider's documented mechanism; a plausible-looking event ID is not authentication.
Avoid deduplicating by message text or receipt time. Two legitimate messages can contain identical text. Conversely, separate event IDs may describe one business operation, which needs its own domain constraint.
2. Let the database arbitrate concurrent requests
This application-level sequence has a race:
if event is not in processed_events:
apply_business_change()
remember_event()
Two workers can both pass the first check. A unique database key gives them a shared point of coordination.
CREATE TABLE processed_webhook_events (
source text NOT NULL,
account_id text NOT NULL,
event_id text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (source, account_id, event_id)
);
Attempt the insert inside the transaction that will apply the business change:
INSERT INTO processed_webhook_events
(source, account_id, event_id)
VALUES ($1, $2, $3)
ON CONFLICT (source, account_id, event_id) DO NOTHING
RETURNING event_id;
Here, $1–$3 are bound parameters. An inserted row is returned; a conflicting row skipped by DO NOTHING is not. Source: PostgreSQL INSERT documentation.
3. Commit the receipt and business change together
For a short operation entirely within one database, the handler can follow this structure:
verify authenticity and validate the supported event
begin transaction on one database connection
try:
inserted = insert event key, returning event_id
if inserted:
apply the local business change
fail if required business preconditions are not met
commit
catch:
roll back
return a failure response appropriate to the provider contract
return a success response accepted by the provider
The marker must not be committed separately before the business change. Otherwise, a crash between them leaves an event marked as processed with its work missing.
Conversely, committing the business change first and remembering the event later permits duplicate work after a crash.
In the single-transaction design, a rollback removes both changes. After a successful commit, a lost HTTP response can cause another delivery, but the event key prevents this handler from reapplying that event. Commit uncertainty must also be treated as retryable, with the key resolving what happened.
4. Keep external side effects outside that guarantee
A database rollback cannot undo an email or an HTTP call.
When processing needs an external action, write an outbox record alongside the local business change in the same transaction. A worker sends it afterward. The worker still needs retry handling: a crash after sending but before recording success can cause another send. Use a stable operation key and downstream idempotency where supported. Source: AWS transactional outbox guidance.
For slow processing, durably accept the event into an inbox or queue before acknowledging it, then process asynchronously with explicit retry and recovery states. An in-memory background task alone is not durable acceptance.
5. Test the failure boundaries
Use these as test cases for an implementation, not as claimed results of this illustrative example:
| Scenario | Expected behavior |
|---|---|
| Two workers receive the same event concurrently | One committed local business application |
| Crash before transaction commit | Neither the marker nor business change persists |
| Commit succeeds, but the response is lost | Redelivery does not repeat the change |
| Two different events contain identical message text | Both remain eligible for processing |
| An older state update arrives later | Domain transition or version rules prevent invalid regression |
| Outbox worker crashes after sending | Stable downstream operation key limits duplicate effects |
Deduplication does not solve event ordering. Apply explicit state transitions or a provider-defined version check where available. Also choose a retention period deliberately: deleting event keys removes protection against sufficiently old replays.
The useful guarantee is specific: one retained event key gates one committed local database operation. Extending that guarantee across queues, services, or external APIs requires additional design.
Top comments (0)