Tenants, Usage & Billing
Start here: README · Related: Authentication · Conventions & errors · Text translation · Documents
This page covers the administrative and commercial surface of the TrueIdiom API: creating and listing tenant accounts, reading per-day and per-period usage, exporting the billing ledger, and starting or managing a Stripe subscription. Reach for it when you are provisioning tenants for your customers, reconciling an invoice against the character ledger, or wiring a "Subscribe" / "Manage billing" button into your own UI. Everything here is denominated in source characters — weighted by language pair before they are counted, see Usage accounting model — but nothing is metered per job: a tenant buys a flat monthly tier subscription — billed in advance at each period start — that carries an included character allowance, and buys prepaid top-ups when it wants to work past that allowance. A tenant admin session sees exactly the quantity the invoice is built from; a small set of operator-only diagnostic fields is withheld from it. See Who sees the token ledger.
Base URL: https://trueidiom.com. There is no version prefix; every route below is served under /api. Requests take an optional api-version=2026-09-01 query parameter; omitting it serves the oldest supported version. See Versioning.
Authentication
The document published at /openapi.json declares no securitySchemes and no global security requirement, so the interactive /docs page shows every endpoint as unauthenticated. They are not. Authentication is enforced server-side on every request, and the table below is the authoritative statement of what each endpoint requires.
Three distinct credentials appear on this page.
| Credential | How it is sent | What it is |
|---|---|---|
| Operator API key | X-API-Key: <key> or Authorization: Bearer <key> | The platform-wide break-glass key, configured per deployment. Cross-tenant: it is the only credential that widens a tenant-scoped read to every tenant. |
| Tenant admin session | Authorization: Bearer <access_token>, or the ll_session cookie for browser navigations | An access token for a user whose role is global_administrator. This role is per-tenant — every tenant creator holds it — so an admin session is confined to its own tenant. |
| Tenant API key | X-API-Key: <key> or Authorization: Bearer <key>, optionally with X-Tenant-ID: <tenant_id> | The per-tenant key returned once by POST /api/tenants. Identifies a single tenant. |
Per-endpoint requirements:
| Endpoint | Required credential | Scope |
|---|---|---|
POST /api/tenants | Operator API key or tenant admin session | Administrative |
GET /api/tenants | Operator API key or tenant admin session | Operator sees all tenants; an admin session sees only its own |
GET /api/tenants/usage/daily | Operator API key or tenant admin session | Operator sees the aggregate, with tokens; an admin session is forced to its own tenant and gets characters only |
POST /api/tenants/invites, GET /api/tenants/invites, DELETE /api/tenants/invites/{invite_id} | Operator API key or tenant admin session | An admin session acts on its own tenant only; naming another returns 404 |
GET /api/tenants/{tenant_id}/billing | Operator API key or tenant admin session | An admin session may read only its own tenant_id, and its response omits the token fields |
GET /api/tenants/{tenant_id}/billing/export | Operator API key or tenant admin session | An admin session may export only its own tenant_id, and its export omits the token fields and the model roster |
POST /api/billing/checkout | Tenant API key or tenant access token | Acts on the resolved tenant |
POST /api/billing/topup | Tenant API key or tenant access token | Acts on the resolved tenant; the tenant must already hold a current subscription |
POST /api/billing/portal | Tenant API key or tenant access token | Acts on the resolved tenant |
POST /api/stripe/webhook | Stripe webhook signature (Stripe-Signature) | Called by Stripe, not by integrators |
Admin gate behavior
The /api/tenants* routes call the same gate, in this order:
- If an operator key is configured on the server and the request presents it, the request passes as a platform operator.
- Otherwise, if the request carries a resolvable session (bearer access token or
ll_sessioncookie):- role is
global_administrator→ passes, scoped to that principal's tenant. - any other role →
403 {"detail": "admin role required"}.
- role is
- Otherwise, if the deployment has no operator key configured and no tenant accounts, the gate passes — an unconfigured deployment is open so a fresh checkout can create its first tenant. The hole closes the moment either exists: configure an operator key, or create one account, and this branch stops applying.
- Otherwise →
401 {"detail": "admin credentials required"}.
A session belonging to an address designated as a platform operator with an authenticator enrolled is also treated as a platform operator for the scoping step, so it sees every tenant on the reads below. Designation is deployment config only and the enrolment half is re-checked on every request — see Authentication → Platform operators.
Tenant resolution
The three /api/billing/* routes resolve a tenant instead of an admin:
- An
Authorization: Bearer <token>value is first tried as an access token (optionally narrowed byX-Tenant-ID); a valid token resolves to that principal's tenant. - Failing that, the
X-API-Keyheader (or the same bearer value) is matched against tenant API keys, optionally narrowed byX-Tenant-ID. - No match →
401 {"detail": "missing or invalid tenant credentials"}. - If the deployment has no tenant accounts at all, no tenant can be resolved and the billing routes return
401 {"detail": "billing requires an authenticated tenant"}.
Tenant resolution reads the Authorization and X-API-Key headers only — the ll_session cookie alone does not authenticate any /api/billing/* route. Browser clients must send the access token as a bearer header on those three calls.
Resolution also records which credential answered — a session or a tenant API key. That distinction is not cosmetic: the free trial admits a workspace session and refuses an API key (see No API during the free trial).
Every response carries an X-Request-ID header (echoed from the request when you supply one). Include it when reporting a problem.
Usage accounting model
Usage is recorded per translation job. The quantity a tenant is billed on is billable_chars — the job's source characters, weighted by language pair. Alongside it the record carries a TokenUsage triple of operator-only processing counters, which never reach an invoice:
| Field | Meaning |
|---|---|
input_tokens | Operator only. Processing counter recorded for the job |
output_tokens | Operator only. Processing counter recorded for the job |
total_tokens | Operator only. Reported total; where it is not reported separately, input_tokens + output_tokens |
The counters cover the job as a whole, whatever work it took to finish. One job produces exactly one TenantUsageRecord; the record is keyed by job_id and is written once, so a replayed or retried job does not double-count.
Records are stamped with a billing_period of YYYY-MM (UTC), derived from the job's updated_at timestamp. All period filters on this page use that same string format — for example 2026-07.
Usage records are produced by the text translation pipeline (POST /api/translations, including documents that the text pipeline splits into pages) and by layout-preserving document jobs (POST /api/documents/translate). A document job is metered asynchronously, on completion — its character count is not knowable until extraction has run, which is why the submission-time quota gate runs with a request size of 0 (see Quotas). Once the job finishes, its record appears in usage/daily and in the ledger export like any other.
Dense-script character weighting
Source characters are weighted by language pair before they are counted. When either side of the pair is Chinese (Simplified or Traditional — zh-Hans, zh-Hant), Japanese, Korean, Thai, or Hindi, each source character counts as 3.5 by default against your monthly allowance, your free-trial grant, and your usage records. All other pairs count 1:1.
A 100,000-character English source translated into Japanese is therefore billed as 350,000 characters, not 100,000.
Three things to hold on to:
- Either side triggers it, and it applies once. Into a dense script the output carries the density; out of one the source does. Both directions cost more than the raw source length suggests, which is why the rule keys on the pair rather than on the target. A pair that is dense on both sides —
ja→zh-Hans— is still weighted3.5, never squared. - The same weighted number gates admission and reports usage. It is what the
402trial checks and the429allowance check evaluate before a job is admitted, and it is whatbillable_chars,usage/daily, and the ledger export report afterwards. There is no second, unweighted figure anywhere in this API. - Budget from the weighted figure, not the raw one. A client that sizes a batch against
len(source_text)will under-count on these pairs by a factor of 3.5 and meet the429earlier than it expected.
The alphabetic languages are deliberately excluded — Arabic, Russian, Vietnamese and Turkish tokenize close to Latin, so weighting them would overcharge. The factor is a per-deployment setting; treat 3.5 as the default rather than as a constant.
Who sees the token ledger
The processing counters and the model roster are operator-only, so they stop at the operator boundary. Every usage response on this page therefore comes in two shapes:
| Caller | What comes back |
|---|---|
| Platform operator (operator API key, or a designated operator session) | The full payload — the token fields and the model roster included. |
| Tenant admin session | The same payload with the token fields removed, and, on the ledger export, the model roster removed as well. What remains is what the tenant is billed on: billable_chars. |
Two consequences worth planning for. A global_administrator is a per-tenant role, so holding it is not enough to see those fields — that is the operator credential's alone. And the split is applied per response by the routes rather than by the store, so it holds identically across both storage backends and across all three export formats.
TenantUsageRecord and TenantUsageSummary are themselves unchanged: the operator shape is the full schema, and the tenant shape is that schema minus the operator-only fields.
Quotas
Two independent quotas can stop a translation request.
Monthly character limit. Every tenant has monthly_char_limit (default 500000 source characters, configurable per tenant at creation via TenantAccountCreate.monthly_char_limit). For a self-serve subscriber this is the included allowance of the tier they bought, written onto the tenant when the subscription event lands — see POST /api/billing/checkout. Before a text translation is submitted, the service counts the request's source characters — weighted by language pair, so a dense-script pair counts 3.5 per character — and compares current_period_total + request against the period's allowance. If that would exceed it, the translation endpoint returns:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{"detail": "tenant usage limit exceeded"}
The limit applies to the current billing period's accumulated billable_chars and resets when the YYYY-MM period rolls over. It is enforced whether or not billing is enabled.
The allowance is the limit plus any prepaid top-up balance:
allowance = monthly_char_limit
+ min(balance_available_to_the_month,
max(0, 10000000 - monthly_char_limit))
Four things about that formula:
balance_available_to_the_monthis your live top-up balance plus whatever this month has already drawn from it. Spending prepaid characters moves them from one side of that sum to the other, so the month's total reach never shrinks as you spend: the live balance shown on/billinggoes down, the month's allowance does not.- The top-up term applies only when billing is enabled and the tenant's subscription is current. A trial tenant gets no top-up headroom — it cannot buy packs in the first place.
- A ceiling of
10000000characters bounds the top-up headroom only. An operator-raisedmonthly_char_limitabove the ceiling — an enterprise account provisioned throughPOST /api/tenants— is not clamped down to it; it simply gets no headroom on top. - A credited balance is honored even after top-ups stop being sold and new purchases stop, because those characters are already paid for.
The refusal names whichever remedies the tenant actually has. Three exact detail strings, chosen from the tenant's billing state:
| Condition | detail |
|---|---|
| Billing current and a top-up Price is configured | tenant usage limit exceeded — buy a prepaid top-up on /billing to keep translating this month; switching plans there raises your allowance from the next billing period |
| Billing current, no top-up Price configured | tenant usage limit exceeded — switch to a larger plan on /billing to raise your allowance from the next billing period |
| Anything else (trial, no subscription, billing disabled) | tenant usage limit exceeded |
The top-up prompt is omitted where top-ups are not on sale, since POST /api/billing/topup would answer 503; the plan-switch prompt is omitted for a tenant with no subscription to switch.
The two remedies do not take effect at the same time, which is why the first string names both and distinguishes them. A prepaid top-up is credited as soon as its Checkout completes, so it raises the allowance within the current period and clears the refusal immediately. A plan switch goes through the Customer Portal, which applies the change at the next billing period: monthly_char_limit is rewritten when that period's subscription event arrives, so a switch made mid-period does nothing for the period it was made in. A client surfacing this 429 should present the top-up as the fix for now and the plan switch as the fix for the months after.
Layout document jobs (POST /api/documents/translate) cannot know their character count before extraction, so they run this gate with a request size of 0: an in-flight document can carry the month past its allowance, but the next submission is refused. Without that, document usage — which is recorded — would grow the month without bound.
Prepaid top-up draw-down. For a subscription-current tenant, each recorded job is charged to exactly one ledger:
| Where the month stood before this job | Charged to |
|---|---|
Inside monthly_char_limit | The subscription's included allowance — the flat monthly fee already paid for it, so there is no per-job charge |
At or past monthly_char_limit, with topup_chars_remaining > 0 | metadata.topup_chars_used |
At or past monthly_char_limit, with no balance | Nothing — there is no ledger left to draw on. This is the hard stop: the quota gate refuses the next submission with 429 |
Never both, and a job is never split across the two. The draw-down decision is taken once per job, from where the month stood before it: a job that straddles the boundary is covered whole by the included allowance and leaves the prepaid balance untouched, and the next job — which now starts past the boundary — draws prepaid. So a top-up is never spent on characters the subscription had already paid for. The third row is normally unreachable, since the gate refuses in advance any submission the allowance plus the balance cannot cover; it shows up only where the two can disagree after admission — concurrent jobs racing the same allowance, or a limit that moved mid-period.
Free-trial character grant. Where the free trial is switched on (default 150000 source characters — weighted like every other figure on this page, so a dense-script pair spends the grant 3.5× faster; ~24,600 words — sized to translate a real document twice, since translation-memory leverage only shows on the repeat), newly created tenants get a one-time grant stamped into metadata.trial_chars_granted. Consumption accumulates in metadata.trial_chars_used; remaining is granted - used, floored at zero. The grant covers text translation only — layout document translation always requires a subscription. Trial usage draws this counter down instead of a plan allowance; a trial tenant has no subscription to charge against, and never reaches Stripe at all.
Free-trial clock. A trial length of 14 days — the length the landing page advertises — bounds that same grant in calendar time. The clock starts at the account's created_at, and that moment is the trial start: the grant is stamped at creation, so there is no separate trial-start field that could drift from it. The trial is expired once now >= created_at + 14 days, boundary inclusive — the instant the fourteenth day completes is already expired. A stored timestamp with no zone is read as UTC. Where the clock is switched off, the characters-only trial remains. The clock only ever ends a trial that exists: a tenant with no grant is refused by the ordinary subscription gate instead, and a subscription-current tenant is never checked against it at all.
A trial must be live on both counts to be admitted — characters remaining and the clock still running. The two ways it can end carry deliberately different 402 messages: exhaustion is the value story ("you spent it"), expiry is the clock. Exhaustion is checked first, so a tenant that burned its grant is told exactly that whatever the calendar says. The workspace consequence is identical either way: /app bounces an ended trial to /billing?setup=required before the page renders — the same first-run redirect a never-subscribed tenant meets — and /billing collapses both endings into one line, Your free trial has ended — subscribe to keep translating. While the trial is live, both pages append · N days left to the character counter, rounded up, so a trial living out its final partial day reads 1 day left rather than 0.
The grant is only enforced when billing is enabled. In that case the translation endpoints return 402 Payment Required with one of these exact messages:
| Condition | detail |
|---|---|
| Trial grant fully consumed | your free trial is used up — add a payment method at /billing to continue |
| Trial clock run out (the trial period elapsed since creation), characters still on the grant | your free trial has ended — add a payment method at /billing to continue |
| This single request would overrun what is left of the grant | this request exceeds your remaining free trial characters — add a payment method at /billing |
| A tenant API key presented on a tenant whose trial is still live — characters remaining and the clock running | the free trial covers the workspace only — API access requires an active subscription; subscribe at /billing |
| No trial grant and no current subscription (also: any layout document job) | an active subscription is required to run translations — add a payment method at /billing |
A subscription counts as current when its mirrored status is active or trialing. Before denying, the service re-checks Stripe directly for the tenant's customer, so a webhook that never arrived cannot strand a paying tenant.
No API during the free trial
The trial surface is the workspace, not the API. A trial tenant translating from a session (Authorization: Bearer <access_token>) is admitted; the same tenant, with the same live grant, presenting its tenant API key is refused with the workspace-only 402 above. Scripted consumption of a no-card grant is the abuse shape this closes, and the refusal is trial-scoped rather than a blanket ban — the moment the subscription lands, the same key works.
That scoping is literal: the workspace-only refusal only exists while there is a live trial to scope it to. Once the grant is spent or the clock runs out, the key meets the same trial-ended wall the session does, not a message about API access.
Per-IP grant gating
The trial grant is rationed by client IP rather than by email domain: the target buyer is a freelancer on a consumer mailbox, so domain rules would block customers and miss attackers. At most 3 grants are stamped per IP per rolling 86400-second window.
An over-limit signup still succeeds and still returns an account and credentials; it is created with an explicit metadata.trial_chars_granted of 0, so the tenant meets the ordinary "an active subscription is required" 402 instead of a signup error. The gate is inert — and consumes no limiter budget — when the trial grant is 0, the per-IP allowance is 0, or billing is disabled, since the grant is then unenforced anyway.
Protected metadata on self-serve signup
POST /api/auth/signup/email (and the OAuth signup path) accept caller metadata, but strip every key whose name begins with one of seven platform-owned prefixes — stripe_, trial_, topup_, mfa_, sso_, invites_, or storage_ — matched case-insensitively, so Trial_Chars_Granted does not slip through. Everything else is stored verbatim.
This is a security boundary, not tidiness. Signup metadata reaches account creation directly, where the trial grant is a setdefault; without the strip a signup could name its own trial_chars_granted, or set stripe_subscription_status: "active" and satisfy the subscription gate with no Stripe customer behind it — nothing downstream charges anything per job, so a self-declared subscription is simply unlimited free service, and there is no invoice anywhere for anyone to notice.
mfa_ and sso_ are the security-policy namespaces (mfa_required, mfa_required_for_sso, sso_auto_join). Pre-setting the MFA flags at signup would only self-inflict friction rather than grant anything, but sso_auto_join is different — a tenant arriving with its domain pre-opened is a real grant — and policy keys are worth exactly one writer either way: PATCH /api/tenants/security, behind the admin gate.
invites_ is the pending-invite ledger (invites_pending), which is a list of token hashes plus the role each one grants. A self-serve signup carrying invites it wrote for itself would be minting workspace access at tenant-creation time, so the prefix is stripped on the same boundary. POST /api/tenants/invites is the only writer.
storage_ holds the tenant's platform-storage cap override. Letting a signup name its own would be letting it lift the ceiling on the bytes it may park on the platform volume, so it is operator-written only.
Self-serve signup may also send monthly_char_limit, but only downward: the stored value is the smaller of the requested cap and the server default. Raising a cap is postpaid exposure on an unproven card, so it is an operator action.
POST /api/tenants is unaffected by all three rules — it is operator-gated, and writing these namespaces by hand is exactly what it is for.
The billing kill switch
Billing is a deployment-level kill switch, off by default. The billing service is considered enabled only when the deployment both switches billing on and holds Stripe API credentials; either half missing behaves as disabled.
When billing is off:
POST /api/billing/checkout,POST /api/billing/topup,POST /api/billing/portal, andPOST /api/stripe/webhookall return503 {"detail": "billing is not enabled"}.- No
402gate is applied to translation endpoints — trial and subscription checks are skipped entirely. - No Stripe SDK calls are made at all: no subscription is created or charged, and no top-up can be purchased or credited.
- The per-IP trial-grant gate is inert: grants are stamped as configured and no limiter budget is consumed.
- Usage records are still written, and
GET /api/tenants/{tenant_id}/billing, the export endpoint, and the monthly-character-limit429all continue to work — without the prepaid-balance term in the allowance, since a balance can only exist under billing.
POST /api/tenants
Create a tenant account and mint its API key.
Auth: operator API key, or a global_administrator session (see Admin gate behavior).
This is the one admin route that is not confined to the caller's own tenant.
global_administratoris a per-tenant role everywhere else — a tenant admin's reads and writes are scoped to their own tenant — but creating a tenant has no existing tenant to scope against, so anyglobal_administratormay call this and will receive the new tenant'sapi_key. It does not grant them any visibility into tenants they did not create:GET /api/tenants, the usage routes, and the billing routes still return only their own tenant. If you are provisioning tenants for customers, prefer the operator key so the audit trail is unambiguous.Self-service signup (
POST /api/auth/signup/email) creates a tenant with no credentials at all and is the path most integrators want; this endpoint exists for operator-driven provisioning.The two paths are not equivalent on
metadataor onmonthly_char_limit. Self-serve signup strips the seven platform-owned prefixes (stripe_/trial_/topup_/mfa_/sso_/invites_/storage_) and clamps the cap down to the server default; this route writes both verbatim, including a bespoketrial_chars_grantedor an enterprise cap above the top-up ceiling. See Protected metadata on self-serve signup.
Request body
Content-Type: application/json — schema TenantAccountCreate.
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name for the tenant. Leading/trailing whitespace is stripped. |
monthly_char_limit | integer | null | No | Per-period ceiling in source characters — weighted ones, as the gate measures them. Minimum 1. Omit or send null to inherit the server default (500000). |
metadata | object | No | Free-form JSON stored on the account. Defaults to {}. The free-trial grant is merged in here at creation; an explicit trial_chars_granted value you supply (including 0) wins over the configured default. |
Example
curl -sS -X POST "https://trueidiom.com/api/tenants?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Northwind Legal",
"monthly_char_limit": 2000000,
"metadata": {"plan_note": "pilot"}
}'
Response 200
{
"account": {
"id": "9f2c1a7b4e6d0835",
"name": "Northwind Legal",
"api_key_prefix": "u7Qm2xR9",
"monthly_char_limit": 2000000,
"active": true,
"created_at": "2026-07-26T09:14:02.518431Z",
"metadata": {
"plan_note": "pilot",
"trial_chars_granted": 25000
}
},
"api_key": "u7Qm2xR9pKf4Ld0aTn6ZbYw1Cv3JhE8sMgVqXo5RtUw"
}
api_key is returned only in this response. The store keeps a SHA-256 hash of the key; every later read exposes api_key_prefix (the first 8 characters) for identification. Capture the key at creation time.
Errors
| Status | Cause |
|---|---|
401 | {"detail": "admin credentials required"} — no usable operator key or session. |
403 | {"detail": "admin role required"} — authenticated, but not global_administrator. |
422 | HTTPValidationError — name missing, or monthly_char_limit below 1. |
GET /api/tenants
List tenant accounts.
Auth: operator API key, or a global_administrator session.
An operator receives every tenant account. A tenant admin session receives an array containing only its own account (empty if that account cannot be loaded). No query parameters.
Example
curl -sS "https://trueidiom.com/api/tenants?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY"
Response 200 — TenantAccount[]
[
{
"id": "9f2c1a7b4e6d0835",
"name": "Northwind Legal",
"api_key_prefix": "u7Qm2xR9",
"monthly_char_limit": 2000000,
"active": true,
"created_at": "2026-07-26T09:14:02.518431Z",
"metadata": {
"plan_note": "pilot",
"trial_chars_granted": 25000,
"trial_chars_used": 25000,
"stripe_customer_id": "cus_QjT2mP4wLbXk9A",
"stripe_subscription_id": "sub_1QpZ8kR2eZvKYlo2CqTr4Vd7",
"stripe_subscription_status": "active"
}
}
]
With the Postgres tenant backend the list is ordered by created_at descending.
Errors
| Status | Cause |
|---|---|
401 | No usable operator key or session. |
403 | Authenticated, but not global_administrator. |
PATCH /api/tenants/security
Set a tenant's sign-in policy — three flags:
| Flag | Meaning | Default |
|---|---|---|
mfa_required | Every member must present a second factor to sign in | false |
mfa_required_for_sso | Extend that to completed Google sign-ins, which are otherwise exempt because the IdP enforced its own factor policy | false |
sso_auto_join | Allow a new user matching the tenant's Google Workspace domain (hd) to join without an invitation | true — and absent metadata also means true, so only an explicit false closes the domain |
Auth: operator API key — which must name the tenant with ?tenant_id, as must a designated operator session — or a global_administrator session, which is confined to its own tenant.
All three body fields are tri-state (true / false / omitted-means-unchanged), an empty body {} reads the effective policy back without writing, and the response always carries all three keys — including after a patch that touched only one. Clients read an absent sso_auto_join as on, so omitting it would show a closed domain as open.
Closing the domain gates joining, never signing in: existing members are unaffected, and a new org-claim match is refused with 403 this organization requires an invitation — ask your workspace admin for an invite link. An invite overrides the gate.
This route is the only writer of the mfa_ and sso_ tenant-metadata namespaces: like stripe_, trial_, topup_, invites_, and storage_, they are stripped from the caller-supplied metadata of self-serve tenant creation, so no tenant can arrive with a security policy — or an open door — it set for itself.
Full reference, including the sign-in flows the flags produce: Authentication & Authorization → PATCH /api/tenants/security.
POST /api/tenants/invites
Mint an invite link for this tenant.
Auth: operator API key — which must name the tenant with ?tenant_id — or a global_administrator session, which is confined to its own tenant (naming another tenant's id returns 404, never 403).
Request body
Content-Type: application/json. Every field is optional; {} mints a plain seven-day member invite.
| Name | Type | Required | Description |
|---|---|---|---|
role | string | No | Role the joiner receives. global_administrator or agent_id_developer (default). An unknown value is 422 — never normalized down silently. |
expires_days | integer | No | Link lifetime, default 7. Clamped to 1–30 rather than rejected. |
email | string | null | No | Bind the link to one address. A redemption from any other address is refused, and the link is not consumed. |
label | string | null | No | Admin-facing note (max 64 chars), e.g. "Q3 contractors". Never shown to the joiner. |
Example
curl -sS -X POST "https://trueidiom.com/api/tenants/invites?api-version=2026-09-01" \
-H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "agent_id_developer", "email": "tomas@acme-legal.com", "label": "Q3 contractors"}'
Response 200
{
"invite": {
"id": "9f2c1a7b4e6d0835",
"role": "agent_id_developer",
"email": "tomas@acme-legal.com",
"label": "Q3 contractors",
"created_by": "maria@acme-legal.com",
"created_at": "2026-08-09T09:14:02.518431+00:00",
"expires_at": "2026-08-16T09:14:02.518431+00:00"
},
"invite_url": "https://trueidiom.com/signin?invite=7c1f5b0e4a9d4d2f.kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU",
"token": "7c1f5b0e4a9d4d2f.kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU"
}
tokenandinvite_urlare returned exactly once. Only a SHA-256 hash is stored, so the link cannot be re-read —GETbelow returns everything about an invite except its token. Anyone holding it can join the workspace at the role it names: treat it like an access token, and never log it. To kill a mis-delivered link, revoke it.
Recorded as invite_created in the auth audit log (tenant, acting admin, role, invite id — never the token).
Errors
| Status | Cause |
|---|---|
400 | tenant_id query parameter is required with API-key access |
401 | No usable operator key or session. |
403 | Authenticated, but not global_administrator. |
404 | Unknown tenant, or another tenant's id from an admin session. |
409 | The tenant already holds 20 pending invites — revoke one first. The request was well-formed; the tenant's state is wrong for it. |
422 | role is not a valid role. |
GET /api/tenants/invites
List this tenant's live pending invites, newest last.
Auth: as above.
{"invites": [{"id": "9f2c1a7b4e6d0835", "role": "agent_id_developer", "email": null, "label": "Q3 contractors", "created_by": "maria@acme-legal.com", "created_at": "…", "expires_at": "…"}]}
Expired invites are omitted — they can no longer be redeemed, so listing them as pending would misreport the tenant's open invitations. They are physically pruned at the next mint, which is also why an abandoned tenant's lapsed invites never block minting: expired entries do not count against the cap of 20.
No entry ever carries the token or its hash.
DELETE /api/tenants/invites/{invite_id}
Withdraw a pending invite. Its link stops working at once, on both the redemption routes and the public preview.
Auth: as above. Returns 204 with no body; an unknown or already-revoked id is 404, so a client can tell "I revoked it" from "there was nothing there". Already-expired ids are accepted, since removing them is the cleanup the admin asked for.
Recorded as invite_revoked (tenant, acting admin, the role that was withdrawn, invite id).
Redemption, the token format, and the public preview endpoint are documented in Authentication & Authorization → Invites.
GET /api/tenants/usage/daily
Per-day usage totals over an inclusive date range.
Auth: operator API key, or a global_administrator session.
An operator receives the aggregate across all tenants, optionally narrowed with tenant_id. A tenant admin session always receives the series for its own tenant — the session's scope overrides the tenant_id parameter rather than being widened by it.
The bucket shape depends on the caller (see Who sees the token ledger): an operator gets the token series beside the character series, a tenant admin gets billable_chars only.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
since | string (date, YYYY-MM-DD) | No | Inclusive start. Defaults to until - 29 days. |
until | string (date, YYYY-MM-DD) | No | Inclusive end. Defaults to today (UTC). |
tenant_id | string | null | No | Narrow the aggregate to one tenant. Honored only for a platform operator. |
The range must satisfy since <= until and span no more than 366 days.
Example
curl -sS -G "https://trueidiom.com/api/tenants/usage/daily?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY" \
--data-urlencode "since=2026-07-01" \
--data-urlencode "until=2026-07-26" \
--data-urlencode "tenant_id=9f2c1a7b4e6d0835"
Response 200
An array of daily buckets, ascending by day. Days with no recorded usage are omitted — fill the gaps client-side if you are drawing a continuous chart.
To an operator, each bucket carries both series. Note that the token side reports input and output only; there is no total_tokens field here.
[
{"day": "2026-07-03", "input_tokens": 18422, "output_tokens": 9106, "billable_chars": 71400},
{"day": "2026-07-04", "input_tokens": 2310, "output_tokens": 1188, "billable_chars": 9060},
{"day": "2026-07-09", "input_tokens": 66540, "output_tokens": 31877, "billable_chars": 258100}
]
To a tenant admin session, the token fields are absent — not zeroed — and each bucket is exactly two keys:
[
{"day": "2026-07-03", "billable_chars": 71400},
{"day": "2026-07-04", "billable_chars": 9060},
{"day": "2026-07-09", "billable_chars": 258100}
]
Buckets are keyed by the usage record's created_at date (UTC), which is when the job was recorded — not by billing_period. Records written before character metering carry no characters, so they bucket as 0.
Errors
| Status | Cause |
|---|---|
400 | {"detail": "since must be on or before until"} |
400 | {"detail": "date range must not exceed 366 days"} |
401 | No usable operator key or session. |
403 | Authenticated, but not global_administrator. |
422 | since or until is not a valid YYYY-MM-DD date. |
GET /api/tenants/{tenant_id}/billing
Usage totals for one tenant in one billing period.
Auth: operator API key, or a global_administrator session belonging to {tenant_id}.
The three token fields are operator-only (see Who sees the token ledger); a tenant admin session receives the same object without them.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
tenant_id | string | Yes | Tenant account id. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
billing_period | string | null | No | YYYY-MM. Defaults to the current UTC month. |
Example
curl -sS -G "https://trueidiom.com/api/tenants/9f2c1a7b4e6d0835/billing?api-version=2026-09-01" \
-H "Authorization: Bearer $TRUEIDIOM_ACCESS_TOKEN" \
--data-urlencode "billing_period=2026-07"
Response 200 — TenantUsageSummary
To an operator:
{
"tenant_id": "9f2c1a7b4e6d0835",
"tenant_name": "Northwind Legal",
"billing_period": "2026-07",
"monthly_char_limit": 2000000,
"input_tokens": 412903,
"output_tokens": 188447,
"total_tokens": 601350,
"billable_chars": 312400,
"remaining_chars": 1687600,
"over_limit": false,
"billable_records": 274
}
| Field | Type | Description |
|---|---|---|
tenant_id | string | Tenant account id. |
tenant_name | string | Tenant display name. |
billing_period | string | The period these totals cover, YYYY-MM. |
monthly_char_limit | integer | The tenant's configured ceiling for the period. |
input_tokens | integer | Operator only. Sum of usage.input_tokens over the period's records. Default 0. |
output_tokens | integer | Operator only. Sum of usage.output_tokens. Default 0. |
total_tokens | integer | Operator only. Sum of usage.total_tokens. Default 0. |
billable_chars | integer | Source characters billed this period, weighted by language pair — the quantity the limit is measured against. Default 0. |
remaining_chars | integer | max(0, monthly_char_limit - billable_chars). Default 0. |
over_limit | boolean | true when billable_chars > monthly_char_limit. Default false. |
billable_records | integer | Number of TenantUsageRecord rows in the period. Default 0. |
The three fields marked operator-only are absent, not zeroed, for a tenant admin session — a client reading them must treat a missing key as "not visible to me" rather than as "no usage":
{
"tenant_id": "9f2c1a7b4e6d0835",
"tenant_name": "Northwind Legal",
"billing_period": "2026-07",
"monthly_char_limit": 2000000,
"billable_chars": 312400,
"remaining_chars": 1687600,
"over_limit": false,
"billable_records": 274
}
Errors
| Status | Cause |
|---|---|
401 | No usable operator key or session. |
403 | Authenticated, but not global_administrator. |
404 | {"detail": "tenant not found"} — the tenant does not exist, or an admin session asked for a tenant_id other than its own. The two cases are deliberately indistinguishable. |
GET /api/tenants/{tenant_id}/billing/export
Download the usage ledger for one tenant as a file attachment.
Auth: operator API key, or a global_administrator session belonging to {tenant_id}.
An operator gets the raw ledger. A tenant admin session gets it redacted in all three formats: no token fields, and no model roster (see Who sees the token ledger). Both shapes are documented below.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
tenant_id | string | Yes | Tenant account id. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
billing_period | string | null | No | YYYY-MM. Omit to export all periods; the current month is then stamped into the filename and into the JSON envelope's billing_period — see the note below. |
format | string | No | One of json, jsonl, csv. Default json. Must match ^(json|jsonl|csv)$. |
Response 200
The response is a file body, not a wrapped JSON envelope. Every format sets Content-Disposition: attachment; filename="tenant-{tenant_id}-{period}.{ext}", where {period} is the requested billing_period or the current UTC month.
An all-periods export still carries a month. Omit
billing_periodand the rows span every period the tenant has, but thejsonenvelope'sbilling_periodfield — like the filename — is stamped with the current UTC month. It is a label on the download, not a description of the rows. Read each record's ownbilling_periodto know which period a row belongs to; do not attribute the whole file to the envelope's value.
format | Content-Type | Body |
|---|---|---|
json | application/json | Object: {"tenant_id": ..., "billing_period": ..., "records": [TenantUsageRecord, ...]}, indented 2 spaces |
jsonl | application/x-ndjson | One TenantUsageRecord JSON object per line, newline-separated |
csv | text/csv | Header row plus one row per record (column list below) |
CSV columns for an operator, in order:
record_id, tenant_id, tenant_name, job_id, job_status, source_lang, target_lang,
input_tokens, output_tokens, total_tokens, billable_chars, billing_period, created_at, models
models is the record's model_roster joined with commas inside a single quoted CSV field.
For a tenant admin session the four withheld columns are dropped from the header as well as the rows — an empty column would still publish that the ledger exists:
record_id, tenant_id, tenant_name, job_id, job_status, source_lang, target_lang,
billable_chars, billing_period, created_at
Example
curl -sS -G "https://trueidiom.com/api/tenants/9f2c1a7b4e6d0835/billing/export?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY" \
--data-urlencode "billing_period=2026-07" \
--data-urlencode "format=csv" \
-o northwind-2026-07.csv
Response body — format=json
To an operator:
{
"tenant_id": "9f2c1a7b4e6d0835",
"billing_period": "2026-07",
"records": [
{
"id": "3d81f0c94b6e42a7bd15c0e7f2a99b40",
"tenant_id": "9f2c1a7b4e6d0835",
"tenant_name": "Northwind Legal",
"job_id": "b47c2e15a9d84f0c8e33915df6a1c2b0",
"job_status": "published",
"model_roster": ["engine-a", "engine-b"],
"usage": {
"input_tokens": 1842,
"output_tokens": 907,
"total_tokens": 2749
},
"billable_chars": 7168,
"source_lang": "en",
"target_lang": "fr",
"billing_period": "2026-07",
"created_at": "2026-07-14T11:02:47.882104Z"
}
]
}
To a tenant admin session, each record loses the usage object and the model_roster list. The envelope and every other field are identical, and jsonl is redacted the same way, one object per line:
{
"tenant_id": "9f2c1a7b4e6d0835",
"billing_period": "2026-07",
"records": [
{
"id": "3d81f0c94b6e42a7bd15c0e7f2a99b40",
"tenant_id": "9f2c1a7b4e6d0835",
"tenant_name": "Northwind Legal",
"job_id": "b47c2e15a9d84f0c8e33915df6a1c2b0",
"job_status": "published",
"billable_chars": 7168,
"source_lang": "en",
"target_lang": "fr",
"billing_period": "2026-07",
"created_at": "2026-07-14T11:02:47.882104Z"
}
]
}
Errors
| Status | Cause |
|---|---|
401 | No usable operator key or session. |
403 | Authenticated, but not global_administrator. |
404 | An admin session requested a tenant_id other than its own. |
422 | format is not json, jsonl, or csv. |
Unlike the summary endpoint, an unknown tenant_id presented with the operator key exports an empty record set rather than returning 404.
POST /api/billing/checkout
Create a Stripe Checkout Session for one self-serve subscription tier and return its redirect URL.
Auth: tenant API key or tenant access token (see Tenant resolution).
Request body
Content-Type: application/json. Required — the endpoint sells a named tier, and there is no default one.
| Name | Type | Required | Description |
|---|---|---|---|
tier | string | Yes | One of personal, pro, studio. Anything else — including a missing body — is 422. |
The three self-serve tiers, cheapest first (the render order on /billing):
tier | Price | Included per month |
|---|---|---|
personal | $25 / month | 500,000 source characters |
pro | $59 / month | 1,500,000 source characters |
studio | $149 / month | 5,000,000 source characters |
Enterprise is negotiated off-platform and is deliberately not a value here.
Prices are in USD and exclude sales tax. Where it applies, Stripe calculates it from the billing address collected at checkout and adds it to the Checkout total, to each renewal invoice, and to top-up purchases — the character allowances and credits are unaffected.
The session is created in mode=subscription and carries exactly one licensed (flat) price — the tier's — at quantity 1. Licensed rather than metered is the whole point: Stripe charges the month in advance at the start of each billing period, so what the customer owes is settled before a character is translated, and no job can add to it afterwards. Work inside the included allowance carries no per-job charge; past it, the tenant buys a prepaid top-up or hits the 429 hard stop.
The included allowance becomes the tenant's quota, but not from the Checkout event. A Checkout Session payload carries no line items, so the Price is invisible there; the allowance is written when customer.subscription.created or customer.subscription.updated lands (or when the service reconciles a tenant against Stripe directly). That handler maps the subscription's Price back to a tier and writes that tier's characters onto the tenant's monthly_char_limit, mirroring the slug into metadata.stripe_tier. It is also why a plan change made in the Customer Portal moves the allowance: it arrives as an ordinary updated event. A Price that is not one of the three — an enterprise or retired one — maps to no tier, and the tenant's provisioned limit is then left exactly as it is.
On first use the tenant's Stripe customer is created and its id persisted to metadata.stripe_customer_id. The tenant id is stamped on the session (client_reference_id) and on the resulting subscription's metadata so the webhook can map Stripe objects back to the tenant.
Success and cancel URLs are built from the deployment's configured public base URL when set, otherwise from the incoming request's base URL: {base}/billing?checkout=success and {base}/billing?checkout=cancelled.
Example
curl -sS -X POST "https://trueidiom.com/api/billing/checkout?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_TENANT_API_KEY" \
-H "X-Tenant-ID: 9f2c1a7b4e6d0835" \
-H "Content-Type: application/json" \
-d '{"tier": "pro"}'
Response 200
{"url": "https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4e5F6g7H8i9J0kLmN#fidkdWxOYHwnPyd1blpxYHZxWjA0S2NLYVJTfGRqZmFvVGRPSDdiRmB0"}
Send the browser to url — a top-level navigation, not an XHR. Stripe Checkout hosts the payment form; the customer returns to your /billing page with ?checkout=success or ?checkout=cancelled. Treat the query parameter as a UI hint only: the subscription becomes authoritative when the checkout.session.completed webhook lands and mirrors stripe_subscription_status onto the tenant. The tier's allowance arrives separately, on the customer.subscription.created event — so a client polling immediately after the return can briefly see a current subscription against the old monthly_char_limit.
Errors
| Status | Cause |
|---|---|
400 | {"detail": "invalid JSON body: ..."} — a body was sent but does not parse as JSON. |
401 | {"detail": "billing requires an authenticated tenant"} (no tenant resolvable) or {"detail": "missing or invalid tenant credentials"}. |
422 | {"detail": "tier is required — one of personal, pro, studio"} — the body was omitted or empty. |
422 | {"detail": "unknown tier 'basic' — choose one of personal, pro, studio"} — a slug outside the catalog. A body with no tier, or a non-string one, fails schema validation and is 422 as well. A tier is never guessed. |
409 | {"detail": "already subscribed — change plans in the billing portal"} — the tenant already holds a subscription in a current status (re-checked against Stripe before refusing). Checkout cannot change a plan, only add a second parallel subscription billed alongside the first, so plan switches go through POST /api/billing/portal instead. |
503 | {"detail": "billing is not enabled"} — billing is switched off, or Stripe credentials are not configured, on this deployment. |
503 | {"detail": "this plan is not available on this deployment"} — billing is enabled, but this deployment sells no Price for the requested tier. The body is deliberately generic and names no configuration; the operator's side of it goes to the server log. The other tiers stay purchasable. |
POST /api/billing/topup
Create a one-time payment Checkout Session for prepaid character packs and return its redirect URL. Top-ups are how a subscriber keeps working after hitting the monthly allowance; they are prepaid only, and the card is never stored for off-session charges.
Auth: tenant API key or tenant access token. The tenant must already hold a current subscription.
Request body
Content-Type: application/json — schema TopupPurchaseRequest. The body may be omitted entirely (or sent empty), which buys one pack.
| Name | Type | Required | Description |
|---|---|---|---|
packs | integer | No | How many packs to buy in this payment. Default 1, minimum 1, and at most 5 per purchase. Each pack is 1000000 source characters. |
Checks, in order
Every check runs before the one below it, and the first failure is what you get back. The order is deliberate.
- Billing disabled →
503. - Top-ups not on sale on this deployment →
503. There is nothing to charge against; the/billingpage hides the top-up section on the same condition. - No tenant resolvable →
401. - Velocity →
429. Two independent budgets — one keyed on client IP, one on tenant id — of5attempts per3600-second window. Both are consumed on every attempt, with no short-circuit: an attacker rotating IPs still burns the tenant budget, and one rotating tenants still burns the IP budget. This is the card-testing bound, so it is deliberately independent of the auth rate-limit switch — turning off login throttling must never unthrottle purchases — and it is consumed before any Stripe call, which is what makes it a bound at all. - Not subscription-current →
402. Subscribers only: a card must have survived subscription Checkout before it can reach the small prepaid packs card-testers target, so a trial tenant can never reach this Price. As with the translation gate, Stripe is re-checked directly before denying. packsabove the per-purchase maximum →400;packsbelow1→422(schema validation).topup_chars_remaining + packs × 1000000above the10000000ceiling →409. The ceiling is on the outstanding balance, not on lifetime purchases: it bounds the dollars one stolen card can move in a burst, and keeps the balance spendable inside the monthly cap the quota gate enforces.
Purchase shape
The Session is created with mode=payment, carries client_reference_id plus metadata.tenant_id and metadata.topup_chars (mirrored onto the PaymentIntent for Dashboard and dispute views), and requests 3D Secure wherever the card supports it (payment_method_options.card.request_three_d_secure = "any"). It deliberately does not set setup_future_usage: the card is never saved for automatic charges, which is what makes the "never auto-billed" promise structural rather than a policy. Stripe Radar rules are Dashboard configuration, not part of this request.
Success and cancel URLs follow the checkout convention: {base}/billing?topup=success and {base}/billing?topup=cancelled.
Example
curl -sS -X POST "https://trueidiom.com/api/billing/topup?api-version=2026-09-01" \
-H "Authorization: Bearer $TRUEIDIOM_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"packs": 3}'
Response 200
{"url": "https://checkout.stripe.com/c/pay/cs_test_b2C3d4E5f6G7h8I9j0KlMn"}
Send the browser to url as a top-level navigation. Nothing is credited at this point. The characters land only when the checkout.session.completed webhook arrives with payment_status: "paid" — or, for a delayed payment method, when checkout.session.async_payment_succeeded does. Treat the ?topup=success return as a UI hint and re-read the balance instead: metadata.topup_chars_purchased minus metadata.topup_chars_used on GET /api/tenants, which the /billing page renders as the prepaid balance.
Errors
| Status | Cause |
|---|---|
400 | {"detail": "at most 5 packs per purchase"} — above the per-purchase maximum. |
400 | {"detail": "invalid JSON body: ..."} — the body was present but unparseable. |
401 | {"detail": "billing requires an authenticated tenant"} or {"detail": "missing or invalid tenant credentials"}. |
402 | {"detail": "top-ups require an active subscription — subscribe at /billing first"}. |
409 | {"detail": "this purchase would exceed the monthly character ceiling — use your current balance first"}. |
422 | packs below 1, or not an integer. The detail is a plain string carrying the raw validation message — not the HTTPValidationError array. See Error reference. |
429 | {"detail": "too many top-up attempts — slow down and retry later"}, with a Retry-After header carrying the window in seconds. |
503 | {"detail": "billing is not enabled"}. |
503 | {"detail": "top-ups are not configured"} — top-ups are not on sale on this deployment. |
POST /api/billing/portal
Create a Stripe Customer Portal session so a tenant can manage its own subscription, payment method, and invoices.
Auth: tenant API key or tenant access token. No request body, no parameters.
Unlike checkout, this endpoint does not create a Stripe customer. It requires that the tenant already has metadata.stripe_customer_id, which is set the first time POST /api/billing/checkout or POST /api/billing/topup runs — whichever comes first.
The portal return URL is {base}/billing, with {base} taken from the deployment's configured public base URL when set, otherwise the incoming request's base URL.
Example
curl -sS -X POST "https://trueidiom.com/api/billing/portal?api-version=2026-09-01" \
-H "Authorization: Bearer $TRUEIDIOM_ACCESS_TOKEN"
Response 200
{"url": "https://billing.stripe.com/p/session/live_YWNjdF8xUXBaOGtSMmVadktZbG8y_test_ZmFrZS1zZXNzaW9uLWlk"}
Redirect the browser to url. Portal sessions are single-use and short-lived — create one per click rather than caching it.
Errors
| Status | Cause |
|---|---|
401 | {"detail": "billing requires an authenticated tenant"} or {"detail": "missing or invalid tenant credentials"}. |
409 | {"detail": "no Stripe customer for this tenant yet — subscribe first"} — run checkout first. |
503 | {"detail": "billing is not enabled"}. |
POST /api/stripe/webhook
Receiver for Stripe events. Integrators do not call this endpoint. It exists so Stripe can notify TrueIdiom of subscription lifecycle changes; point a Stripe webhook endpoint at https://trueidiom.com/api/stripe/webhook and configure its signing secret on the deployment.
Signature verification
Every request is verified against the configured signing secret using the raw request bytes and the Stripe-Signature header, before any field of the payload is read. A missing, malformed, tampered, or expired signature is rejected with 400. If no signing secret is configured, the endpoint returns 503.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
Stripe-Signature | string | Yes | Timestamped HMAC produced by Stripe. |
Handled events
| Event type | Effect on TenantAccount.metadata |
|---|---|
checkout.session.completed, mode: "subscription" | Sets stripe_subscription_status to active, and stripe_subscription_id when the session carries one. Nothing else — a Checkout payload carries no line items, so the tier and its allowance are not visible here and are left to the customer.subscription.* events below. Tenant resolved from client_reference_id. |
checkout.session.completed, mode: "payment" | Credits a prepaid top-up — only when payment_status is paid. See below. |
checkout.session.completed, no mode | Treated as a subscription session when it carries a subscription id (sessions predating the mode branch); otherwise unclassifiable, logged, and nothing is written. The gate is never opened on a guess. |
checkout.session.async_payment_succeeded | Credits a prepaid top-up. This event is the payment confirmation for delayed payment methods, so it carries no payment_status precondition of its own. |
checkout.session.async_payment_failed | Logged. Nothing is credited. |
customer.subscription.created | Sets stripe_subscription_id and stripe_subscription_status from the subscription. This is where the allowance is decided: it is the only payload the Price is visible in, so it resolves the Price to a tier and writes that tier's included characters onto monthly_char_limit (mirroring the slug into stripe_tier). An unrecognized Price leaves the allowance untouched. Tenant resolved from metadata.tenant_id. |
customer.subscription.updated | Same as created. |
customer.subscription.deleted | Sets stripe_subscription_status to canceled. |
invoice.payment_failed | Logged. Dunning and retries are left to Stripe. |
Any other event type is accepted and ignored.
The top-up credit branch
Completed Checkout Sessions arrive on one event type for two products, so the handler branches on mode. The branch is load-bearing: crediting a top-up as a subscription would open the subscription gate — and a whole tier's monthly allowance — for the price of one prepaid pack, and the reverse would drop characters the customer paid for. A mode: "payment" session never writes stripe_subscription_status or stripe_subscription_id — buying characters does not open the subscription gate.
Crediting adds to metadata.topup_chars_purchased and appends the Checkout Session id to metadata.topup_credited_sessions:
- The tenant comes from
client_reference_id, falling back tometadata.tenant_idon the session. An unresolvable or unknown tenant is logged and skipped. - The character count comes from
metadata.topup_chars, which the server stamped when it created the Session (packs × 1000000) and which reaches the handler inside a signature-verified payload. It is never taken from anything the buyer supplies. A missing or non-positive value is logged and skipped. - Redelivery is expected. Stripe retries and replays events, so crediting is keyed on the Session id: a redelivered
checkout.session.completedfor a Session already intopup_credited_sessionsis a no-op. That ledger is a bounded FIFO (the 20 most recent ids), so tenant metadata cannot grow without limit. - Completed is not paid. Delayed payment methods complete the Session first and settle later, so an unpaid completion credits nothing; the later
async_payment_succeededdoes the crediting.
Response 200
{"received": true}
Every verified event returns 200 — including unhandled types — so Stripe does not retry.
Errors
| Status | Cause |
|---|---|
400 | {"detail": "invalid webhook signature"} — signature verification failed or the body was malformed. |
503 | {"detail": "billing is not enabled"} — billing is switched off, or Stripe credentials are not configured. |
503 | {"detail": "billing webhooks are not configured"} — no webhook signing secret is configured on this deployment. The body names no configuration; the operator's side of it goes to the server log. |
Schemas
TenantAccountCreate
Request body for POST /api/tenants.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | Yes | — | |
monthly_char_limit | integer | null | No | null | Minimum 1; null inherits the server default. Not clamped by the top-up ceiling on this operator route. |
metadata | object | No | {} | Arbitrary JSON, written verbatim — including the seven platform-owned namespaces (stripe_ / trial_ / topup_ / mfa_ / sso_ / invites_ / storage_) that self-serve signup strips. |
SubscriptionCheckoutRequest
Request body for POST /api/billing/checkout. Required — every tier is a different monthly charge, so there is no default one to fall back to.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
tier | string | Yes | — | Non-empty. The slug is checked against the tier catalog in the route rather than pinned as an enum in the schema, so the catalog stays the one source of truth; an unknown slug is a 422 from the route. |
TopupPurchaseRequest
Request body for POST /api/billing/topup. The whole body is optional; omitting it buys one pack.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
packs | integer | No | 1 | Minimum 1 (schema). The upper bound (5) lives in the route rather than the schema, because it is a deployment setting rather than a schema constant — so exceeding it is a 400, not a 422. |
TenantAccount
Returned by GET /api/tenants and nested under account in the create response.
| Field | Type | Default | Notes |
|---|---|---|---|
id | string | generated | 16-character hex id. |
name | string | — | Required in the schema. |
api_key_prefix | string | — | Required in the schema. First 8 characters of the tenant API key. |
monthly_char_limit | integer | 500000 | Minimum 1. |
active | boolean | true | Inactive accounts fail tenant authentication. |
created_at | string (date-time) | generated | UTC. |
metadata | object | {} | See below. |
Keys the platform writes into metadata:
| Key | Type | Written by |
|---|---|---|
trial_chars_granted | integer | Tenant creation, where a free-trial grant is configured. Stamped as 0 when the per-IP grant budget is spent. |
trial_chars_used | integer | Job accounting, for tenants without a current subscription. |
topup_chars_purchased | integer | Stripe webhook, on a verified paid top-up payment. Counts up only. |
topup_chars_used | integer | Job accounting, for a subscription-current tenant past its monthly limit. Counts up only; remaining is purchased - used, floored at zero. |
topup_credited_sessions | string[] | Stripe webhook. The redelivery ledger — Checkout Session ids already credited, bounded to the 20 most recent. |
stripe_customer_id | string | First POST /api/billing/checkout or POST /api/billing/topup. |
stripe_subscription_id | string | Stripe webhook / direct reconciliation. |
stripe_subscription_status | string | Stripe webhook / direct reconciliation. active and trialing permit translation work. |
stripe_tier | string | Stripe webhook / direct reconciliation. Slug of the self-serve tier the subscription is on (personal, pro, studio); the allowance it implies is written to monthly_char_limit, which is what the quota gate reads. Absent for an enterprise or otherwise unrecognized Price. |
mfa_required | boolean | PATCH /api/tenants/security only. Every member must present MFA to sign in. |
mfa_required_for_sso | boolean | PATCH /api/tenants/security only. Extends mfa_required to completed Google sign-ins, which are otherwise exempt. |
All seven namespaces — stripe_, trial_, topup_, mfa_, sso_, invites_, storage_ — are platform-owned: self-serve signup cannot write them (see Protected metadata on self-serve signup).
TenantUsageRecord
One completed translation job's usage. Appears in the export payloads. Not referenced by any response schema in openapi.json, because the routes that serve it return raw file bodies.
| Field | Type | Default | Notes |
|---|---|---|---|
id | string | generated | 32-character hex record id. |
tenant_id | string | — | |
tenant_name | string | — | Denormalized at write time. |
job_id | string | — | Unique per record; re-recording the same job is a no-op. |
job_status | JobStatus | — | One of pending, translated, refined, awaiting_review, approved, human_edited, rejected, published, failed, persistence_failed. |
model_roster | string[] | [] | Operator only. Deduplicated list of engine identifiers recorded for the job, in first-seen order. |
usage | TokenUsage | — | Operator only. See below. |
billable_chars | integer | 0 | Source characters billed for the job — the customer-facing quantity, weighted by language pair (so it will exceed the raw source length on a dense-script pair). Records written before character metering carry 0. |
source_lang | string | — | |
target_lang | string | — | |
billing_period | string | — | YYYY-MM, from the job's updated_at. |
created_at | string (date-time) | generated | UTC; drives the usage/daily buckets. |
The two operator-only fields are omitted entirely from a tenant admin's export, in every format.
TenantUsageSummary
Response body of GET /api/tenants/{tenant_id}/billing. Field table is in that section, including which of its fields are operator-only. Not referenced by any response schema in openapi.json — that route is declared as returning an untyped object.
TokenUsage
| Field | Type | Default |
|---|---|---|
input_tokens | integer | 0 |
output_tokens | integer | 0 |
total_tokens | integer | 0 |
Error reference
The canonical error catalog — body shapes, the full error_code vocabulary, and status-code semantics — is in Conventions & errors. Below is what these endpoints emit.
| Status | Where | detail |
|---|---|---|
400 | usage/daily | since must be on or before until |
400 | usage/daily | date range must not exceed 366 days |
400 | stripe/webhook | invalid webhook signature |
400 | /api/billing/topup | at most {n} packs per purchase |
400 | /api/billing/checkout, /api/billing/topup | invalid JSON body: ... |
401 | /api/tenants* | admin credentials required |
401 | /api/billing/* | billing requires an authenticated tenant |
401 | /api/billing/* | missing or invalid tenant credentials |
402 | translation endpoints | your free trial is used up — add a payment method at /billing to continue |
402 | translation endpoints | your free trial has ended — add a payment method at /billing to continue |
402 | translation endpoints | this request exceeds your remaining free trial characters — add a payment method at /billing |
402 | translation endpoints | the free trial covers the workspace only — API access requires an active subscription; subscribe at /billing |
402 | translation endpoints | an active subscription is required to run translations — add a payment method at /billing |
402 | /api/billing/topup | top-ups require an active subscription — subscribe at /billing first |
403 | /api/tenants* | admin role required |
404 | {tenant_id}/billing, {tenant_id}/billing/export | tenant not found |
409 | /api/billing/portal | no Stripe customer for this tenant yet — subscribe first |
409 | /api/billing/topup | this purchase would exceed the monthly character ceiling — use your current balance first |
413 | any | request body exceeds MAX_UPLOAD_BYTES (enforced by middleware over the whole request stream) |
422 | /api/billing/checkout | tier is required — one of personal, pro, studio, or unknown tier '...' — choose one of personal, pro, studio, or the raw validation message for a missing/non-string tier — all plain strings |
422 | /api/billing/topup | the raw validation message for a packs below 1 or of the wrong type — a plain string, not the array shape |
429 | translation endpoints | tenant usage limit exceeded; for a billing-current tenant, suffixed — buy a prepaid top-up on /billing to keep translating this month; switching plans there raises your allowance from the next billing period when top-ups are configured, or — switch to a larger plan on /billing to raise your allowance from the next billing period when they are not |
429 | /api/billing/topup | too many top-up attempts — slow down and retry later (with Retry-After) |
503 | /api/billing/*, stripe/webhook | billing is not enabled |
503 | /api/billing/topup | top-ups are not configured |
503 | /api/billing/checkout | this plan is not available on this deployment — no Price configured for the requested tier |
503 | stripe/webhook | billing webhooks are not configured — no signing secret configured |
Every 422 from a billing request body is a plain string. The two checkout tier refusals above are thrown by the route; so is a missing, unknown, or non-string tier, and so is an out-of-range packs — those two carry the raw validation message inside a string detail, because the route validates the body itself rather than declaring it as a parameter. Only path and query validation on this page produces FastAPI's HTTPValidationError array shape:
{
"detail": [
{
"loc": ["query", "format"],
"msg": "String should match pattern '^(json|jsonl|csv)$'",
"type": "string_pattern_mismatch",
"input": "xlsx"
}
]
}
Related browser pages
These serve HTML, not JSON, and are listed only because the billing flow references them: /billing is the page hosting the three-tier plan chooser (one subscribe/switch button per tier, the current one marked) and the Manage-billing button — and the target of the Checkout return URLs — plus the top-up section — prepaid balance, pack selector, buy button — which renders only for a subscriber where top-ups are on sale. /app contains the usage dashboard that consumes GET /api/tenants/usage/daily and GET /api/tenants/{tenant_id}/billing, and, during the free trial, the scripted-onboarding card.