← All posts

Handling webhooks safely: signatures, speed and retries

Payment providers, form tools and messaging apps all send webhooks. Here is how I make sure a webhook endpoint is secure, fast and safe to call twice.

Written byMuwafaq Murad

Handling webhooks safely: signatures, speed and retries, cover

A webhook is another service calling your app to say "something happened": a payment went through, a form was submitted, a message arrived. It looks simple, just a POST route. But a careless webhook endpoint can be faked, can time out, or can process the same event twice. These are the three things I always handle.

1. Check that the request is real

Anyone who finds your webhook URL can send a request to it. Most providers sign each request with a shared secret, usually as an HMAC in a header. Before trusting anything in the body, check the signature.

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

if (! hash_equals($expected, (string) $request->header('X-Signature'))) {
    abort(401);
}

Two details matter. Use the raw request body, not re-encoded JSON, because any change in spacing breaks the signature. And compare with hash_equals, which is safe against timing attacks. The header name and format differ per provider, so follow their docs exactly. Many also include a timestamp you should check, to reject old replayed requests.

Webhook routes also need to be excluded from CSRF protection, since the caller cannot send a CSRF token. The signature check takes its place.

2. Answer fast, work later

Providers wait only a short time for a response. If your endpoint sends emails and updates five tables before replying, it may time out, and the provider will mark it as failed and send it again.

My endpoint does as little as possible: verify the signature, store the event, dispatch a queued job and return 200. The job does the real work in the background.

3. Expect the same event twice

Retries are normal. A network blip, a slow reply, or a provider bug can deliver one event more than once. If your code marks an order as paid and sends a receipt every time, the customer gets two receipts, or worse.

The fix is to make processing idempotent. Most events carry a unique ID. I save it in a table with a unique index, and if the ID already exists, I skip it.

$event = WebhookEvent::firstOrCreate(
    ['provider_event_id' => $request->input('id')],
    ['payload' => $payload]
);

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

Keep a record

Storing every event has another benefit. When a client asks "why did this order not update?", I can look at exactly what the provider sent and when. Many issues turn out to be events that never arrived, and the stored log proves it in a minute instead of an afternoon.