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

ClaimEvidence you needWhat it does not establish
This request passed authenticationProvider-specific verification of the received bytes and required freshness checksThat this is its first delivery
We accepted responsibility for this eventA committed receipt or durable queue entryThat the business operation completed
We applied this business transitionA committed state change with a domain-specific uniqueness ruleThat 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.

Four stages: verify the request, commit a pending receipt before acknowledgment, atomically apply a local business transition, and reconcile any separate external effect. A crash after an external effect can leave its outcome unknown.

The external-effect boundary is intentionally separate. A local database transaction cannot roll back a remote service.

  1. 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.
  2. 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.
  3. 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.
  4. 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 pointWhat must surviveNext-attempt behavior to check
Before receipt commitNothing has been accepted yetNo success acknowledgment; follow the provider’s recovery mechanism
After receipt commit, before acknowledgmentPending receiptRepeated delivery must not create another task; a worker still processes the first
During the local business transactionPending receipt, with uncommitted changes rolled backRetry the transition safely
After local commit, before worker acknowledgmentLocally completed receipt, business state, and required external intentRetry skips the local effect; external work retains its separate recovery state
After remote success, before local confirmationAn unresolved external attemptReconcile 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.

  1. Stripe — Webhooks
  2. GitHub — Best practices for using webhooks
  3. GitHub — Handling failed webhook deliveries
  4. 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.

Send a correction

Back to writingEditorial standards