Index

Integration Without Illusions

A Webhook Delivery Is Not a Unique Event

A subscription callback had already created this local record:

carrier: external-freight
subscription_id: sub-demo-17
secret: old-secret
expires_at: 2018-05-08T12:00:00Z

The sender delivered another validation message for sub-demo-17, now with a new secret and expiry. The handler tried to insert a second row. A unique constraint rejected it, so the activation step never ran.

The database was right. The handler had mistaken another delivery for another subscription.

Start with the identity the sender repeated

A webhook request is evidence that a message was delivered. It is not evidence that this message has never arrived before.

The retained callback included a subscription URL, secret, and optional expiry. The handler extracted the remote subscription identifier from the URL. The table already declared the durable identity as:

(carrier, subscription_id)

That compound key answers the useful question: does this callback describe a subscription the application already knows?

Payload equality would answer a weaker question. A replay can carry a refreshed secret or expiry while still referring to the same subscription. Hashing the whole body would classify that delivery as new and reproduce the original mistake in a less obvious form.

Let repeated delivery converge

The first handler used an unconditional insert:

Subscription::create([
    'carrier' => $carrier,
    'subscription_id' => $subscriptionId,
    'secret' => $secret,
    'expires_at' => $expiresAt,
]);

That works only while every identity arrives once. The schema disagreed with that assumption by enforcing uniqueness.

The correction used the same identity for lookup and refreshed the mutable representation:

Subscription::query()->updateOrCreate(
    [
        'carrier' => $carrier,
        'subscription_id' => $subscriptionId,
    ],
    [
        'secret' => $secret,
        'expires_at' => $expiresAt,
    ],
);

After two deliveries, there is still one subscription. Its secret and expiry match the later message, and activation can continue.

This is convergence: processing the same logical identity again moves local state toward the current representation instead of multiplying it.

Decide which fields identify and which fields refresh

An upsert is only as sound as that split.

FieldRoleReplay behavior
carrieridentity namespacemust match
subscription identifierstable remote identitymust match
secretmutable subscription datareplace
expirymutable subscription datareplace
activation timestamplocal lifecycle statepreserve unless activation changes it

Putting the secret in the lookup attributes would create another identity whenever the sender rotates it. Putting only the remote identifier there could merge two carriers which happen to issue the same identifier.

The unique constraint and the handler should express the same decision. If the application looks up by one set of fields while the database protects another, the disagreement will surface under replay or concurrency.

An upsert does not make the whole handler idempotent

The retained fix proves a narrow result:

same carrier + same subscription identifier
    -> one local row
    -> latest secret and expiry
    -> activation attempted again

It does not prove that every later effect happens once. Activation is a remote call. Repeating it may be harmless, necessary, or dangerous depending on that API’s contract.

The handler therefore needs two separate decisions:

  1. What local state should converge when this message is repeated?
  2. What should happen when the downstream action is repeated?

If remote activation is itself idempotent by subscription identity, calling it again can be the recovery path. If it creates a new resource or charges money, the consumer needs a stable remote idempotency key or a durable local state machine before making the call.

Calling the database operation updateOrCreate does not settle that protocol question.

Keep the original delivery for diagnosis

Convergent state and delivery history serve different purposes.

The subscription row answers what the application currently believes. A separate inbound record can answer what arrived, when it arrived, which headers were present, whether authentication passed, and how processing ended.

delivery record
    immutable received request and processing outcome

subscription record
    current state keyed by remote subscription identity

Overwriting the current row is appropriate for a refreshed secret. Overwriting the only copy of the delivery would make a failed replay harder to investigate. The retained system stored the inbound webhook separately from the subscription it updated, preserving that distinction.

Retention still needs an explicit policy. Secrets and personal data should not be kept indefinitely merely because diagnostic storage exists.

Test the second delivery, not only the first

A creation test cannot expose this defect. The useful regression begins with existing state:

given:
    one subscription with old secret and expiry

when:
    another validation arrives for the same identity

then:
    exactly one subscription remains
    secret and expiry are refreshed
    activation is attempted

Add a contrasting case for a genuinely different subscription identifier. It should create a second row. Without that case, a fix which collapses every subscription for one carrier could look idempotent while losing valid state.

The public fixture runs all three paths: naïve duplicate insertion fails, replay-aware handling converges, and a new identity remains separate.

Do not claim more than one replay proves

This retained change demonstrates duplicate delivery for one validation callback. It does not demonstrate that the provider omitted callbacks, changed their order, or delivered them concurrently. Those are reasonable integration risks to evaluate, but they require their own evidence and policies.

The practical rule is smaller and more useful: when the remote protocol gives a stable identity, use it to decide whether another delivery represents new work or a newer description of work already known. Then test the second delivery.

The database constraint had already documented that identity. Once the handler agreed with it, repetition stopped being an exceptional path and became an ordinary state transition.