| format | md |
|---|
Setup guide for contributors working on the billing surface. Two clouds to wire:
- Stripe — subscription + payment processing.
- Metronome — AI credit ledger (top-up, auto-reload, usage gate). Charges flow through Stripe under the hood; Metronome owns balance, drain order, and the entitlement decision the AI seam reads.
Every contributor uses their own free Stripe + Metronome test/sandbox accounts. We don't share credentials. Live keys are refused at boot.
If you are not working on the billing surface and only need the AI chat / canvas agent (text) to run locally, skip the entire Metronome / Stripe / tunnel setup below. Set a contributor BYOK key and the AI-SDK text path routes through your own provider with billing bypassed — no credit gate, no metering, no Metronome.
# editor/.env.local (gitignored)
BYOK_OPENROUTER_API_KEY=sk-or-v1-... # https://openrouter.ai/keys
# …or, if you have one, a dedicated Vercel AI Gateway key:
# BYOK_AI_GATEWAY_API_KEY=...- Bypasses billing only — never auth. Still sign in (
insider@grida.co/password); a resolvable org is still required (an unauthenticated request still 401s). - Text/chat only — BYOK swaps the AI-SDK provider, so only the text path is unbilled. Image/audio go through Replicate (
withTransaction) and still gate + bill even under BYOK — those features need the full billing setup. (OpenRouter also exposes no image/audio models.) Catalog model IDs are unchanged; use IDs your provider accepts (editeditor/lib/ai/models.tslocally if one 404s). - Precedence if both are set: OpenRouter, then Vercel. Fail-closed — an empty/unset (or whitespace-only) key falls back to the billed path.
- Never set
BYOK_*on a hosted or preview deploy. It disables billing and the org-id sanity gate for every org. Contributor / self-host / local only. See SECURITY.md (GRIDA-SEC-003, BYOK carve-out).
Working on billing itself? Ignore BYOK and continue with the full setup below.
- Local Supabase running (
supabase start). - A free Stripe test mode account.
- The Stripe CLI:
brew install stripe/stripe-cli/stripe. - A Metronome sandbox account + API token. Sign up at metronome.com.
cloudflaredfor the Metronome webhook tunnel:brew install cloudflared.- Node 24 + pnpm (covered by repo-wide setup).
supabase start
supabase db resetStripe Dashboard → Test mode → Developers → API keys. Copy the secret key (sk_test_…).
Metronome Dashboard → Connections → API tokens & webhooks → create a sandbox token.
.env.test holds committed defaults; .env.test.local is gitignored.
supabase status -o env | grep SUPABASE_SECRET_KEY >> editor/.env.test.local
echo 'STRIPE_SECRET_KEY=sk_test_...' >> editor/.env.test.local
echo 'METRONOME_API_TOKEN=...' >> editor/.env.test.local
# STRIPE_WEBHOOK_SECRET / METRONOME_WEBHOOK_SECRET / WEBHOOK_TUNNEL_HOSTNAME — added belowpnpm tsx editor/scripts/billing/cli.ts setup:stripe
pnpm tsx editor/scripts/billing/cli.ts setup:metronomeBoth idempotent. Re-run after every supabase db reset. Stripe writes price IDs into the catalog; Metronome creates the rate card / products / billable metric.
The CLI is the single entry point for every billing script — run it without arguments to see all subcommands.
In a dedicated terminal kept open during development:
stripe listen --forward-to localhost:3000/webhooks/stripeCopy the printed whsec_… into STRIPE_WEBHOOK_SECRET. Per-stripe listen session — restart resets it.
Hosted / self-host deployments. stripe listen forwards every event to your machine, so local dev needs no event list. A real Stripe webhook endpoint delivers only the event types you enable — so when you deploy, create an endpoint (Stripe Dashboard → Developers → Webhooks) at https://<your-domain>/webhooks/stripe, copy its signing secret into STRIPE_WEBHOOK_SECRET, and subscribe every event class below. All of them feed the billing projector or the AI-credit flow, so enable the full set.
| Class | Events | Drives |
|---|---|---|
| Checkout | checkout.session.completed |
AI-credit top-up + auto-reload enablement |
| Customer | customer.created, customer.updated |
Stripe-customer ↔ org binding |
| Subscription | customer.subscription.created, customer.subscription.updated, customer.subscription.deleted |
plan state + auto-reload teardown on cancel |
| Invoice | invoice.payment_succeeded, invoice.payment_failed |
subscription renew / dunning state |
| Disputes | charge.dispute.created, charge.dispute.updated, charge.dispute.closed |
chargeback handling |
The Metronome endpoint (step 8) is the same in production — the same /webhooks/metronome path on your own domain, with its signing secret in METRONOME_WEBHOOK_SECRET.
The Stripe CLI can forward to localhost; Metronome can't — it requires a public HTTPS endpoint. Use a Cloudflare named tunnel configured locally to forward /webhooks/* only. Nothing about the tunnel is git-tracked — the config lives in your ~/.cloudflared/ directory and the hostname is one of yours.
One-time setup (~5 min):
brew install cloudflared
cloudflared tunnel login # browser → pick a Cloudflare zone you control
cloudflared tunnel create grida-webhooks
cloudflared tunnel route dns grida-webhooks <hostname> # e.g. metronome-dev.yourdomain.coCreate ~/.cloudflared/grida-webhooks.yml (path filter is the security boundary — see SECURITY.md GRIDA-SEC-001):
tunnel: grida-webhooks
ingress:
- hostname: metronome-dev.yourdomain.co
path: ^/webhooks/.*$
service: http://localhost:3000
- service: http_status:404Run it in a dedicated terminal:
cloudflared tunnel --config ~/.cloudflared/grida-webhooks.yml runAdd the hostname to .env.test.local:
WEBHOOK_TUNNEL_HOSTNAME=metronome-dev.yourdomain.co
Metronome Dashboard → Webhooks → Add endpoint:
- URL:
https://<WEBHOOK_TUNNEL_HOSTNAME>/webhooks/metronome - Copy the generated signing secret →
METRONOME_WEBHOOK_SECRETin.env.test.local.
pnpm dev --filter=editorSign in as insider@grida.co / password. Two flows to try:
- Subscription: Org settings → Billing → Upgrade. Test card
4242 4242 4242 4242, any future expiry / CVC. - AI credit: Same page, "Grida AI Credit" section → Buy Credit. The first top-up bootstraps the Stripe customer if needed.
The insiders QA harness at /insiders/billing exercises every primitive (top-up, complimentary commit, auto-reload, alerts, ingest) directly.
Three integration tests against your real Stripe sandbox. Refuses to start unless every channel is demonstrably test-mode.
pnpm --filter editor vitest run lib/billing/__tests__/e2eSee the suite's own README for the contract.
- DB schema:
grida_billing.*— locked, not REST-exposed. Public reads viav_billing_*views; writes only viafn_billing_*RPCs. - Stripe projector:
public.fn_billing_apply_stripe_event— only place subscription state mutates. - Metronome projector:
public.fn_billing_apply_metronome_event— credit / alert /payment_gateevents. - Webhook paths:
/webhooks/stripe,/webhooks/metronome. Both signature-verified. - Service module:
editor/lib/billing/metronome.ts—provisionOrg,addStripeChargedCommit,setAutoReload,getEntitlement,ingestUsageEvent. grida_billing.account.provisioning_uid: per-account UUID composed into Metronome aliases.supabase db resetproduces fresh aliases — any orphan Metronome customers from previous instances are inert. No manual cleanup needed.
User-facing billing copy: docs/platform/billing.mdx. Design notes: docs/wg/platform/billing/ (AI credits master plan, Metronome integration, known issues). CLI guide: editor/scripts/billing/README.md.
STRIPE_SECRET_KEY is required—.env.test.localnot loaded.plan.pro price not wired— re-runcli.ts setup:stripeafterdb reset.Metronome substrate missing— re-runcli.ts setup:metronome.- Stripe webhook signature failing —
stripe listenwas restarted; newwhsec_…. UpdateSTRIPE_WEBHOOK_SECRET. - Metronome webhook signature mismatch —
METRONOME_WEBHOOK_SECRETdiffers from the value in Metronome Dashboard. Re-copy. - Tunnel returns 404 —
WEBHOOK_TUNNEL_HOSTNAMEdoesn't match the routed hostname, orcloudflaredisn't running. Re-run fromcli.ts smoke:webhookto pinpoint which layer is broken. - Customer Portal "no Stripe customer" — org hasn't subscribed or topped up yet. Stripe customer is lazy-created on first paid action.
- AI credit shows "Out of credit" forever after a successful top-up — Metronome webhook didn't reach the tunnel. Run
cli.ts smoke:webhookto verify each layer. - AI returns a 402 / credit-gate error and you're not testing billing — you don't have Metronome wired. Set
BYOK_OPENROUTER_API_KEY(see Just need AI to work?) — it bypasses the gate entirely. If it's set and you still see billing behavior, the key is empty or AI is being called before sign-in (BYOK never bypasses auth).
| Variable | Where |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
editor/.env.test (committed) |
SUPABASE_SECRET_KEY |
editor/.env.test.local |
STRIPE_SECRET_KEY |
editor/.env.test.local |
STRIPE_WEBHOOK_SECRET |
editor/.env.test.local |
METRONOME_API_TOKEN |
editor/.env.test.local |
METRONOME_WEBHOOK_SECRET |
editor/.env.test.local |
WEBHOOK_TUNNEL_HOSTNAME |
editor/.env.test.local |
BILLING_E2E, BILLING_TEST_MODE, APP_URL |
editor/.env.test (committed) |
Contributor BYOK (alternative — not required): BYOK_OPENROUTER_API_KEY or BYOK_AI_GATEWAY_API_KEY in editor/.env.local. When set, the AI seam bypasses billing entirely and none of the Metronome rows above are needed. Auth is still required. See Just need AI to work?.