Skip to content

Sandbox billing

Whenever STRIPE_SECRET_KEY is unset. That single condition switches:

Flow With a key Without
Checkout Stripe Checkout /billing/sandbox
Manage billing Stripe customer portal /billing/sandbox/manage
Receipts Stripe invoices /billing/sandbox/receipt
Plan changes /api/webhooks/billing Written directly

The plan card and the invoice list say so in place: “Sandbox receipts. They become real Stripe invoices once a secret key is set.”

The sandbox writes the same rows Stripe would: a subscription with a plan, interval, amount, status and period, and an invoice for each charge. The rest of the app never learns the difference — accounts.plan and accounts.events_quota are denormalised from the subscription exactly as they are in production, so gating, quotas and the usage panel all behave normally.

Subscription ids are prefixed sandbox_ instead of sub_.

/billing/sandbox/manage offers what a portal would, minus the card, plus the things that otherwise take a month to reach:

Action Effect
Change plan Moves the subscription to another plan or interval
Cancel at period end Sets cancelAtPeriodEnd, keeps the plan until the period ends
Resume Clears it
Simulate renewal Advances the period and writes the next invoice
Simulate a failed payment Puts the subscription into past_due
Settle the open invoice Back to active

Those last three are how you see dunning, renewal and trial-expiry states without waiting.

The Revenue screen has an equivalent: a sample payment button on the setup card that delivers the same two events Stripe sends for a new subscription, signed the same way, handled by the same route. Also development-only.

  1. Terminal window
    wrangler secret put STRIPE_SECRET_KEY

    Sandbox mode switches off the moment the key is present.

  2. Create the products, prices, meter and portal configuration

    Section titled “Create the products, prices, meter and portal configuration”
    Terminal window
    pnpm --filter @tracing/web stripe:setup

    Prices are addressed by lookup key, never by price id, because ids are environment-specific and lookup keys are not:

    Plan Monthly Yearly Overage
    Pro tracing_pro_month tracing_pro_year tracing_pro_overage
    Business tracing_business_month tracing_business_year tracing_business_overage

    The metered prices read from one Stripe billing meter, tracing_events_overage.

  3. https://tracing.tools/api/webhooks/billing

    Locally:

    Terminal window
    stripe listen --forward-to localhost:3000/api/webhooks/billing

    which prints the signing secret for STRIPE_BILLING_WEBHOOK_SECRET.

  4. Terminal window
    curl -X POST -H "x-cron-secret: $CRON_SECRET" \
    https://tracing.tools/api/billing/report-usage

    Hourly is plenty. Guarded by CRON_SECRET; without that variable set, the route allows unauthenticated calls outside production only.