Skip to content
API Contractstable

Idempotent webhook ingestion contract

The strict contract for receiving at-least-once webhooks: verify the signature, dedupe on the provider's event ID via an atomic insert, apply the effect in the same transaction, and always answer 2xx once accepted.

Webhook providers deliver at least once and retry until they receive a 2xx. This contract defines how an endpoint accepts those deliveries exactly once in effect: signature verification, deduplication keyed on the provider's stable event ID, and an atomic insert that gates the side effect. It is the receiving counterpart to the “charged twice” failure mode.

Contract

Endpoint

A single POST endpoint receives the raw request body (needed byte-for-byte for signature verification) and the provider's signature header. It performs no business logic inline beyond recording and enqueuing.

ElementValue / rule
Method / pathPOST /webhooks/{provider}
BodyRaw bytes, unparsed until signature is verified
Required headerProvider signature (e.g. Stripe-Signature)
Idempotency keyProvider event ID from the verified payload (e.g. evt_…)
Max processingRecord + enqueue only; heavy work is async
Request contract

Contract

Response contract

Responses are chosen to control the provider's retry behaviour. A duplicate is a success, not an error — returning non-2xx for an already-seen event invites another redelivery and amplifies load.

CodeConditionProvider behaviour
200Accepted, or duplicate no-opStops retrying — correct for both
400Signature invalid / unverifiableStops retrying — reject bad senders
5xxTransient internal failureRetries later — desired for real outages
Response codes and their meaning to the provider

Definition

Deduplication table

A dedicated table records processed event IDs with a unique constraint. The insert into this table — not a prior SELECT — is the concurrency gate: the first delivery inserts and proceeds, a concurrent duplicate hits the unique violation and no-ops. Detection is via the typed constraint violation, never by matching an error-message string.

processed_events — the atomic gate
CREATE TABLE processed_events (
    provider          text        NOT NULL,
    provider_event_id text        NOT NULL,
    received_at       timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (provider, provider_event_id)   -- the uniqueness that dedupes
);

Contract

Processing rule

The dedup insert and the side effect it authorises commit together, in one transaction. Either both happen or neither does — there is no state where the event is marked processed but the effect was lost, or the effect applied but the event can be replayed.

Insert-gates-effect, in a single transaction
DB::transaction(function () use ($event) {
    $inserted = DB::table('processed_events')->insertOrIgnore([
        'provider'          => 'stripe',
        'provider_event_id' => $event->id,   // canonical dedupe key
    ]);
    if ($inserted === 0) {
        return;                              // duplicate: idempotent no-op, still 2xx
    }
    $this->apply($event);                    // effect commits with the dedupe row
});

Invariants this spec guarantees

  • An unverified or wrongly-signed request is rejected before any effect and cannot be processed.
  • Each provider event ID produces its effect exactly once, regardless of how many times it is delivered.
  • The processed marker and the side effect are committed atomically — never one without the other.
  • A duplicate delivery returns 2xx, so the provider stops retrying rather than amplifying load.

Want this specified for your system?

We turn definitions like these into the actual schema, policies, and contracts your system runs on. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint