Skip to content
Tech

Receiving Webhooks Safely: Verification, Retries and Idempotency

Webhooks are easy to receive and easy to get wrong. Verify signatures, respond fast, process asynchronously and handle duplicate deliveries gracefully.

Editorial illustration of envelopes travelling along a glowing wire into a shielded server inbox
Editorial illustration of envelopes travelling along a glowing wire into a shielded server inbox
In this article

    Payment gateways, messaging platforms, code hosts and countless SaaS tools use webhooks to tell your application that something happened. Receiving them looks trivial: expose a URL and read the JSON. In practice, a production-ready webhook endpoint needs to handle forged requests, slow processing, retries and duplicate events.

    Verify every request

    Your webhook URL is public, so anyone can send it a request. Most providers sign each payload with a shared secret using HMAC, and send the signature in a header. Recompute the signature over the raw request body and compare it using a constant-time comparison.

    $payload = $request->getContent();
    $expected = hash_hmac('sha256', $payload, config('services.vendor.webhook_secret'));
    
    if (! hash_equals($expected, $request->header('X-Signature', ''))) {
        abort(401);
    }

    Use the raw body, not a re-encoded version of parsed JSON, because even a whitespace difference changes the signature. Store the secret in environment configuration, never in code.

    Guard against replays

    An attacker who captures a valid request could resend it later. Many providers include a timestamp in the signed data. Reject requests whose timestamp is more than a few minutes old, and combine this with the deduplication described below.

    Respond quickly

    Providers expect a fast success response and will retry if they do not receive one within their timeout. If your endpoint sends emails, calls other APIs or updates many rows before replying, it may time out and trigger retries for work that actually succeeded.

    • Validate the signature.
    • Store the raw event or dispatch a queued job.
    • Return a 2xx response immediately.
    • Do the real work in a background worker.

    Expect duplicates

    Webhook delivery is typically "at least once": the same event can arrive more than once due to retries or network issues. Your processing must be idempotent. The simplest approach is to record each event's unique ID with a unique database constraint and skip events you have already handled.

    $inserted = DB::table('webhook_events')->insertOrIgnore([
        'event_id' => $event['id'],
        'type' => $event['type'],
        'received_at' => now(),
    ]);
    
    if ($inserted === 0) {
        return response()->noContent(); // already seen
    }

    Do not trust event order

    Events can arrive out of order. A "subscription updated" event might land before "subscription created". Where order matters, compare timestamps or version numbers on the resource, or fetch the latest state from the provider's API using the ID in the event rather than relying on the payload alone.

    Handle failures inside your system

    If your background job fails, let the queue retry it with backoff. After repeated failures, move it to a failed-jobs table and alert someone. Because you stored the raw event, you can fix the bug and replay it without asking the provider to resend.

    Keep the endpoint narrow

    • Accept only the HTTP method the provider uses, usually POST.
    • Exclude the route from CSRF protection, but only that route.
    • Apply rate limiting to blunt floods of junk requests.
    • Log the event ID and type, not the full payload, if it contains personal data.

    Test with real tooling

    Most providers offer a dashboard or CLI that can send test events to a local tunnel. Write automated tests that post signed sample payloads to your endpoint and assert both the happy path and rejection of bad signatures, stale timestamps and duplicates.

    Keep a webhook event log

    Store each received event with its ID, type, received time, processing status and any error message. A small admin screen listing recent events makes debugging integrations far easier and lets support staff confirm whether a payment or status change actually arrived. Purge old events on a schedule to keep the table small and respect data retention policies.

    Wrapping up

    A trustworthy webhook receiver verifies signatures over the raw body, rejects stale requests, acknowledges quickly, processes in the background, deduplicates by event ID and copes with events arriving out of order. Build these habits once into a reusable pattern and every new integration becomes far less risky.

    Written by Raj

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.