To prevent duplicate webhook effects, verify the request, durably accept the work, and make the business transition idempotent. A signature is not a record of previous processing. A row saying “seen” is not proof that processing finished.
This guide is for developers connecting payment, repository, or messaging events to account changes and background jobs. The useful review question is not just “Do we store an event ID?” It is: If this process stops after any durable step, what will the next attempt do?
First separate three different claims
| Claim | Evidence you need | What it does not establish |
|---|---|---|
| This request passed authentication | Provider-specific verification of the received bytes and required freshness checks | That this is its first delivery |
| We accepted responsibility for this event | A committed receipt or durable queue entry | That the business operation completed |
| We applied this business transition | A committed state change with a domain-specific uniqueness rule | That an external service performed a separate action exactly once |
For example, Slack’s signing recipe authenticates the raw body together with a timestamp and checks a five-minute freshness window. That limits acceptance of old signed requests; it does not make an accepted request single-use. Use the provider’s supported verifier rather than treating JSON parsing as verification. Slack request verification
Stripe can deliver an event again and does not guarantee event order. Its guide distinguishes repeated delivery of one event from separate events concerning the same object. Consequently, “new event ID” and “new business operation” are not equivalent. Stripe delivery behavior and duplicate handling
Design the receipt as recoverable work
The following is an engineering design recommendation, not a universal provider contract. It assumes a transactional local database and a worker that can recover pending work.
The external-effect boundary is intentionally separate. A local database transaction cannot roll back a remote service.
- Verify before trusting or dispatching. Preserve the bytes required by the provider verifier. Validate the event type, expected account or installation, and supported payload shape. Authentication is not permission to perform every action described by a payload.
- Commit a pending receipt. Use a unique receipt key within the correct integration scope. Persist enough validated information to process the task later, with access controls and retention appropriate to the payload. Do not acknowledge success while the only copy exists in process memory.
- Process pending work transactionally. In one transaction, enforce the business transition’s uniqueness, make the local change, and mark local processing completed. If a required external effect follows, commit its durable intent in this same transaction and track its pending, uncertain, or completed outcome separately. End-to-end completion must wait for that required outcome. A worker crash before commit should leave recoverable work, not a permanent “already processed” marker.
- Recover deliberately. Track pending age, failed attempts, and an explicit retry or review state. If using worker leases, expired leases need a recovery rule. A uniqueness constraint by itself does not schedule that recovery.
An already accepted delivery can receive a successful acknowledgment without being processed again only if your durable pending work remains recoverable. That is different from deleting failed work and hoping the provider will resend it.
Test the crash points, not only the happy path
| Stop point | What must survive | Next-attempt behavior to check |
|---|---|---|
| Before receipt commit | Nothing has been accepted yet | No success acknowledgment; follow the provider’s recovery mechanism |
| After receipt commit, before acknowledgment | Pending receipt | Repeated delivery must not create another task; a worker still processes the first |
| During the local business transaction | Pending receipt, with uncommitted changes rolled back | Retry the transition safely |
| After local commit, before worker acknowledgment | Locally completed receipt, business state, and required external intent | Retry skips the local effect; external work retains its separate recovery state |
| After remote success, before local confirmation | An unresolved external attempt | Reconcile or retry with the remote service’s supported idempotency mechanism |
We checked the middle local-transaction cases with a small, fictional SQLite ledger: a forced rollback preserved the pending receipt; retry applied one local entitlement; a repeated receipt did not apply it again; a different event ID for that same entitlement did not add another entitlement; another account could receive its own entitlement. These are local assertions, not a concurrency benchmark or a production webhook test.
Choose two keys, not one magic key
A receipt key answers “Have I accepted this delivery’s event?” A business key answers “Has this particular transition already happened?” Keep tenant and integration boundaries explicit when identifiers are not guaranteed globally unique.
For a fictional product, a business key might be (account, paid_invoice, entitlement_grant). That can be reasonable for a one-time grant. It is not a universal key for subscription updates: the same subscription can legitimately change repeatedly. Define the transition before choosing its uniqueness rule. Do not permanently suppress every future update merely because its object ID and event type match an earlier update.
GitHub documents that a redelivery keeps the original X-GitHub-Delivery value. That makes the identifier useful for delivery tracking; do not confuse a delivery header with proof of authenticated business state. GitHub webhook best practices
External effects need a separate answer
A transactional outbox can preserve the intent to call another service alongside a local change. It does not make that later remote call exactly-once: the receiver may succeed while the sender loses the response.
Before automating a non-repeatable effect, establish the receiver’s idempotency-key scope and retention, whether you can query the outcome, and what happens after that retention expires. If those answers are absent, use an explicit uncertain state and reconciliation or review—not an unconditional retry of a payment, shipment, or access-changing action.
Provider recovery also differs. GitHub does not automatically redeliver failed webhook deliveries; its documentation describes manual or scheduled recovery. Do not import another provider’s retry assumptions into that integration. GitHub failed-delivery handling
Before shipping: write one failure fixture for every row in the crash table, define receipt and business keys separately, and assign an owner to stuck or uncertain work. For adjacent boundaries, see valid output versus permission to act and bounded downstream automation.
Sources & scope
Local synthetic SQLite transaction checks only; no provider requests, concurrent workers, network failure injection, or production integration tested.
- Stripe — Webhooks
- GitHub — Best practices for using webhooks
- GitHub — Handling failed webhook deliveries
- Slack — Verifying requests from Slack
Publishing details & corrections
Prepared with AI assistance. Sources, any tests performed, and their limitations are described in the article.
No material corrections recorded for this version.