Webhook Handling
This document describes how payment gateway webhooks are processed in the commerce module.
Overview
Payment gateways notify the application of payment events via webhooks. These are HTTP POST requests sent to predefined endpoints when payment state changes.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ BTCPay │ │ Host UK │ │ Stripe │
│ Server │ │ Commerce │ │ API │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ POST /api/webhooks/ │ │
│ btcpay │ │
│ ───────────────────────▶│ │
│ │ │
│ │ POST /api/webhooks/ │
│ │ stripe │
│ │◀─────────────────────────
│ │ │Endpoints
| Gateway | Endpoint | Signature Header |
|---|---|---|
| BTCPay | POST /api/webhooks/btcpay | BTCPay-Sig |
| Stripe | POST /api/webhooks/stripe | Stripe-Signature |
Both endpoints:
- Rate limited: 120 requests per minute
- No authentication middleware (signature verification only)
- Return 200 for successful processing (even if event is skipped)
- Return 401 for invalid signatures
- Return 500 for processing errors (triggers gateway retry)
BTCPay Webhooks
Configuration
In BTCPay Server dashboard:
- Navigate to Store Settings > Webhooks
- Create webhook with URL:
https://yourdomain.com/api/webhooks/btcpay - Select events to send
- Copy webhook secret to
BTCPAY_WEBHOOK_SECRET
Event Types
| BTCPay Event | Mapped Type | Action |
|---|---|---|
InvoiceCreated | invoice.created | No action |
InvoiceReceivedPayment | invoice.payment_received | Order → processing |
InvoiceProcessing | invoice.processing | Order → processing |
InvoiceSettled | invoice.paid | Fulfil order |
InvoiceExpired | invoice.expired | Order → failed |
InvoiceInvalid | invoice.failed | Order → failed |
Processing Flow
// BTCPayWebhookController::handle()
1. Verify signature
└── 401 if invalid
2. Parse event
└── Extract type, invoice ID, metadata
3. Log webhook event
└── WebhookLogger creates audit record
4. Route to handler (in transaction)
├── invoice.paid → handleSettled()
├── invoice.expired → handleExpired()
└── default → handleUnknownEvent()
5. Return response
└── 200 OK (even for skipped events)Invoice Settlement Handler
protected function handleSettled(array $event): Response
{
// 1. Find order by gateway session ID
$order = Order::where('gateway', 'btcpay')
->where('gateway_session_id', $event['id'])
->first();
// 2. Skip if already paid (idempotency)
if ($order->isPaid()) {
return response('Already processed', 200);
}
// 3. Create payment record
$payment = Payment::create([
'gateway' => 'btcpay',
'gateway_payment_id' => $event['id'],
'amount' => $order->total,
'status' => 'succeeded',
// ...
]);
// 4. Fulfil order (provisions entitlements, creates invoice)
$this->commerce->fulfillOrder($order, $payment);
// 5. Send confirmation email
$this->sendOrderConfirmation($order);
return response('OK', 200);
}Stripe Webhooks
Configuration
In Stripe Dashboard:
- Navigate to Developers > Webhooks
- Add endpoint:
https://yourdomain.com/api/webhooks/stripe - Select events to listen for
- Copy signing secret to
STRIPE_WEBHOOK_SECRET
Event Types
| Stripe Event | Action |
|---|---|
checkout.session.completed | Fulfil order, create subscription |
invoice.paid | Renew subscription period |
invoice.payment_failed | Mark past_due, trigger dunning |
customer.subscription.created | Fallback (usually handled by checkout) |
customer.subscription.updated | Sync status, period dates |
customer.subscription.deleted | Cancel, revoke entitlements |
payment_method.attached | Store payment method |
payment_method.detached | Deactivate payment method |
payment_method.updated | Update card details |
setup_intent.succeeded | Attach payment method from setup flow |
Checkout Completion Handler
protected function handleCheckoutCompleted(array $event): Response
{
$session = $event['raw']['data']['object'];
$orderId = $session['metadata']['order_id'];
// Find and validate order
$order = Order::find($orderId);
if (!$order || $order->isPaid()) {
return response('Already processed', 200);
}
// Create payment record
$payment = Payment::create([
'gateway' => 'stripe',
'gateway_payment_id' => $session['payment_intent'],
'amount' => $session['amount_total'] / 100,
'status' => 'succeeded',
]);
// Handle subscription if present
if (!empty($session['subscription'])) {
$this->createOrUpdateSubscriptionFromSession($order, $session);
}
// Fulfil order
$this->commerce->fulfillOrder($order, $payment);
return response('OK', 200);
}Subscription Invoice Handler
protected function handleInvoicePaid(array $event): Response
{
$invoice = $event['raw']['data']['object'];
$subscriptionId = $invoice['subscription'];
// Find subscription
$subscription = Subscription::where('gateway', 'stripe')
->where('gateway_subscription_id', $subscriptionId)
->first();
// Update period dates
$subscription->renew(
Carbon::createFromTimestamp($invoice['period_start']),
Carbon::createFromTimestamp($invoice['period_end'])
);
// Create payment record
$payment = Payment::create([...]);
// Create local invoice
$this->invoiceService->createForRenewal($subscription->workspace, ...);
return response('OK', 200);
}Signature Verification
BTCPay
// BTCPayGateway::verifyWebhookSignature()
$providedSignature = $signature;
if (str_starts_with($signature, 'sha256=')) {
$providedSignature = substr($signature, 7);
}
$expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret);
return hash_equals($expectedSignature, $providedSignature);Stripe
// StripeGateway::verifyWebhookSignature()
try {
\Stripe\Webhook::constructEvent($payload, $signature, $this->webhookSecret);
return true;
} catch (\Exception $e) {
return false;
}Webhook Logging
All webhook events are logged via WebhookLogger:
// Start logging
$this->webhookLogger->startFromParsedEvent('btcpay', $event, $payload, $request);
// Link to entities for audit trail
$this->webhookLogger->linkOrder($order);
$this->webhookLogger->linkSubscription($subscription);
// Mark outcome
$this->webhookLogger->success($response);
$this->webhookLogger->fail($errorMessage, $statusCode);
$this->webhookLogger->skip($reason);Logged data includes:
- Event type and ID
- Raw payload (encrypted)
- IP address and user agent
- Processing outcome
- Related order/subscription IDs
Error Handling
Gateway Retries
Both gateways retry failed webhooks:
| Gateway | Retry Schedule | Max Attempts |
|---|---|---|
| BTCPay | Exponential backoff | Configurable |
| Stripe | Exponential over 3 days | ~20 attempts |
Important: Return 200 OK even for events that are skipped or already processed. Only return 500 for actual processing errors that should be retried.
Transaction Safety
All webhook handlers wrap processing in database transactions:
try {
$response = DB::transaction(function () use ($event) {
return match ($event['type']) {
'invoice.paid' => $this->handleSettled($event),
// ...
};
});
return $response;
} catch (\Exception $e) {
Log::error('Webhook processing error', [...]);
return response('Processing error', 500);
}Testing Webhooks
Local Development
Use gateway CLI tools to send test webhooks:
BTCPay:
# Trigger test webhook from BTCPay admin
# Or use btcpay-cli if availableStripe:
# Forward webhooks to local
stripe listen --forward-to localhost:8000/api/webhooks/stripe
# Trigger specific event
stripe trigger checkout.session.completedAutomated Tests
See tests/Feature/WebhookTest.php for webhook handler tests:
test('btcpay settled webhook fulfils order', function () {
$order = Order::factory()->create(['status' => 'processing']);
$payload = json_encode([
'type' => 'InvoiceSettled',
'invoiceId' => $order->gateway_session_id,
// ...
]);
$signature = hash_hmac('sha256', $payload, config('commerce.gateways.btcpay.webhook_secret'));
$response = $this->postJson('/api/webhooks/btcpay', [], [
'BTCPay-Sig' => $signature,
'Content-Type' => 'application/json',
]);
$response->assertStatus(200);
expect($order->fresh()->status)->toBe('paid');
});Troubleshooting
Common Issues
401 Invalid Signature
- Check webhook secret matches environment variable
- Ensure raw payload is used (not parsed JSON)
- Verify signature header name is correct
Order Not Found
- Check
gateway_session_idmatches invoice ID - Verify order was created before webhook arrived
- Check for typos in metadata passed to gateway
Duplicate Processing
- Normal behavior if webhook is retried
- Order state check (
isPaid()) prevents double fulfillment - Consider adding idempotency key storage
Debug Logging
Enable verbose logging temporarily:
// In webhook controller
Log::debug('Webhook payload', [
'type' => $event['type'],
'id' => $event['id'],
'raw' => $event['raw'],
]);Webhook Event Viewer
Query logged events:
SELECT * FROM commerce_webhook_events
WHERE event_type = 'InvoiceSettled'
AND status = 'failed'
ORDER BY created_at DESC
LIMIT 10;