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
| Environment | URL |
|---|---|
| Local | http://localhost:8000/api/webhooks/stripe |
| Production | https://admin.kubuli.app/api/webhooks/stripe |
Events to Subscribe
Subscribe to these events in the Stripe Dashboard (Developers > Webhooks > Add endpoint):
| Event | Purpose |
|---|---|
payment_intent.succeeded | Order transitions to paid; triggers receipt email + merchant push + email |
payment_intent.payment_failed | Order transitions to failed |
charge.refunded | Order 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:
$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
- Look up the order by
payment_intent_id. - Transition order status:
pending_payment → paid(viaOrderStateMachine, which writes state history). - Dispatch queued jobs (fire-and-forget, not in the webhook transaction):
SendCustomerReceiptEmailJob— receipt to customerSendMerchantPushNotificationJob— FCM push to merchant devicesSendMerchantNotificationEmailJob— email to merchant
payment_intent.payment_failed
- Look up the order by
payment_intent_id. - Transition order status:
pending_payment → failed. - The cart is not restored.
charge.refunded
- Look up the order by
payment_intent(fromdata.object.payment_intentin the event payload). - Transition order status:
any → refunded(admin-initiated — the admin triggers the refund in the Stripe Dashboard, Stripe fires the event, Kubuli records it). - 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:
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
# 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_SECRETset 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
paymentsqueue 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
Related
- API Overview — full request/response schemas and live OpenAPI endpoint
- Commerce Endpoints — cart, checkout, merchant inventory, payments