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.

Conformance language follows RFC 2119: MUST / MUST NOT are enforceable requirements, SHOULD / SHOULD NOT are strong recommendations with legitimate exceptions, and MAY marks a genuine choice.

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

Signature verification

The signature MUST be verified before the body is parsed or trusted. Verification is an HMAC — HMAC-SHA256 over the raw request bytes (with the timestamp prefixed where the provider prescribes) — compared in constant time against the header value, using a signing secret read from a secure store, never hard-coded or logged. A mismatch is a 400; verification failure is never a fall-through to trusting the payload.

A replayed-but-validly-signed request is still rejected by a timestamp check: the signed material carries a timestamp, and a delivery whose timestamp is outside a tolerance window — default 5 minutes — is refused even if the signature verifies, bounding how long a captured request stays usable. The timestamp is part of the signed bytes, so it cannot be altered without breaking the signature.

Key rotation is supported by accepting more than one active secret during an overlap window: verification tries each currently-valid secret and passes if any matches, so a rotation deploys without dropping in-flight deliveries. A retired secret is removed once the overlap exceeds the provider's retry window.

HMAC-SHA256, constant-time, timestamp window, rotation overlap
$raw = $request->getContent();                 // exact signed bytes
$ts  = (int) $request->header('X-Timestamp');
$sig = $request->header('X-Signature', '');

foreach (config('webhooks.active_secrets') as $secret) {   // >1 during rotation overlap
    $expected = hash_hmac('sha256', "{$ts}.{$raw}", $secret);
    if (hash_equals($expected, $sig)) {
        if (abs(now()->timestamp - $ts) > 300) abort(400, 'stale'); // 5-minute window
        return;                                // verified and fresh
    }
}
abort(400, 'invalid signature');

The signing secret MUST come from a secure store, the HMAC comparison MUST be constant-time, and a delivery outside the timestamp tolerance (default 5 minutes) MUST be rejected even when the signature is valid.

Definition

Idempotency key extraction

The dedupe key is the provider's stable event ID, taken from the verified payload and normalized — trimmed and lower-cased — so trivial formatting differences don't defeat deduplication. The normalized key, scoped by provider, is what the unique constraint enforces.

Providers that do not supply a stable event ID need a constructed key: a deterministic composite of the provider, the resource/source, and the provider's own external identifier (an object id plus an event type, or an object id plus a version), chosen so the same logical event always yields the same key and two genuinely-distinct events never collide. A payload hash is a last resort — it conflates legitimately-identical events and varies with serialization.

A delivery whose key matches an already-processed event but whose payload differs is an anomaly, not a routine duplicate. It MUST be rejected — a duplicate key with a changed body must not silently overwrite or re-process — and logged as a suspected spoof or provider bug for investigation, never treated as a normal redelivery.

Keys are normalized (trim + lower-case) and provider-scoped; where no stable ID exists, construct a deterministic key from provider + source + external id; a same-key/different-payload delivery MUST be rejected and logged as an anomaly.

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

Deduplication table maintenance

The dedupe table grows with every event and MUST be bounded by retention, not kept forever. An entry is needed only long enough to catch the provider's redeliveries: retention is at least the provider's maximum retry window and defaults to 7 days. Beyond that a re-arriving event is astronomically unlikely, and re-processing it if it did occur is harmless given idempotent effects.

Old entries are purged by partitioning the table by received-at (daily or weekly partitions) and dropping whole partitions past the retention horizon — a partition drop is cheap and lock-light, where a bulk DELETE on a hot table is neither. New partitions are provisioned ahead of time.

Table size and purge health are monitored: an alert fires if the table grows past its expected bound (a stalled or failed purge), if the oldest un-purged partition exceeds retention, or if insert latency on the unique constraint climbs — each a sign the dedupe gate is degrading.

Retention MUST be at least the provider's retry window (default 7 days); purge by dropping date partitions, not a bulk DELETE; and alert on table growth past its expected bound or a stalled purge.

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
});

Atomicity holds for effects that participate in the same database transaction as the dedupe insert. An effect with an external side effect (an outbound API call, an email) is not covered by that atomicity and MUST itself be idempotent, so a retry after a partial failure does not double-apply it.

Contract

Enqueue failure

When the endpoint records-and-enqueues rather than applying inline, the dedupe insert and the enqueue MUST be atomic. If the enqueue fails, the dedupe insert is rolled back and the endpoint returns 5xx, so the event is not marked processed with no work queued and the provider redelivers it. The safe construction is a transactional outbox — the enqueue is a row committed with the dedupe insert and relayed after commit — or an equivalent commit-then-enqueue with rollback on failure.

