Skip to content

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

GatewayEndpointSignature Header
BTCPayPOST /api/webhooks/btcpayBTCPay-Sig
StripePOST /api/webhooks/stripeStripe-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 EventMapped TypeAction
InvoiceCreatedinvoice.createdNo action
InvoiceReceivedPaymentinvoice.payment_receivedOrder → processing
InvoiceProcessinginvoice.processingOrder → processing
InvoiceSettledinvoice.paidFulfil order
InvoiceExpiredinvoice.expiredOrder → failed
InvoiceInvalidinvoice.failedOrder → failed

Processing Flow

php
// 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

php
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 EventAction
checkout.session.completedFulfil order, create subscription
invoice.paidRenew subscription period
invoice.payment_failedMark past_due, trigger dunning
customer.subscription.createdFallback (usually handled by checkout)
customer.subscription.updatedSync status, period dates
customer.subscription.deletedCancel, revoke entitlements
payment_method.attachedStore payment method
payment_method.detachedDeactivate payment method
payment_method.updatedUpdate card details
setup_intent.succeededAttach payment method from setup flow

Checkout Completion Handler

php
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

php
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

php
// 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

php
// 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:

php
// 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:

GatewayRetry ScheduleMax Attempts
BTCPayExponential backoffConfigurable
StripeExponential 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:

php
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:

bash
# Trigger test webhook from BTCPay admin
# Or use btcpay-cli if available

Stripe:

bash
# 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:

php
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:

php
// In webhook controller
Log::debug('Webhook payload', [
    'type' => $event['type'],
    'id' => $event['id'],
    'raw' => $event['raw'],
]);

Webhook Event Viewer

Query logged events:

sql
SELECT * FROM commerce_webhook_events
WHERE event_type = 'InvoiceSettled'
  AND status = 'failed'
ORDER BY created_at DESC
LIMIT 10;

Released under the EUPL-1.2 License.