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.
| Element | Value / rule |
|---|---|
| Method / path | POST /webhooks/{provider} |
| Body | Raw bytes, unparsed until signature is verified |
| Required header | Provider signature (e.g. Stripe-Signature) |
| Idempotency key | Provider event ID from the verified payload (e.g. evt_…) |
| Max processing | Record + enqueue only; heavy work is async |
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.
| Code | Condition | Provider behaviour |
|---|---|---|
| 200 | Accepted, or duplicate no-op | Stops retrying — correct for both |
| 400 | Signature invalid / unverifiable | Stops retrying — reject bad senders |
| 5xx | Transient internal failure | Retries later — desired for real outages |
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.
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.
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.