billt API Reference
billt is a Backend-as-a-Service that gives your SaaS app Google Auth, Stripe payments and credit-based metering through a single API key. No backend needed - just plug in the SDK.
What billt provides
Authentication model
billt has two separate auth layers - don't mix them up:
1. Founder auth - for you (the builder)
You sign in with Google via /founder/auth/login. You get a JWT (stored as an HttpOnly cookie) to manage your projects, plans, and settings. All /founder/* routes require this token.
2. User SDK auth - for your end-users
Your app's users sign in via /sdk/auth/login. All /sdk/* routes require your project's X-API-Key header (the billy_... key from your dashboard) - except /sdk/auth/login, /sdk/auth/callback and /sdk/plans, which are public entry points identified by public_project_id instead (see below). Protected routes also need the user's own Authorization: Bearer <access_token>.
Base URL
https://your-billt-instance.com
Quick start
# 1. Create a project in the dashboard → get your billy_api_key and public_project_id # 2. Redirect your user to login (public endpoint, no X-API-Key) GET /sdk/auth/login?public_project_id=<uuid> # 3. Google → billt callback → redirected to YOUR redirect_url?code=<login_code> # 4. Exchange the one-time code for real tokens GET /sdk/auth/token X-API-Key: billy_xxxxx Body: "<login_code>" # 5. Get current user + entitlements GET /sdk/user/me X-API-Key: billy_xxxxx Authorization: Bearer <access_token> # 6. Consume a credit before doing expensive work POST /sdk/usage/consume X-API-Key: billy_xxxxx Authorization: Bearer <access_token> { "credit_key": "api_calls", "amount": 1 } # 7. Start Stripe checkout for a paid plan GET /sdk/payments/checkout?plan_id=<uuid> X-API-Key: billy_xxxxx Authorization: Bearer <access_token>
How user auth works
billt uses Google OAuth2 with a short-lived one-time login code in between, so the OAuth token exchange never happens directly in your frontend.
Why a login code instead of tokens directly?
The redirect in step 5 lands on your frontend URL - anything in that URL (browser history, referrer headers, analytics) can leak. The login_code is single-use and short-lived; it's worthless once exchanged, so it's safe to pass through a redirect. The real access_token/refresh_token are only ever returned in a JSON response body from /sdk/auth/token, called server-to-server with your X-API-Key.
Token lifetimes
POST /sdk/auth/refresh with the refresh token to get a new pair.
User isolation
Users are scoped to your project. The same Google account signing into two different billt projects = two separate user records. No data leaks between tenants.
Credits & metering
Every plan (including the auto-created free plan on every new project) has a credits map: arbitrary string keys you define (api_calls, tokens, exports - whatever your product meters) mapped to an integer grant per billing period.
How it works
When a user first calls /sdk/usage/consume for a given credit_key, billt lazily creates a balance bucket sized to their current plan's grant for that key. Each successful consume call decrements the bucket. When the plan's billing period rolls over (or a Stripe invoice renews it), the bucket refills to the plan's grant - unused credits do not carry over, they're overwritten on refill.
Zero balance
If a user is out of credits for a key, consume returns 402 Payment Required. billt does not currently support overage billing - that's on the roadmap.
Plan downgrade
When a subscription is cancelled (customer.subscription.deleted), billt reverts the user to the free plan and re-seeds their credit balances from the free plan's grants - old paid-tier balances are cleared, not kept.
A failed renewal payment does not downgrade immediately. The user is marked past_due (surfaced as payment_status in /sdk/user/me) but keeps their plan, access, and credits while Stripe retries the charge (dunning). The drop to free happens only if every retry fails, which ends in customer.subscription.deleted.
Errors
billt returns standard HTTP status codes. Error body is plain text (not JSON) unless noted otherwise.
This is billt's own subscription - your Free/Pro/Team plan for using billt itself, completely separate from the plans you sell to your end-users via /founder/projects/{id}/plans. Don't mix the two up.
{
"plan": "free" // free | pro | team,
"status": "ok" // ok | warning | blocked,
"mau": 0,
"mau_limit": 1000 // -1 = unlimited (Pro/Team),
"project_limit": 1 // -1 = unlimited,
"plan_renews_at": "2026-08-09T00:00:00Z" // omitted if not on a paid plan,
"has_billing_customer": false // true once a Stripe customer exists - gates the portal link
}
status is computed from your MAU relative to your plan's limit: free plans go warning past 2x the limit and blocked past 4x. Paid plans (pro/team) are always ok regardless of MAU since they're unlimited. When blocked, every project under your account gets billing_blocked = true and their end-users are locked out until you upgrade.
Recomputes your MAU synchronously (users across all your projects who consumed usage in the last 30 days) and caches it. GET /founder/billing reads the cached value - call this first if you need a fresh number right now instead of waiting for the next scheduled recalculation.
{ "mau": 42 }
Redirects (307) to Stripe Checkout for a billt Pro or Team subscription. This is what the "Upgrade" buttons on /public/billing.html hit.
| Field | Type | Required | Notes |
|---|---|---|---|
| plan | string | required | "pro" or "team" - anything else returns 400 |
503 self-billing not configured if the instance's own Stripe credentials (BILLT_STRIPE_SECRET_KEY, BILLT_STRIPE_WEBHOOK_SECRET, price IDs) aren't set - these are separate from any founder's per-project Stripe keys.Redirects (307) to the Stripe customer billing portal - update card, view invoices, cancel or downgrade. There's no separate "downgrade" API endpoint; cancellation here is what drops you back to Free (handled via the customer.subscription.deleted webhook event below).
400 if you've never completed a checkout (no Stripe customer yet) - go through Checkout first.Registered once in the Stripe Dashboard against BILLT_STRIPE_WEBHOOK_SECRET - this is billt's own webhook for its own subscriptions, not to be confused with the per-project /sdk/payments/webhook/{project_id} above. Idempotent - replays of the same event ID are skipped.
2026-04-22.dahlia) to match your Stripe account's configured API version, or every event fails with 400. Sync it in Stripe Dashboard → Developers → Workbench → Overview → API versions if you upgrade the stripe-go dependency.[
{
"id": "uuid",
"public_id": "uuid",
"founder_id": "uuid",
"name": "My SaaS",
"redirect_url": "https://app.com/auth/callback",
"google_client_id": "xxx.apps.googleusercontent.com",
"billy_api_key": "billt_xxx" // hashed key, not the raw secret,
"allowed_origins": ["https://app.com"]
}
]
google_client_secret, stripe_secret_key, stripe_webhook_secret are never returned in JSON - they're write-only fields.
{ "project_name": "My SaaS" }
Creates the project and its default free plan (price 0, empty credits/features) atomically. Edit the free plan afterwards to actually grant credits.
{
"project_id": "uuid",
"api_key": "billt_xxxxxxxxxxxx"
}
api_key is the raw secret - shown exactly once. Only its hash is stored. If lost, there is no recovery endpoint yet - delete and recreate the project.{
"redirect_url": "https://app.com/auth/callback",
"google_client_id": "xxx.apps.googleusercontent.com",
"google_client_secret": "GOCSPX-...",
"stripe_secret_key": "sk_live_...",
"stripe_webhook_secret": "whsec_...",
"allowed_origins": ["https://app.com"]
}
Secrets are encrypted at rest. Leave a field empty/omitted to keep its current value - empty string never overwrites an existing secret.
204 No Content
Cascades to all users, plans, credit balances and payments belonging to this project. Not reversible.
204 No Content
[
{
"id": "uuid",
"project_id": "uuid",
"stripe_price_id": "price_1ABC...",
"name": "pro",
"price": 2900 // cents,
"currency": "usd",
"billing_interval": "month" // month | year | one_time,
"credits": { "api_calls": 10000 },
"features": { "priority_support": true },
"archived": false
}
]
| Field | Type | Required | Notes |
|---|---|---|---|
| name | string | required | 1-100 chars |
| price | int64 | required | Cents, > 0. Use 0 for a free tier by not calling this at all - the free plan is auto-created per project. |
| currency | string | required | e.g. "usd", "eur" |
| billing_interval | string | optional | "month" | "year" | "one_time", defaults to "month" |
| stripe_price_id | string | optional | Leave empty - billt will create the Stripe Product + Price for you using the project's Stripe secret key. Only set this if you already have an existing Price you want to reuse. |
| credits | map[string]int64 | optional | Credit key → grant per billing period. Keys are your own naming, e.g. "api_calls". |
| features | map[string]any | optional | Arbitrary flags/limits returned in /sdk/user/me for your app to gate on. |
{
"name": "pro",
"price": 2900,
"currency": "usd",
"billing_interval": "month",
"credits": { "api_calls": 10000 },
"features": { "priority_support": true }
}
"uuid" // just the new plan's id, as a raw JSON string
stripe_price_id is empty. This requires stripe_secret_key to already be set on the project via the credentials endpoint.{
"name": "pro",
"billing_interval": "year",
"credits": { "api_calls": 20000 },
"features": { "priority_support": true }
}
price and stripe_price_id cannot be changed here - Stripe prices are immutable. To change the price, create a new plan (which mints a new Stripe Price) and archive the old one via DELETE.{ // full updated plan object, same shape as list }
This sets archived = true, it does not hard-delete the row - existing subscribers keep resolving to it (e.g. on renewal webhooks), it just disappears from GET /sdk/plans so new users can't pick it.
204 No Content
This is a browser redirect endpoint reached by public_project_id (safe to expose client-side), not your secret X-API-Key. Find public_id in the project list response.
GET /sdk/auth/login?public_project_id=3fa1...c2e
Creates or finds the user, issues a one-time login_code, then redirects the browser to your project's configured redirect_url:
your_redirect_url?code=<login_code>
Your frontend should pick up code from the query string and immediately send it to your own backend to exchange for tokens (next endpoint) - never expose your X-API-Key in frontend JS to do this exchange directly.
Despite being a GET, this endpoint reads a JSON body - a raw JSON string, not an object:
"the_login_code_from_the_redirect"
The code is single-use and short-lived; a second exchange attempt returns 401.
{
"access_token": "eyJ...",
"refresh_token": "eyJ..."
}
{ "refresh_token": "eyJ..." }
{
"user_access_token": "eyJ...",
"user_refresh_token": "eyJ..."
}
/sdk/auth/token above (user_access_token/user_refresh_token here vs access_token/refresh_token there). Check the exact key when wiring up your SDK client.{ "refresh_token": "eyJ..." }
200 OK
{
"user_id": "uuid",
"user_email": "user@example.com",
"plan": "pro",
"payment_status": "active",
"features": { "priority_support": true }
}
features comes straight from the resolved plan's features map - use it to gate UI/functionality client-side without a separate entitlement call. It's null if the plan can't be resolved.
payment_status reflects the user's Stripe subscription health: "active" (no payment problem) or "past_due" (a renewal payment failed and Stripe is retrying). During past_due the user keeps access - surface an "update your card" prompt. If retries ultimately fail, the user is downgraded to free and reset to "active".
No X-API-Key and no founder token needed - safe to call from a public pricing page. Returns only non-archived plans.
[
{
"id": "uuid",
"name": "pro",
"price": 2900,
"currency": "usd",
"billing_interval": "month",
"credits": { "api_calls": 10000 },
"features": { "priority_support": true }
}
]
Redirects (307) to the Stripe Checkout page for the given plan. Checkout mode is picked automatically from the plan: subscription for month/year plans, payment for one_time plans.
On success/cancel, Stripe redirects back to your project's redirect_url with ?checkout=success or ?checkout=cancel appended.
Point your Stripe Dashboard → Webhooks at this URL (per-project, note the {project_id} path segment). Verified against the project's stripe_webhook_secret. Idempotent - replays of the same event ID are skipped.
{ "credit_key": "api_calls", "amount": 1 }
amount defaults to 1 if omitted or ≤ 0. The bucket for credit_key is lazily created on first use, sized to the user's current plan grant.
{ "remaining": 9999 }
insufficient credits
can't resolve plan / plan "free" has no credit "api_calls"
[
{
"user_id": "uuid",
"project_id": "uuid",
"credit_key": "api_calls",
"balance": 9999,
"period_end": "2026-08-01T00:00:00Z"
}
]
Use for uptime monitoring (UptimeRobot, etc.).
{ "status": "ok", "db": "ok" }
{ "status": "error", "db": "unreachable" }