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

🔐
Auth
Google OAuth for your users. JWT access + refresh tokens. Multi-tenant - each project has isolated users.
💳
Payments
Stripe Checkout integration. Plans auto-create their Stripe Product/Price. Subscriptions or one-time payments.
📊
Metering
Per-plan credit buckets (API calls, tokens, exports - whatever key you want). Consume, check balance, auto-refill on billing cycle.

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.

1. Your appRedirect to /sdk/auth/login?public_project_id=...
2. billtRedirects to Google
3. GoogleUser approves
4. billt callbackCreates/finds user, issues login_code
5. Your redirect_url?code=<login_code>
6. Your backendExchanges code via /sdk/auth/token

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

Access token: short-lived. Refresh token: long-lived, stored hashed in the DB and revocable. When the access token expires, call 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.

400Bad request - missing or invalid parameters 401Unauthorized - missing or invalid JWT / API key / login code 402Payment required - no credits left for this credit_key 403Forbidden - you don't own this resource 404Not found - resource doesn't exist 500Internal error - something broke on the server 503Service unavailable - DB unreachable (see /health)
GET /founder/billing Your billt plan + MAU status
Bearer: founder_access_token

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.

Response 200
{
  "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.

GET /founder/billing/mau Force a MAU recount
Bearer: founder_access_token

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.

Response 200
{ "mau": 42 }
GET /founder/billing/checkout?plan={pro|team} Upgrade your own billt plan
Bearer: founder_access_token

Redirects (307) to Stripe Checkout for a billt Pro or Team subscription. This is what the "Upgrade" buttons on /public/billing.html hit.

Query params
FieldTypeRequiredNotes
planstringrequired"pro" or "team" - anything else returns 400
⚠️ Returns 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.
GET /founder/billing/portal Manage/cancel your billt subscription
Bearer: founder_access_token

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).

⚠️ Returns 400 if you've never completed a checkout (no Stripe customer yet) - go through Checkout first.
POST /founder/billing/webhook Stripe → billt (self-billing)
Stripe-Signature header

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.

