/v1/webhooks/stripe; the /api/ prefix is added by the
production reverse proxy.)
It accepts only Stripe-signed payloads — every request is verified against
STRIPE_WEBHOOK_SECRET before any side effects fire. Unsigned or unverifiable
requests get a 400 invalid_signature and are not processed.
If you’re standing up a fresh Stripe environment, configure this endpoint in
Stripe Dashboard → Developers → Webhooks and subscribe to the events
below.
Subscribed events
Top-ups (one-off & auto)
| Event | What it does |
|---|---|
checkout.session.completed | One-off Stripe Checkout finished. Credits the wallet by amount_total cents. Idempotent on the payment-intent id. |
payment_intent.succeeded | Saved-card top-up (off-session) or Checkout PI fallback. Same credit logic — same idempotency key, so subscribing to both never double-credits. |
setup_intent.succeeded | User added a card. We persist brand/last4/exp + set it as the customer’s default payment method. |
Invoices (added v0.5)
| Event | What it does |
|---|---|
invoice.paid | Flips our local invoice to paid, records paidAt + receiptUrl. For topup_failed invoices, also credits the wallet by amountCents (idempotent on invoice_paid_<id>). |
invoice.payment_succeeded | Stripe fires this alongside invoice.paid in practice; same handler. Idempotent. |
invoice.voided | Reflects Stripe-side void onto our copy. Status → void, voidedAt set. |
invoice.marked_uncollectible | Stripe auto-writes-off invoices that go 30+ days unpaid. Status → uncollectible. |
Idempotency
Two layers, defence-in-depth:- Event-id dedupe: every processed
evt_…id is written to astripe_eventstable. Redelivery of the same event short-circuits with a{received: true, duplicate: true}response. - Ledger key dedupe: every wallet movement triggered by a webhook has a
unique
idempotencyKey(deposit_<pi_id>,invoice_paid_<inv_id>, etc). A retried event that somehow slipped past layer 1 still can’t double-post.
Failure modes
| Failure | What happens | How we recover |
|---|---|---|
| Bad signature | 400 invalid_signature returned, no DB writes. | Check STRIPE_WEBHOOK_SECRET matches the endpoint secret in Stripe. |
| Unknown event type | Silently ignored (returns {received: true}). | We add a handler when we want it to do something. |
| Database error during processing | Throws → Stripe retries with backoff. | Retries are safe due to idempotency. |
Stripe-side data we can’t map (e.g. invoice with no ourInvoiceId metadata) | Logged + skipped. | The Stripe-side invoice still gets paid; our copy stays stale. Manual reconciliation only. |
Testing locally
Stripe CLI can forward production webhooks to localhost during dev:STRIPE_WEBHOOK_SECRET
in your local env. Then trigger events with stripe trigger: