Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Webhooks: How They Work and How to Make Them Reliable

About Post

Writing a webhook endpoint takes ten minutes. A route, a controller, read the JSON, update the payment. It works in testing on the first try.

Then, in production, it gets a request it can't verify, the same event twice, two events in the wrong order, and a retry storm because your endpoint took too long to answer. None of it shows up in the happy-path demo. All of it shows up eventually.

Let's look at how webhooks actually work, and the handful of rules that turn a ten-minute endpoint into one you can trust with money.

Polling vs webhooks: checking the door vs a doorbell

Say you're waiting for a payment provider to confirm a payment. You have two options.

Polling: ask every few seconds. "Is it paid? Is it paid? How about now?" It's simple, and it's wasteful: most requests return "no", and you still find out late, up to one polling interval after it happened.

Webhooks: give the provider a URL and let it call you when something happens. A doorbell instead of opening the door every minute to check. The provider sends an HTTP POST to your endpoint with a JSON body describing the event, like payment.succeeded.

The catch is that a doorbell can be pressed by anyone, more than once, and at awkward times. That's what the rest of this article is about.

Rule 1: verify the signature, every time

Your webhook URL is public. Anyone who finds it can POST a fake "payment succeeded" to it. So providers sign each request: they compute an HMAC of the body using a secret only you and they know, and send it in a header. You compute the same HMAC and compare.

Exact header names and formats vary by provider, so always follow their docs. A common shape, with a timestamp included in the signed data, looks like this:

private function verify(Request $request): void
{
    $timestamp = (string) $request->header('X-Webhook-Timestamp');
    $signature = (string) $request->header('X-Webhook-Signature');

    $expected = hash_hmac(
        'sha256',
        $timestamp.'.'.$request->getContent(),
        config('services.payments.webhook_secret'),
    );

    abort_unless($timestamp !== '' && hash_equals($expected, $signature), 401);

    // Reject old requests so a captured one can't be replayed later
    abort_if(abs(time() - (int) $timestamp) > 300, 401);
}

Three details matter here. Sign the raw body (getContent()), not re-encoded JSON, because re-encoding can change the bytes. Compare with hash_equals(), which takes the same time whether the strings differ at the first character or the last, so an attacker can't learn the signature by timing your responses. And the timestamp check stops replays of an old, validly signed request.

Rule 2: respond fast, work later

Providers wait only a limited time for your response. If your endpoint sends emails, generates a PDF and calls two APIs before answering, it will eventually time out. The provider marks the delivery as failed and sends it again, while your first attempt is still running.

So the endpoint should do as little as possible: verify, store, queue, return 2xx. The real work happens in a queued job.

Rule 3: expect duplicates

Webhook providers deliver at least once, not exactly once. Retries after timeouts, network blips and provider-side issues all mean you'll see the same event twice sooner or later. Every event carries an ID; use it as an idempotency key, protected by a unique index on that column:

public function __invoke(Request $request): Response
{
    $this->verify($request);

    $event = WebhookEvent::createOrFirst(
        ['provider_event_id' => $request->json('id')],
        ['type' => $request->json('type'), 'payload' => $request->getContent()],
    );

    if ($event->wasRecentlyCreated) {
        ProcessWebhookEvent::dispatch($event);
    }

    return response()->noContent();
}

createOrFirst() tries the insert first and falls back to fetching the existing row if the unique index rejects it, so two copies arriving at the same moment can't both be processed. A duplicate gets a polite 204 and nothing else happens.

Storing the raw payload also gives you a free audit trail. When someone asks "did we ever receive the confirmation for this payment?", you can answer in seconds.

Rule 4: don't trust the order

Events can arrive out of order. You might get payment.refunded before payment.succeeded if the first delivery of the success event failed and was retried later. Two defences:

  • Treat the webhook as a notification, then fetch the current state from the provider's API inside your job. The API tells you how things are now, whatever order the events came in.
  • Never move state backwards. If your payment is already refunded, a late succeeded event shouldn't flip it back.

Rule 5: make failures visible

Once processing happens in a queue, failures are quiet by default. Give the job sensible retries and backoff, log the event ID with every error, and alert on jobs landing in the failed jobs table. A webhook that silently fails is worse than one that loudly fails, because nobody knows to look.

Keep a reconciliation fallback for anything important: a scheduled task that asks the provider for recent payments and compares them with your records. Webhooks are fast; reconciliation is the safety net for the rare one that never arrives.

A Laravel note on routing

Webhook routes can't carry a CSRF token. Either define the route in routes/api.php, which has no CSRF middleware, or exclude the path in bootstrap/app.php with $middleware->validateCsrfTokens(except: ['webhooks/*']). The signature check replaces CSRF here, which is exactly why Rule 1 is not optional.

The reliable webhook receiver, in one line: verify the signature, store the event under a unique ID, queue the work, return 2xx quickly, and process in a way that's safe to run twice and in any order.

If you're the one sending webhooks

Flip the rules around and be the provider you'd want: sign every payload, include a unique event ID and a timestamp, retry failed deliveries with exponential backoff, and give your users a way to see recent deliveries and resend one. Document your signature scheme with a working code sample. Your integrators will thank you.

Checklist

  • HMAC signature on the raw body, compared with hash_equals()
  • Timestamp tolerance to stop replays
  • Unique index on the provider's event ID
  • Store, queue, return 2xx fast
  • Fetch current state; never move status backwards
  • Retries, alerts on failures, and a reconciliation job

Which of these did you learn the hard way? My bet is on duplicates.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close