php-commerce/docs/webhooks.md
Snider c19e467735 security: add webhook idempotency and payment amount verification
Idempotency (replay attack protection):
- Add WebhookEvent model for tracking processed events
- Add webhook_events migration with unique constraint
- Add isAlreadyProcessed() to BTCPay and Stripe controllers
- Reject duplicate events with 200 response

Payment amount verification (BTCPay):
- Add verifyPaymentAmount() method
- Reject underpayments (mark order failed, create audit record)
- Reject currency mismatches
- Log overpayments for manual review
- Add 0.01 tolerance for floating point precision

Add comprehensive tests for both features.
Update TODO.md to mark P1 issues as fixed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:32:25 +00:00

11 KiB

title description updated
Webhooks Payment gateway webhook handling documentation 2026-01-29

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:

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

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:

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