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:
1. Navigate to Store Settings > Webhooks
2. Create webhook with URL: https://yourdomain.com/api/webhooks/btcpay
3. Select events to send
4. 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:
1. Navigate to Developers > Webhooks
2. Add endpoint: https://yourdomain.com/api/webhooks/stripe
3. Select events to listen for
4. 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:
Stripe:
# Forward webhooks to local
stripe listen --forward-to localhost:8000/api/webhooks/stripe
# Trigger specific event
stripe trigger checkout.session.completed
Automated 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_id matches 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: