Skip to content
Integrations7 minstable

Wiring a new webhook consumer safely

Every inbound webhook is at-least-once and retried until you 2xx. The checklist to onboard a provider idempotent from the first delivery: verify, dedupe on the event ID, record-and-enqueue, respond correctly.

When to use this

  • You're onboarding a new inbound webhook provider (payments, messaging, CI) and want it correct and idempotent before it ships.
  • An existing consumer is double-processing on retries, or 500-ing and inviting more redeliveries.

Prerequisites

  • The provider's signing secret and the exact bytes they sign (usually the raw request body).
  • A queue for async processing so the endpoint stays fast.
  • A `processed_events` table with a unique key on (provider, event id) — create it if missing.

Procedure

  1. 1

    Verify the signature on the raw body, before parsing

    Read the raw request body and verify the provider's signature against it before you parse or trust anything. Parsing first, or verifying a re-serialised body, breaks the signature. A request that fails verification is rejected outright.

    Verify against raw bytes
    $raw = $request->getContent();                 // exact bytes the provider signed
    $expected = hash_hmac('sha256', $raw, config('services.provider.secret'));
    if (! hash_equals($expected, $request->header('X-Signature', ''))) {
        abort(400, 'invalid signature');           // reject, do not retry-invite
    }
    $event = json_decode($raw, true);              // only now is it safe to parse
  2. 2

    Dedupe on the provider's event ID with an atomic insert

    Key idempotency on the provider's stable event ID, and let a unique insert — not a prior SELECT — be the gate. First delivery inserts and proceeds; a concurrent duplicate hits the constraint and no-ops. Detect the duplicate via the typed constraint, never an error-message string.

    The insert is the lock
    DB::transaction(function () use ($event) {
        $fresh = DB::table('processed_events')->insertOrIgnore([
            'provider'          => 'provider',
            'provider_event_id' => $event['id'],
        ]);
        if ($fresh === 0) {
            return;                                 // duplicate — idempotent no-op
        }
        ProcessWebhook::dispatch($event['id']);     // record-and-enqueue only
    });
  3. 3

    Record and enqueue — never do the heavy work inline

    The endpoint's only job is to verify, dedupe, persist the event, and enqueue. All business logic runs in a queued job that takes the event ID and re-loads current state. This keeps the endpoint fast (providers time out) and the processing retryable.

  4. 4

    Map responses to control the provider's retries

    Return 2xx for accepted and for duplicates — a duplicate is a success, and returning non-2xx invites another redelivery. Return 400 for an unverifiable signature so bad senders stop. Reserve 5xx for genuine transient failures you want retried.

    Response contract
    // accepted OR duplicate -> 200 (stop retrying)
    // bad signature        -> 400 (stop retrying)
    // transient failure    -> 5xx (retry later)
    return response()->noContent();                // 204: accepted / already seen
  5. 5

    Add a dead-letter alarm and a replay path

    A processing job that exhausts its retries must page, not vanish. And keep a replay tool keyed on the event ID so you can safely re-drive a failed event once the bug is fixed — idempotency makes replay safe.

Verify it worked

  • The same event delivered twice produces exactly one effect (replay it and confirm).
  • A request with a bad signature returns 400 and produces no effect.
  • The provider's delivery dashboard shows 2xx for both first deliveries and retries — no redelivery storms.
  • A job that exhausts retries appears in the dead-letter store and fires an alert.

If it goes wrong — rollback

  • Take the endpoint to 503 so the provider queues and retries deliveries while you fix — no events are lost.
  • Re-enable, then replay any events that arrived during the outage by their event IDs.

Want this run for you?

We implement and operate procedures like this in production systems where a misstep is expensive. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint