Setup
Stripe Dashboard
- Developers > API Keys > copy Secret Key
- Webhooks > Add endpoint > URL:
https://[tenant].studeia.com/api/webhooks/stripe - Events: checkout.session.completed, customer.subscription.{created,updated,deleted}, invoice.payment_{succeeded,failed}
- Copy webhook signing secret
Create products + prices
For each B2B plan (mini, growth, pro_100): Products > Add > Recurring monthly + price. Copy price_id.
Env vars
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_MINI=price_...
STRIPE_PRICE_GROWTH=price_...
STRIPE_PRICE_PRO_100=price_...
STRIPE_PRICE_MINI_USD=price_...
STRIPE_PRICE_GROWTH_USD=price_...
STRIPE_PRICE_PRO_100_USD=price_...
Checkout flow
Admin clicks "Subscribe Mini" → POST /api/institution/billing/checkout → Studeia createCheckoutSession() (resolveCustomerIdForProvider, getCurrencyFromHeaders, create Stripe Session) → returns Stripe URL → admin pays → webhook checkout.session.completed → Studeia validates (HMAC + retrieve subscription + cross-check tenantId + price_id allowlist) → promotes Tenant.plan + creates PaymentLog (idempotent).
Hardening (rules 129-139)
- Webhook returns 5xx on failure (Stripe retries 3 days)
- Ordering guard: lastEventAt/lastEventId in TenantSubscription
- Idempotency: unique [provider, externalEventId] in PaymentLog
- Zod schema:
z.enum(PAID_PLAN_SLUGS)in checkout body - PII redact: PaymentLog.rawPayload via redactPaymentPayload()
- Past_due gate: isAccessBlocked(status) in layouts. Cron /api/cron/billing-grace-expire transitions after grace 7d.
Stripe self-manage portal
POST /api/institution/billing/portal returns temporary Stripe portal URL: admin changes plan, updates card, sees invoices, cancels.
Multi-currency
| Aspect | BRL | USD |
|---|---|---|
| Detection | Default | x-vercel-ip-country / cf-ipcountry |
| Price IDs | STRIPE_PRICE_* | STRIPE_PRICE_*_USD |
| Asaas fallback | Yes (PIX/boleto) | NO (Brazil only) |