Skip to content

Stripe Webhook Integration

How the Kubuli API receives and processes Stripe webhook events for order lifecycle management.

Overview

Kubuli uses a Stripe webhook to transition orders from pending_payment to paid (or failed). The webhook endpoint lives in the Laravel admin app (POST /api/webhooks/stripe) and is the sole access control mechanism — no Bearer token is required (NFR-2.3).

Webhook Setup

URL

EnvironmentURL
Localhttp://localhost:8000/api/webhooks/stripe
Productionhttps://admin.kubuli.app/api/webhooks/stripe

Events to Subscribe

Subscribe to these events in the Stripe Dashboard (Developers > Webhooks > Add endpoint):

EventPurpose
payment_intent.succeededOrder transitions to paid; triggers receipt email + merchant push + email
payment_intent.payment_failedOrder transitions to failed
charge.refundedOrder transitions to refunded (admin-initiated only)

Do not subscribe to checkout.session.completed — Kubuli does not use Stripe Checkout Sessions.

Signing Verification

The endpoint verifies the Stripe signature using a 300-second timestamp tolerance:

php
$payload = file_get_contents('php://input');
$sig = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');

$event = \Stripe\Webhook::constructEvent($payload, $sig, $secret, 300);

Events with a signature mismatch or a timestamp older than 300 seconds return 400 and are discarded.

Event Processing

payment_intent.succeeded

  1. Look up the order by payment_intent_id.
  2. Transition order status: pending_payment → paid (via OrderStateMachine, which writes state history).
  3. Dispatch queued jobs (fire-and-forget, not in the webhook transaction):
    • SendCustomerReceiptEmailJob — receipt to customer
    • SendMerchantPushNotificationJob — FCM push to merchant devices
    • SendMerchantNotificationEmailJob — email to merchant

payment_intent.payment_failed

  1. Look up the order by payment_intent_id.
  2. Transition order status: pending_payment → failed.
  3. The cart is not restored.

charge.refunded

  1. Look up the order by payment_intent (from data.object.payment_intent in the event payload).
  2. Transition order status: any → refunded (admin-initiated — the admin triggers the refund in the Stripe Dashboard, Stripe fires the event, Kubuli records it).
  3. Dispatch RefundProcessedEmailJob (T008 placeholder; replaced with full SES implementation in a future phase).

Idempotency

Every Stripe event has a unique event_id. The sh_payment_webhook_events table uses event_id (varchar 64) as its primary key. The webhook handler uses insertOrIgnore to deduplicate:

php
DB::table('sh_payment_webhook_events')->insertOrIgnore([
    'event_id' => $event->id,
    'event_type' => $event->type,
    'received_at' => now(),
]);

Duplicate events (from retries or re-deliveries) return 200 with idempotent: true and do not enqueue a second job. The record's status is updated to processed after successful async handling, or failed if the job exhausts its retries. Monitor and retry failed queue jobs; event deduplication does not itself provide exactly-once processing or replay failed events.

Retry Behavior

Stripe retries webhook delivery with exponential backoff. The maximum retry window is approximately 3 days. The 300-second signature tolerance guards against clock skew between Stripe and the server, not event age — Stripe signs each retry attempt with a fresh timestamp, so retries are not rejected by the tolerance window.

If the endpoint is down, Stripe will continue retrying. After the event is accepted, queue-worker health and failed-job recovery are required to complete processing; duplicate Stripe deliveries remain no-ops.

Testing with Stripe CLI

bash
# Install and login
stripe login

# Forward webhooks to local API
stripe listen --forward-to localhost:8000/api/webhooks/stripe

# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
stripe trigger charge.refunded

# Resend a specific event
stripe events resend <evt_id>

The CLI prints a local webhook signing secret. Use it as STRIPE_WEBHOOK_SECRET in your local .env.

Production Checklist

  • [ ] Stripe webhook endpoint registered in the Stripe Dashboard
  • [ ] STRIPE_WEBHOOK_SECRET set in production .env
  • [ ] Events subscribed: payment_intent.succeeded, payment_intent.payment_failed, charge.refunded
  • [ ] Webhook endpoint returns 200 (check Stripe Dashboard > Webhooks > Recent deliveries)
  • [ ] CSRF middleware excludes the webhook route (bootstrap/app.php)
  • [ ] Queue worker running on the payments queue for email + push jobs
  • [ ] Apple Pay domain verification file at public/.well-known/apple-developer-merchantid-domain-association
  • [ ] Google Pay enabled in Stripe Dashboard and Google Pay Business Console

Built with VitePress