Handled events
checkout.session.completedSaves the Stripe customer/subscription id against your founder account customer.subscription.created / .updatedResolves plan by Stripe price ID, updates your plan + renewal date, re-evaluates billing status customer.subscription.deletedReverts your account to the free plan, re-evaluates billing status
⚠️ Signature verification requires the API version pinned in the Stripe SDK build (currently 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.
GET /founder/projects List your projects
Bearer: founder_access_token
Response 200
[
  {
    "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.

POST /founder/projects Create a project
Bearer: founder_access_token
Request body
{ "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.

Response 201
{
  "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.
PUT /founder/projects/{project_id}/credentials Update OAuth / Stripe / redirect config
Bearer: founder_access_token
Request body (all fields optional - only non-empty values are applied)
{
  "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.

Response
204 No Content
DELETE /founder/projects/{id} Delete a project
Bearer: founder_access_token

Cascades to all users, plans, credit balances and payments belonging to this project. Not reversible.

Response
204 No Content
GET /founder/projects/{project_id}/plans List all plans (including archived)
Bearer: founder_access_token
Response 200
[
  {
    "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
  }
]
POST /founder/projects/{project_id}/plans Create a plan
Bearer: founder_access_token
Body params
FieldTypeRequiredNotes
namestringrequired1-100 chars
priceint64requiredCents, > 0. Use 0 for a free tier by not calling this at all - the free plan is auto-created per project.
currencystringrequirede.g. "usd", "eur"
billing_intervalstringoptional"month" | "year" | "one_time", defaults to "month"
stripe_price_idstringoptionalLeave 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.
creditsmap[string]int64optionalCredit key → grant per billing period. Keys are your own naming, e.g. "api_calls".
featuresmap[string]anyoptionalArbitrary flags/limits returned in /sdk/user/me for your app to gate on.
Request body
{
  "name": "pro",
  "price": 2900,
  "currency": "usd",
  "billing_interval": "month",
  "credits": { "api_calls": 10000 },
  "features": { "priority_support": true }
}
Response 201
"uuid"  // just the new plan's id, as a raw JSON string
⚠️ Creating a plan calls the Stripe API synchronously (Product + Price creation) if stripe_price_id is empty. This requires stripe_secret_key to already be set on the project via the credentials endpoint.
PUT /founder/projects/{project_id}/plans/{plan_id} Update a plan
Bearer: founder_access_token
Request body (all fields optional)
{
  "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.
Response 200
{ // full updated plan object, same shape as list }
DELETE /founder/projects/{project_id}/plans/{plan_id} Archive a plan
Bearer: founder_access_token

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.

Response
204 No Content
GET /sdk/auth/login?public_project_id={uuid} Start end-user Google OAuth
No X-API-Key

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.

Example
GET /sdk/auth/login?public_project_id=3fa1...c2e
GET /sdk/auth/callback Google redirects here
No X-API-Key

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.

GET /sdk/auth/token Exchange login_code for real tokens
X-API-Key

Despite being a GET, this endpoint reads a JSON body - a raw JSON string, not an object:

Request body
"the_login_code_from_the_redirect"

The code is single-use and short-lived; a second exchange attempt returns 401.

Response 200
{
  "access_token": "eyJ...",
  "refresh_token": "eyJ..."
}
POST /sdk/auth/refresh Rotate user tokens
X-API-Key
Request body
{ "refresh_token": "eyJ..." }
Response 200
{
  "user_access_token": "eyJ...",
  "user_refresh_token": "eyJ..."
}
⚠️ Note the field names differ from /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.
POST /sdk/auth/logout Revoke a refresh token
X-API-Key
Request body
{ "refresh_token": "eyJ..." }
Response
200 OK
GET /sdk/user/me Get current user + entitlements
X-API-Key Bearer: access_token
Response 200
{
  "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".

GET /sdk/plans?public_project_id={uuid} Public pricing list
No auth

No X-API-Key and no founder token needed - safe to call from a public pricing page. Returns only non-archived plans.

Response 200
[
  {
    "id": "uuid",
    "name": "pro",
    "price": 2900,
    "currency": "usd",
    "billing_interval": "month",
    "credits": { "api_calls": 10000 },
    "features": { "priority_support": true }
  }
]
GET /sdk/payments/checkout?plan_id={uuid} Start Stripe Checkout
X-API-Key Bearer: access_token

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.

POST /sdk/payments/webhook/{project_id} Stripe → billt
Stripe-Signature header

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.

Handled events
checkout.session.completedRecords payment, sets user's plan, grants that plan's credits customer.subscription.created / .updatedResolves plan by Stripe price ID, sets user's plan, refills credits, marks payment_status "active" customer.subscription.deletedReverts user to "free" plan, clears and re-seeds credit balances, resets payment_status to "active" (terminal - fires after dunning is exhausted) invoice.payment_succeededRecords renewal payment, refills credits for the current plan invoice.payment_failedMarks user "past_due" but keeps plan, access, and credits while Stripe retries (dunning) - no downgrade here
POST /sdk/usage/consume Spend credits
X-API-Key Bearer: access_token
Request body
{ "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.

Response 200
{ "remaining": 9999 }
Response 402 - out of credits
insufficient credits
Response 400 - plan/credit_key not configured
can't resolve plan / plan "free" has no credit "api_calls"
GET /sdk/usage Get all credit balances
X-API-Key Bearer: access_token
Response 200
[
  {
    "user_id": "uuid",
    "project_id": "uuid",
    "credit_key": "api_calls",
    "balance": 9999,
    "period_end": "2026-08-01T00:00:00Z"
  }
]
GET /health System health check
No auth

Use for uptime monitoring (UptimeRobot, etc.).

Response 200 - healthy
{ "status": "ok", "db": "ok" }
Response 503 - DB down
{ "status": "error", "db": "unreachable" }