Skip to content

Reliability & Integrations / Engineering guide

Your Webhook Was Delivered Twice. Now What?

By ·

The receiver accepts your webhook and creates the shipment. Its response never reaches you. You retry. What stops the second delivery from creating a second shipment?

A timeout describes your knowledge, not the receiver’s state

Consider an illustrative partner integration: order 42 is confirmed, and your application sends a webhook asking a fulfilment service to act. The service commits its update and returns success, but the connection breaks before your sender reads the response. From the sender’s perspective, delivery failed. From the receiver’s perspective, the work is already done.

One event, two delivery attempts

The missing reply creates uncertainty.

  1. 01 / SENDOrder confirmedThe sender transmits the event.evt-order-42-v1
  2. 02 / ACTShipment createdThe receiver commits the business action.shipment for order 42
  3. 03 / LOSEReply lostThe sender cannot confirm success.transport timeout
  4. 04 / RETRYSame event againThe receiver must recognize the repeated intent.evt-order-42-v1
The shipment is an illustrative application action. Delivery tracking alone cannot determine whether that action already happened.

This is why retrying and recognizing duplicates belong together. Stripe, for example, explicitly documents that webhook endpoints can receive duplicate events and that event delivery order is not guaranteed. Its webhook guidance recommends tracking event identity rather than assuming every receipt is new.

Semitexa provides mechanisms for durable delivery records, retries, and duplicate recognition. To use them correctly, first separate the identities each mechanism protects.

One delivery key cannot answer three different questions

Publication identity asks whether your application has already queued this outbound intent for this endpoint. Event identity asks whether the receiver has already recorded this provider event. Business identity asks whether the requested effect is already complete for the relevant order, account, or other domain object.

For the example, an outbound key might be order:42:confirmed:v1, an event ID evt-order-42-v1, and the business rule “one shipment for this fulfilment intent.” These are application choices. A later legitimate confirmation or a replacement shipment needs an identity that distinguishes the new intent from a retry.

A freshly generated key on every retry defeats duplicate recognition. A key that collapses all activity for an order can suppress legitimate later work. Choose the identity according to what may happen once, and retain it across attempts to perform that same thing.

Publish an intent once; let the worker attempt delivery

In Semitexa, WebhookPublisher resolves an enabled endpoint, constructs a pending OutboundDelivery, and calls the repository’s insertOrMatchIdempotency(). It records delivery intent; the delivery worker performs transport separately.

use Semitexa\Webhooks\Domain\Model\OutboundWebhookMessage;

$message = new OutboundWebhookMessage(
    endpointKey: 'partner-orders',
    eventType: 'order.confirmed',
    payload: ['id' => 'evt-order-42-v1', 'order_id' => 42],
    headers: ['X-Webhook-Event-Id' => 'evt-order-42-v1'],
    idempotencyKey: 'order:42:confirmed:v1',
    sourceRef: 'order:42',
);

// With a configured endpoint and an injected publisher:
$publisher->publish($message);

The example makes both identities explicit. The publisher’s idempotencyKey is optional. In the shipped MySQL persistence model, non-null keys are unique per (endpoint_definition_id, idempotency_key); a matching publication returns the existing record. Null keys create separate records. This is a database constraint, which matters when concurrent callers attempt the same publication.

The custom event-ID header serves a separate purpose: giving the receiver a stable identifier. The transport forwards configured and message headers; do not assume the publication key automatically becomes a receiver deduplication header. This is also not a Stripe-specific adapter. Match headers, signatures, and event normalization to the actual partner protocol.

Give the receiver durable memory of the event

InboundWebhookReceiver resolves the endpoint, verifies the envelope, derives an event identity, and builds an inbox delivery. The dedupe key factory scopes a provider event ID by provider, endpoint, and tenant. The repository claims that key through a unique constraint on webhook_inbox.dedupe_key, rather than a race-prone “look first, insert later” sequence.

use Semitexa\Webhooks\Application\Service\Inbound\InboundDedupeKeyFactory;

$key = (new InboundDedupeKeyFactory())->generate(
    'partner', 'orders', 'evt-order-42-v1', $rawBody, 'shop-a',
);

// tenant:shop-a:provider:partner:endpoint:orders:event:evt-order-42-v1

The current receiver recognizes event IDs in X-Webhook-Event-Id, X-GitHub-Delivery, or Stripe-Webhook-Id, with case-insensitive header names. It does not automatically read an id field from arbitrary JSON. Without a recognized event ID, the factory uses a SHA-256 hash of the raw body. Two differently formatted bodies can therefore produce different keys even when they describe the same business intent.

