Stripe webhooks
How billing events flow into xcity-home, what can fail, and how to replay.
/api/billing/webhook is the only endpoint Stripe ever calls. It must be:
- Idempotent — Stripe retries on
5xx. Don’t double-apply effects. - Signature-checked — every request is verified against
STRIPE_WEBHOOK_SECRET. Unsigned events return400. - Fast — Stripe times out after ~30s. Defer slow work to a background queue.
Events we listen for
| Event | Effect |
|---|---|
checkout.session.completed | Mark user as paid, set initial plan |
customer.subscription.created | Set plan on app_metadata.plan |
customer.subscription.updated | Re-sync plan + entitlements |
customer.subscription.deleted | Downgrade to Free |
invoice.payment_failed | Tag user payment_failed=true, send dunning email |
Failure modes
Signature mismatch — 400. Caller logs the discrepancy. Most often: wrong env (test secret in prod) or a stale webhook endpoint.
GoTrue down — webhook returns 500. Stripe retries with exponential backoff for ~3 days. If GoTrue stays down past then, manually replay from the Stripe dashboard.
Plan id unknown — webhook returns 200 but logs a warning. We don’t fail the event because Stripe state is the truth; instead, the plan resolver in src/lib/billing.ts falls back to Free until the price id is added to the plan map.
Replay
From Stripe Dashboard → Developers → Webhooks → click the failed event → Resend.
For batch replay during an incident, use the Stripe CLI:
stripe events resend --type customer.subscription.updated --since 1747000000
Local development
stripe listen --forward-to http://localhost:4321/api/billing/webhook
Copy the printed whsec_... into .env. Then:
stripe trigger checkout.session.completed
Last updated: