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.
// 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:
// 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.
// 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.
// 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']);
});
// 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_jobstabel processed_webhooksrecords met statusfailed
// 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:
// 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
| Maatregel | Waarom |
|---|---|
| Signature verificatie | Voorkomt nep-webhooks van kwaadwillenden |
| Queue voor verwerking | Garandeert snelle 200-respons, voorkomt timeouts |
| Idempotency via webhook_id | Voorkomt dubbele verwerking bij retries |
| Retry logic met backoff | Geeft tijdelijke problemen kans te herstellen |
| Failed job monitoring | Maakt stille failures zichtbaar |
| Ruwe body opslaan | Maakt 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.

Geschreven door Ruthger Idema
15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.
Meer over ons team →