When a record matches, the repository marks it DuplicateIgnored, and the receiver returns before invoking the optional processor. That prevents normal repeated receipt from invoking it again. It does not establish that the original processor finished successfully. The distinction becomes critical when the first attempt failed after recording the event.

Repeat temporary failures, with a finite budget

WebhookDeliveryWorker::processOne() claims a due delivery, calls the transport, then records success, permanent failure, or a scheduled retry. The implemented policy distinguishes a rejection from a temporary inability to finish:

Observed resultWorker decision
Successful transport, normally HTTP 2xxMark delivered.
HTTP 408 or 429Schedule a retry if attempts remain.
Other HTTP 4xxMark failed as a permanent rejection.
HTTP 5xx, transport exception, or no HTTP statusRetry if attempts remain; otherwise mark failed.
A failure explicitly marked permanent by the transportMark failed without scheduling another attempt.

BackoffCalculator increases the base delay exponentially, adds jitter, and caps the result. For an initial delay of 30 seconds, the first attempt’s calculated delay is 23 to 37 seconds in the current implementation; the second is 45 to 75 seconds, before any lower configured cap. These are possible ranges, not a promise of exact execution times. A due record still needs a running worker.

The classification does not prove that the recipient performed no work. A timeout or server error may occur after a side effect. Retry scheduling improves the chance of delivery; receiver and business identities make that repetition manageable.

A lease protects the record, not the remote side effect

The outbox claim delegates to claimAndLease(). The persistence path claims ownership and increments the attempt count; finalization methods such as markDeliveredIfOwned() check that the worker still owns the delivery.

Suppose a worker sends the HTTP request, then loses its lease before finalization. The receiver may already have acted. Semitexa’s worker reports the lost-lease outcome and avoids overwriting a record it no longer owns. That is a useful coordination boundary, but it cannot retract the request already sent.

The same caution applies when describing an outbox as “transactional.” A pending delivery record is useful, but atomicity between your order update and its publication requires an application transaction arrangement that actually includes both writes. Calling publish() after a domain write does not by itself establish that guarantee.

Protect the business action where it happens

Expand each scenario below. The sender sees a timeout in all three, while the recovery decision depends on receiver state:

The request never reached the receiver

A repeated delivery may be the first successful receipt. Keep the event identity stable so another retry remains recognizable. There is no earlier business effect to suppress.

The receiver committed the action, but the reply was lost

The repeated event should find the recorded receipt or completed business intent. The order should not acquire a second shipment merely because the sender needs confirmation.

The receiver recorded the event, then failed during processing

An inbox match can prevent the processor from running again without completing the intended action. Investigate persisted business state and provide an explicit recovery path; “duplicate” and “completed” are different facts.

For a local database action, design the completion record and business update to commit together where possible, using a unique business identity and a transaction. For an external side effect, use the recipient’s supported idempotency mechanism or a recovery and reconciliation process. An inbox record cannot make an email, shipment request, or remote API operation atomic with your database.

Event ordering is another boundary. Recognizing a repeated event does not stop an older, distinct event from overwriting newer state. Decide how your domain accepts versions or reconciles authoritative state. The DDD article explores explicit business decisions and consistency responsibilities; the background-work article covers carrying the correct tenant into that work.

Inspect the delivery before reaching for replay

Start with the inbox or outbox record and its attempt history. Establish which identity was used, whether a retry is due, and what the receiver’s business state says. Semitexa provides inspection commands:

bin/semitexa webhook:show outbox --status=failed --limit=20
bin/semitexa webhook:show inbox --limit=20

webhook:replay:outbound resets an outbound delivery to pending for redelivery. That can be useful after correcting a target or configuration, but it may send an event the recipient already acted on. Verify the receiver’s duplicate handling before treating replay as harmless.

Do not interpret webhook:replay:inbound as a general retry of your business processor. The current command reconstructs the envelope and calls receive() without a processor; an existing dedupe key can still match. Application recovery needs to explicitly identify and complete the unfinished business intent.

What was verified for this article? The real installed key factory, failure classifier, backoff calculator, and message DTO passed a runnable example with 13 checks. Focused existing unit tests passed 36 tests and 82 assertions. Repository uniqueness and worker ownership behavior were inspected in source; database concurrency, real HTTP delivery, and a fulfilment side effect were not executed. The shipment story is illustrative.

A useful integration check is therefore more specific than “the endpoint returns 200.” Deliver the same logical event twice, interrupt execution at a meaningful boundary, and inspect the resulting business state. The goal is a completed action with a recoverable history, even when the delivery path repeats.

← Back to the Semitexa Blog

Have a product idea?One free MVP every month