Billing API
Programmatic access to usage, invoices, and Stripe portal/checkout sessions.
The Billing API powers the workspace Usage, Billing, and Spending pages and is fully usable from your own code.
Authentication & gating
| Gate | Status code on failure |
|---|---|
Authenticated session (getSession() returns a user) | 401 |
Email verified (session.user.emailVerified) | 403 { code: "EMAIL_NOT_VERIFIED" } |
Stripe credentials present (hasValidStripeCredentials()) | 503 (Stripe-touching routes only) |
All five routes below enforce the first two gates; the Stripe-touching
ones (/portal, /invoices, /checkout) also require valid Stripe
keys.
GET /api/billing/usage
Paginated per-execution usage rows for a workspace.
GET /api/billing/usage
?workspaceId=ws_abc
&from=2026-04-01T00:00:00Z
&to=2026-05-01T00:00:00Z
&limit=100
&cursor=<base64-encoded-cursor>Required: workspaceId.
Optional: from, to (ISO timestamps), limit (1–500, default 100), cursor.
{
"data": [
{
"id": "log_123",
"executionId": "exec_456",
"workflowId": "wf_789",
"workflowName": "Daily summary",
"startedAt": "2026-05-01T10:00:00.000Z",
"trigger": "manual",
"model": "gpt-4o",
"tokens": 312,
"cost": 0.0042
}
],
"nextCursor": "<base64-encoded-cursor-or-omitted>"
}tokens is computed from the execution's cost JSONB; falls back to
input + output when tokens.total is absent.
GET /api/billing/usage/export
CSV stream of the same query (no pagination; capped at 10,000 rows).
GET /api/billing/usage/export?workspaceId=ws_abc&from=...&to=...Response is text/csv; charset=utf-8 with header:
Date,Execution,Workflow,Trigger,Model,Tokens,Cost (USD).
Filename pattern: usage-<workspaceId>-<YYYY-MM-DD>.csv.
GET /api/billing/invoices
Live Stripe invoice list for the signed-in user's stripeCustomerId.
GET /api/billing/invoices?starting_after=in_xyz&limit=10Optional: starting_after (Stripe cursor), limit (1–50, default 10).
{
"data": [
{
"id": "in_1...",
"number": "INV-001",
"created": 1700000000,
"status": "paid",
"amountPaid": 2000,
"amountDue": 2000,
"currency": "usd",
"hostedInvoiceUrl": "https://invoice.stripe.com/i/...",
"invoicePdf": "https://invoice.stripe.com/i/...?pdf=1",
"description": "Pro monthly",
"periodStart": 1699000000,
"periodEnd": 1701000000
}
],
"hasMore": true,
"nextCursor": "in_1..."
}If the user has no Stripe customer on file (e.g. Free tier), the response
is { "data": [], "hasMore": false, "nextCursor": null }.
POST /api/billing/portal
Create a Stripe Customer Portal session and return the URL.
{
"context": "user",
"organizationId": "org_xyz",
"returnUrl": "https://app.mybotbox.com/workspace/<id>/billing"
}Optional fields:
context:"user"(default) or"organization". Org context looks up the customer viasubscription.referenceId.organizationId: required whencontext = "organization".returnUrl: defaults to/workspace?billing=updated.
{ "url": "https://billing.stripe.com/p/session/..." }POST /api/billing/checkout
Create a Stripe Checkout session for a plan upgrade.
{
"plan": "pro",
"billingPeriod": "monthly",
"successUrl": "https://app.mybotbox.com/workspace/<id>/billing?checkout=success",
"cancelUrl": "https://app.mybotbox.com/workspace/<id>/spending?checkout=cancelled"
}Required: plan ("starter" | "pro" | "team" | "enterprise").
Optional: billingPeriod ("monthly" default or "annual"), successUrl, cancelUrl.
{
"success": true,
"sessionId": "cs_test_...",
"url": "https://checkout.stripe.com/c/pay/..."
}Plan → Stripe price ID resolution lives in
apps/sat/app/api/billing/checkout/route.ts:14-33 and reads the
STRIPE_<PLAN>_PRICE_ID{,_ANNUAL,_MONTHLY} env vars.
GET /api/billing/on-demand
Read the current user's on-demand spending state.
GET /api/billing/on-demand{
"enabled": true,
"capUsd": 50
}POST /api/billing/on-demand
Toggle on-demand spending and/or set the per-period cap. Either field is optional; only the keys you include are updated.
{
"enabled": true,
"capUsd": 100
}Optional: enabled (boolean), capUsd (number, USD).
{
"ok": true,
"enabled": true,
"capUsd": 100
}Writes land on user_stats.on_demand_enabled and user_stats.on_demand_cap_usd.
GET /api/billing/check-gate
Lightweight probe used by the in-app workflow executor before invoking a
run. Returns the same shape that the execute gate would on a real run, so
the UI can preempt 402s with the
workflow-blocked modal. Behind
the EXECUTE_USAGE_GATE_ENABLED flag — when the flag is off, this
endpoint always returns { allow: true }.
GET /api/billing/check-gate{ "allow": true }{
"allow": false,
"code": "ON_DEMAND_CAP_REACHED",
"message": "On-demand cap reached.",
"context": { "capUsd": 50, "spendUsd": 52.10 }
}Execution gate (402 responses)
Both POST /api/workflows/[id]/execute (programmatic) and
GET /api/billing/check-gate (in-app) call the same
checkUsageGate(userId) helper in apps/sat/lib/billing/core/usage.ts.
When the gate rejects, the response body carries one of three typed
codes:
| Code | When | Surface fix |
|---|---|---|
BILLING_BLOCKED | user_stats.billing_blocked = true (a Stripe invoice.payment_failed set this) | Update payment in Stripe portal |
INCLUDED_USAGE_EXHAUSTED | current_period_cost >= currentUsageLimit AND on-demand is OFF | Enable on-demand or upgrade |
ON_DEMAND_CAP_REACHED | overage >= on_demand_cap_usd | Raise the cap or upgrade |
The gate is fail-open on internal errors — if checkUsageGate itself
throws, the run proceeds (errors are logged separately).
Bonus: GET/PUT /api/usage
The Spending page's Monthly Limit control writes to this endpoint.
GET /api/usage?context=userReturns { data: { currentLimit, updatedAt, ... } }.
PUT /api/usage?context=user
Content-Type: application/json
{ "limit": 75 }Sets userStats.currentUsageLimit to the new value. Server-side
validation (in lib/billing/core/usage.ts) rejects:
- Free users (
Free plan users cannot edit usage limits) - Team / Enterprise members (
use organization limits— set the org-level cap via?context=organizationinstead) - Values below the plan minimum
- Values below current period usage
Org-level usage limits are settable via ?context=organization and
require the caller to be an org owner or admin.