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

Writing Webhooks You Can Trust: A Laravel Guide to Receiving Them Safely

About Post

Here's an uncomfortable way to look at a webhook endpoint: it's a public URL, with no login, that marks invoices as paid.

Your payment gateway calls it. So could anyone else who finds or guesses it. And even the genuine calls arrive twice, out of order, or while your database is busy, because that's how webhooks work.

The good news is that receiving webhooks safely is a solved problem with a clear recipe. Here it is, in Laravel 12, in the order a request flows through it.

The recipe at a glance

  1. Verify the signature, on the raw body.
  2. Reject replays with a timestamp check.
  3. Store the event, using its ID to ignore duplicates.
  4. Respond with a 2xx immediately.
  5. Process on a queue, idempotently, trusting the provider's API over the payload's order.

Step 1: verify the signature

Most providers sign each webhook with a shared secret using HMAC-SHA256. They compute a hash of the request body with the secret, and send it in a header. You compute the same hash on your side. If they match, the request came from someone who knows the secret, and the body wasn't changed on the way.

Header formats differ between providers, so check their docs. A common style puts a timestamp and signature together, like t=1790000000,v1=5f2b..., and signs timestamp.body. Here's a middleware for that style:

public function handle(Request $request, Closure $next): Response
{
    parse_str(str_replace(',', '&', (string) $request->header('X-Signature')), $parts);

    $timestamp = (int) ($parts['t'] ?? 0);
    $signature = (string) ($parts['v1'] ?? '');

    if (abs(time() - $timestamp) > 300) {
        abort(401, 'Stale or missing timestamp.');
    }

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

    if (! hash_equals($expected, $signature)) {
        abort(401, 'Invalid signature.');
    }

    return $next($request);
}

Three details in there matter more than they look:

  • Use the raw body. $request->getContent() gives the exact bytes the provider signed. Re-encoding the parsed JSON changes spacing and key order, and the signature will never match.
  • Use hash_equals(), not ===. It compares in constant time, so an attacker can't learn the correct signature one character at a time by measuring how quickly you reject them.
  • Keep the secret in config, loaded from the environment, never in code. And plan for rotation: many providers let you have two active secrets during a changeover, so accept either for a short window.

Step 2: reject replays

A valid signed request stays valid forever. If someone captures one (from a log, a proxy, a misconfigured debugging tool), they could send it again next month.

That's what the timestamp check above is for. Because the timestamp is part of the signed content, it can't be changed without breaking the signature, and anything older than a few minutes is rejected. The duplicate check in step 3 covers replays inside that window.

Step 3: route it without CSRF, and store it first

Webhooks can't send a CSRF token, so put the route in routes/api.php (run php artisan install:api if your app doesn't have one yet), where CSRF doesn't apply. If it must live in web.php, exclude it in bootstrap/app.php with $middleware->validateCsrfTokens(except: ['webhooks/*']).

Route::post('/webhooks/payments', PaymentWebhookController::class)
    ->middleware(VerifyWebhookSignature::class);

The controller does almost nothing, on purpose. It saves the event and hands it to a queue:

public function __invoke(Request $request): Response
{
    $payload = $request->json()->all();

    // unique index on (provider, event_id)
    $event = WebhookEvent::createOrFirst(
        ['provider' => 'payments', 'event_id' => $payload['id']],
        ['type' => $payload['type'], 'payload' => $payload],
    );

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

    return response()->noContent();
}

createOrFirst() tries the insert and, if the unique index rejects it, returns the existing row. That makes duplicate deliveries harmless even when two copies arrive at the same moment, which a "check if it exists, then insert" approach can't promise.

Step 4: respond fast

Providers wait only a limited time for your response, and treat a timeout or an error as a failure. Then they retry, often with increasing delays, and some will eventually disable an endpoint that keeps failing.

So don't send emails, generate PDFs or call other APIs inside the request. Store, dispatch, return 204. The slow work happens in the job, where it can be retried on your terms. And when you do want a retry, a non-2xx response is your way of asking for one: return an error only if you genuinely failed to store the event.

Step 5: process idempotently, and don't trust the order

The job is where your business logic lives, and it has to assume the worst:

  • It might run twice. Queue jobs can be retried. Check a processed_at column at the start and set it, inside a transaction, at the end.
  • Events can arrive out of order. "Payment refunded" can land before "payment succeeded". Don't let an older event overwrite newer state. For important objects, it's often safest to treat the webhook as a nudge and fetch the current state from the provider's API before acting.
  • Unknown event types should be stored and ignored, not crash the job. Providers add new events over time.

The mindset: a webhook is a notification that something may have changed, delivered at least once, in no guaranteed order. Verify who sent it, store it once, and let a queued job work out what's actually true.

A few extras worth having

  • Keep the stored events. The webhook_events table doubles as an audit log and lets you replay processing after fixing a bug.
  • Monitor failures. Alert on failed jobs and on signature failures. A sudden burst of invalid signatures means either a rotated secret or someone probing.
  • Don't log secrets, and think twice before logging full payloads that contain personal data.
  • IP allowlists are a fine extra layer if your provider publishes its ranges, but never a replacement for signatures.
  • If you'd rather not build it yourself, the spatie/laravel-webhook-client package implements this store-then-process pattern with signature validation.

The code here is simplified (no secret rotation, minimal error handling), but the structure is the real thing: thin endpoint, verified input, stored once, processed safely. For the queued side (retries, backoff, failed jobs), see the Laravel queues docs.

What's the strangest thing a webhook has done to you: duplicates, wrong order, or a provider that changed its payload without warning?

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