Once the endpoint has returned 2xx, the provider's obligation is discharged; the webhook delivery and the job's execution are then independent. The downstream job retries on its own schedule, with its own backoff and dead-letter (see the async write and job contract) — a job failure MUST NOT ask the provider to redeliver, because the provider has already been told the event was accepted.

Record + enqueue MUST be atomic: an enqueue failure rolls back the dedupe insert and returns 5xx (the provider retries). After 2xx, job retries are internal and independent of webhook delivery.

Contract

Replay protection

The timestamp window is the first-line defence: a captured, validly-signed request replayed later is rejected once it falls outside the tolerance (default 5 minutes, per Signature verification), so the replay window is bounded even before deduplication.

The endpoint SHOULD be rate-limited — per source and per provider — so a flood of deliveries (malicious or a provider malfunction) cannot exhaust the dedupe table or the processing path; excess is shed with 429, which providers treat as retryable.

Duplicate-with-payload-mismatch is an anomaly signal, not a duplicate: a run of same-key/different-payload deliveries (rejected during key extraction) is surfaced to anomaly detection as a possible spoof or a compromised signing secret, distinct from ordinary at-least-once redelivery.

Replay is bounded by the timestamp window first, the endpoint is rate-limited (429 on excess), and same-key/different-payload deliveries are escalated as an anomaly rather than counted as routine duplicates.

Contract

Error handling and observability

Ingestion is invisible unless measured. The endpoint MUST emit, at minimum, per provider: total deliveries, signature failures, dedupe hits (duplicates), and processing errors. The ratios between them are the health signal — a spike in signature failures points at a rotation gone wrong or a spoof; a spike in dedupe hits points at a provider stuck redelivering; a spike in processing errors points at a downstream fault.

Alerting is on rates over a window, not single events: page on a signature-failure rate above a small percentage (a legitimate provider signs correctly), on a sustained processing-error rate, and on a dedupe-hit rate that signals a redelivery storm. A single failure is noise; a rate is a signal.

Each delivery is logged with its provider, event id, verification outcome, dedupe result (fresh / duplicate), and processing outcome — never the raw secret or full sensitive payload — so a specific event can be traced end-to-end during an incident.

MetricAlert when
Deliveries (per provider)Baseline — context for the ratios below
Signature failuresRate exceeds a small % — rotation error or spoof
Dedupe hits (duplicates)Sustained spike — provider stuck redelivering
Processing errorsSustained rate — downstream fault
Minimum metrics and alert thresholds

Invariants this spec guarantees

  • The signature MUST be verified before the body is trusted — HMAC-SHA256 over the raw bytes, constant-time comparison, secret from a secure store — and a validly-signed delivery outside the timestamp tolerance (default 5 minutes) MUST still be rejected.
  • The dedupe key is the provider's stable event ID, normalized (trim + lower-case) and provider-scoped; where no stable ID exists it is a deterministic composite of provider + source + external id.
  • A delivery whose key matches a processed event but whose payload differs MUST be rejected and logged as an anomaly, never silently accepted.
  • Each provider event ID's effect is applied at most once (exactly once given at-least-once delivery) via the unique-insert gate; an effect with external side effects MUST itself be idempotent, since the transaction's atomicity does not extend to it.
  • Record + enqueue MUST be atomic: an enqueue failure rolls back the dedupe insert and returns 5xx; after 2xx, job retries are internal and independent of webhook delivery.
  • A duplicate delivery MUST return 2xx, so the provider stops retrying rather than amplifying load.
  • The dedupe table MUST be retained at least the provider's retry window (default 7 days), purged by dropping date partitions, and monitored for growth.
  • Ingestion MUST emit deliveries, signature failures, dedupe hits, and processing errors, with rate-based alerts and per-delivery logging.

Revision history: revised 24 August 2026 for consistency (RFC 2119; “exactly once” scoped to in-effect; atomicity boundary). Revised 25 August 2026 to add signature-verification detail (HMAC-SHA256, secure-store secret, 5-minute timestamp window, key rotation), idempotency-key extraction (normalization, constructed keys, same-key/different-payload anomaly), enqueue-failure handling (atomic record+enqueue, 5xx rollback, independent job retries), replay protection (timestamp window, rate limiting, anomaly detection), dedupe-table maintenance (7-day retention, partition purge, size monitoring), and error handling & observability (metrics, thresholds, per-delivery logging).

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