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 return 400.
  • Fast — Stripe times out after ~30s. Defer slow work to a background queue.

Events we listen for

EventEffect
checkout.session.completedMark user as paid, set initial plan
customer.subscription.createdSet plan on app_metadata.plan
customer.subscription.updatedRe-sync plan + entitlements
customer.subscription.deletedDowngrade to Free
invoice.payment_failedTag user payment_failed=true, send dunning email

Failure modes

Signature mismatch400. 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: