Webhooks betrouwbaar verwerken in Laravel
Terug naar blog

Webhooks betrouwbaar verwerken in Laravel

AuthorRuthger Idema
10 maart 202611 min leestijd

Een webhook die twee keer verwerkt wordt, boekt een order dubbel. Idempotency, signature verificatie en retry logic: zo bouw je het goed.

Webhooks betrouwbaar verwerken in Laravel

Een webhook die twee keer verwerkt wordt, boekt een order dubbel. Eén die nooit aankomt, mist een betaling. We hebben beide situaties gezien bij klanten — en ze zijn altijd te voorkomen.

Webhooks lijken simpel: een externe partij stuurt een HTTP POST, jij verwerkt het. Maar in productie gaat er genoeg mis om er een serieuze architectuur voor te bouwen. Dit artikel legt uit hoe.

Wat je leert in dit artikel

  • Hoe je signature verificatie implementeert
  • Waarom idempotency niet optioneel is
  • Hoe queues webhook verwerking betrouwbaar maken
  • Hoe je retry logic bouwt zonder dubbele verwerking
  • Hoe je failure handling inricht zodat niets verloren gaat

Waarom webhooks mislukken

Webhooks mislukken om een handvol redenen. Elk heeft een oplossing.

Je server is even offline. De webhook wordt verstuurd, niemand luistert. De meeste providers retrien een aantal keer, maar niet onbeperkt. Shopify herprobeert 19 keer over 48 uur. Stripe herprobeert 3 dagen. Mollie herprobeert 15 minuten tot 3 dagen. Na die periode is de event weg. Je verwerking duurt te lang. De meeste providers verwachten een 200-respons binnen 5-10 seconden. Als je verwerking langer duurt — een zware database operatie, een API-aanroep naar een derde partij — time-out je de webhook. De provider denkt dat de aflevering mislukte en retryt. Nu verwerk je hem twee keer. Je server geeft een fout terug. Een 500-response triggert een retry. Als die retry dezelfde fout veroorzaakt, heb je een loop. De event komt twee keer aan. Providers garanderen "at-least-once" delivery, niet "exactly-once". Bij netwerkstoringen kan dezelfde event meerdere keren worden verstuurd.

Stap 1: signature verificatie

Nooit een webhook endpoint openzetten zonder te verifiëren dat het verzoek van de echte afzender komt. Iedereen die jouw URL weet, kan anders fictieve events sturen.

De meeste providers gebruiken HMAC-SHA256 signing. Ze sturen een header met een signature, berekend over de request body met een gedeeld secret.

php
// app/Http/Middleware/VerifyWebhookSignature.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyWebhookSignature
{
    public function handle(Request $request, Closure $next, string $provider): Response
    {
        $verified = match($provider) {
            'shopify'  => $this->verifyShopify($request),
            'stripe'   => $this->verifyStripe($request),
            'mollie'   => $this->verifyMollie($request),
            default    => false,
        };

        if (! $verified) {
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        return $next($request);
    }

    private function verifyShopify(Request $request): bool
    {
        $hmac = $request->header('X-Shopify-Hmac-Sha256');
        $secret = config('services.shopify.webhook_secret');

        // Gebruik de ruwe body — niet de geparseerde versie
        $computed = base64_encode(
            hash_hmac('sha256', $request->getContent(), $secret, true)
        );

        return hash_equals($computed, $hmac ?? '');
    }

    private function verifyStripe(Request $request): bool
    {
        $signature = $request->header('Stripe-Signature');
        $secret = config('services.stripe.webhook_secret');

        try {
            \Stripe\Webhook::constructEvent(
                $request->getContent(),
                $signature,
                $secret
            );
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }

    private function verifyMollie(Request $request): bool
    {
        // Mollie gebruikt geen signature — IP whitelisting is de alternatieve methode
        // In productie: verificeer via Mollie API dat de payment status klopt
        return true;
    }
}

Gebruik hash_equals voor de vergelijking, niet ===. Dat voorkomt timing attacks waarbij een aanvaller via responstijd informatie over het secret afleidt.

Registreer de middleware in je routes:

php
// routes/api.php

Route::post('/webhooks/shopify', ShopifyWebhookController::class)
    ->middleware('verify.webhook:shopify');

Route::post('/webhooks/stripe', StripeWebhookController::class)
    ->middleware('verify.webhook:stripe');

Stap 2: snel reageren met queues

Je webhook controller doet één ding: de payload opslaan en een job dispatchten. Daarna stuurt hij meteen een 200 terug.

php
// app/Http/Controllers/ShopifyWebhookController.php

namespace App\Http\Controllers;

use App\Jobs\ProcessShopifyWebhook;
use Illuminate\Http\Request;

class ShopifyWebhookController extends Controller
{
    public function __invoke(Request $request): \Illuminate\Http\JsonResponse
    {
        $topic = $request->header('X-Shopify-Topic');
        $shopDomain = $request->header('X-Shopify-Shop-Domain');

        ProcessShopifyWebhook::dispatch(
            topic: $topic,
            shopDomain: $shopDomain,
            payload: $request->all(),
            webhookId: $request->header('X-Shopify-Webhook-Id'),
        );

        return response()->json(['status' => 'queued']);
    }
}

De volledige verwerking gebeurt in de job, asynchroon. De webhook provider krijgt zijn 200 binnen milliseconden.

Stap 3: idempotency — de kern van betrouwbaarheid

Idempotency betekent: hetzelfde verzoek twee keer uitvoeren heeft hetzelfde resultaat als één keer. Een dubbele order wordt niet tweemaal aangemaakt. Een betaling wordt niet tweemaal geboekt.

De implementatie: sla elke verwerkte webhook-ID op en weiger duplicaten.

php
// database/migrations/create_processed_webhooks_table.php

Schema::create('processed_webhooks', function (Blueprint $table) {
    $table->id();
    $table->string('provider');          // 'shopify', 'stripe', 'mollie'
    $table->string('webhook_id')->unique(); // unieke ID van de provider
    $table->string('topic');             // 'orders/create', 'payment_intent.succeeded'
    $table->json('payload');
    $table->enum('status', ['processing', 'completed', 'failed']);
    $table->text('error_message')->nullable();
    $table->timestamps();

    $table->index(['provider', 'webhook_id']);
});
php
// app/Jobs/ProcessShopifyWebhook.php

namespace App\Jobs;

use App\Models\ProcessedWebhook;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;

class ProcessShopifyWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60; // seconden tussen retries

    public function __construct(
        private readonly string $topic,
        private readonly string $shopDomain,
        private readonly array $payload,
        private readonly string $webhookId,
    ) {}

    public function handle(): void
    {
        // Idempotency check: al eerder verwerkt?
        $alreadyProcessed = ProcessedWebhook::where('provider', 'shopify')
            ->where('webhook_id', $this->webhookId)
            ->where('status', 'completed')
            ->exists();

        if ($alreadyProcessed) {
            return; // Stil negeren — dit is normaal gedrag
        }

        // Sla op als 'processing' — atomisch om race conditions te voorkomen
        $record = DB::transaction(function () {
            return ProcessedWebhook::firstOrCreate(
                [
                    'provider'   => 'shopify',
                    'webhook_id' => $this->webhookId,
                ],
                [
                    'topic'   => $this->topic,
                    'payload' => $this->payload,
                    'status'  => 'processing',
                ]
            );
        });

        // Als een andere process dit al pakt, stop dan
        if ($record->status === 'completed') {
            return;
        }

        try {
            $this->processEvent();

            $record->update(['status' => 'completed']);
        } catch (\Exception $e) {
            $record->update([
                'status'        => 'failed',
                'error_message' => $e->getMessage(),
            ]);

            throw $e; // Laat Laravel de job retrien
        }
    }

    private function processEvent(): void
    {
        match($this->topic) {
            'orders/create'   => $this->handleOrderCreate(),
            'orders/updated'  => $this->handleOrderUpdated(),
            'orders/paid'     => $this->handleOrderPaid(),
            'products/update' => $this->handleProductUpdate(),
            default           => null, // Onbekende topics negeren
        };
    }

    private function handleOrderCreate(): void
    {
        // Verwerk het order — dit is idempotent omdat we de webhook_id al checken
        \App\Services\OrderService::createFromShopify($this->payload, $this->shopDomain);
    }

    // ... andere handlers
}

Stap 4: failure handling en monitoring

Webhooks die falen moeten zichtbaar zijn. Stille failures zijn gevaarlijk — je denkt dat alles werkt, maar orders komen niet binnen.

Stel alerts in op:

  • Queue lengte boven een drempelwaarde (bijv. > 100 jobs)
  • Failed jobs in de failed_jobs tabel
  • processed_webhooks records met status failed
php
// app/Console/Commands/CheckWebhookHealth.php

public function handle(): void
{
    $failedToday = ProcessedWebhook::where('status', 'failed')
        ->whereDate('created_at', today())
        ->count();

    if ($failedToday > 10) {
        // Stuur alert via Slack, e-mail of PagerDuty
        \Notification::route('slack', config('services.slack.webhook_url'))
            ->notify(new WebhookFailureAlert($failedToday));
    }
}

Voeg ook een dead letter queue toe voor jobs die alle retries hebben uitgeput:

php
// config/queue.php

'failed' => [
    'driver'   => 'database-uuids',
    'database' => env('DB_CONNECTION', 'mysql'),
    'table'    => 'failed_jobs',
],

Met php artisan queue:retry all kun je gefaalde jobs opnieuw starten nadat je het onderliggende probleem hebt opgelost.

Best practices samengevat

MaatregelWaarom
Signature verificatieVoorkomt nep-webhooks van kwaadwillenden
Queue voor verwerkingGarandeert snelle 200-respons, voorkomt timeouts
Idempotency via webhook_idVoorkomt dubbele verwerking bij retries
Retry logic met backoffGeeft tijdelijke problemen kans te herstellen
Failed job monitoringMaakt stille failures zichtbaar
Ruwe body opslaanMaakt debugging mogelijk na een incident

Conclusie

Betrouwbare webhook verwerking is geen rocket science. Het zijn vier concrete stappen: verificeer de signature, reageer snel via een queue, implementeer idempotency en monitor failures. Elke stap die je overslaat is een productie-incident in wording.

Wij bouwen deze infrastructuur regelmatig voor klanten die integreren met Shopify, Stripe, Mollie en andere providers. Bekijk onze Laravel diensten of lees meer over API-beveiliging. Zie ook de officiële Laravel Queue documentatie voor meer over de queue-architectuur die we hier gebruiken.

Neem contact op als je hulp nodig hebt bij het bouwen van een betrouwbare webhook-integratie.

Veelgestelde vragen

Moet ik de webhook payload opslaan in de database?

Ja, in ieder geval tijdelijk. Als verwerking mislukt, heb je de originele payload nodig om te debuggen. Sla hem op bij het ontvangen, verwijder hem na succesvolle verwerking als je opslagruimte wilt besparen.

Wat als een provider geen webhook-ID stuurt?

Genereer dan een fingerprint op basis van de payload-inhoud: een hash van het event-type, de resource-ID en de timestamp. Niet perfect, maar voorkomt de meeste dubbele verwerkingen.

Hoe lang bewaar je processed webhooks?

30 dagen is doorgaans voldoende. Providers retrien niet langer dan dat. Voeg een scheduled command toe die oudere records opruimt.

Welke queue driver is het best voor webhooks?

Redis voor productie. Het is snel, betrouwbaar en ondersteunt delayed jobs voor backoff logic. Database queues werken ook maar presteren minder goed onder hoge load.

Hoe test ik webhook verwerking lokaal?

Gebruik Stripe CLI of Shopify CLI voor het forwarden van real-time webhooks naar je lokale omgeving. Alternatief: schrijf feature tests die de controller aanroepen met gesigneerde payloads.

Ruthger Idema

Geschreven door Ruthger Idema

15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.

Meer over ons team →
Deel dit artikel:

Wil je jouw e-commerce naar het volgende niveau?

Plan een vrijblijvend gesprek met onze experts over Magento, Shopify of Laravel maatwerk.

Plan een Tech Check