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 and a designated operator session (see Admin gate behavior) are the only credentials that widen 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, when the tenant is created — by self-serve signup or by POST /api/tenants. Identifies a single tenant. |
Per-endpoint requirements:
| Endpoint | Required credential | Scope |
|---|---|---|
POST /api/tenants |
Operator API key or a designated operator session | Operator-only: an ordinary tenant admin session gets 403 |
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 of a designated platform operator — an address on the deployment's operator list, signed in to the deployment's designated operator workspace, 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 (with either the address list or the operator workspace unset, nobody is designated), and every half is re-checked on every request — see Authentication → Platform operators.
POST /api/tenants admits only a platform operator: any other admin session passes the gate above and is then refused with 403 {"detail": "tenant provisioning is operator-only"}.
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.
Only delivered work is recorded. A text job that ends failed produces no record, and neither does a layout document job that fails — unless its translated file had already been written to the tenant's own storage: that job is billed like any delivered translation, and its record carries job_status: "failed". A text job parked as persistence_failed is billed: its translation is complete and readable through GET /api/translations/{job_id}, even though the submission itself answered with an error.
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, or a PDF, DOCX or XLSX uploaded to POST /api/translations). A document job is metered asynchronously, on completion — its character count is not knowable until extraction has run, which is why its quota check happens in two steps, at submission and again after extraction (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, Hindi, Marathi, or Nepali, 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. A batch is weighed input by input — each input's ownLanguageagainst each of itsTargets— exactly as its jobs will be billed. The one unweighted figure is a document job's ownbillable_charsfield, which counts the raw source characters of its translated segments; the usage record that job produces — and every total above — is weighted. - 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 committed + request against the month's allowance. committed is the usage already recorded in the allowance window (below) plus everything admitted but not yet recorded: the tenant's other text requests still running, and the characters reserved by its in-flight document jobs. Concurrent submissions from one tenant are admitted one after another, each counting the others, so a burst cannot all pass against the same snapshot — and a text request can be refused while a large document is still running. If the request would exceed the allowance, the translation endpoint returns:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{"detail": "tenant usage limit exceeded"}
The count covers the current UTC calendar month (YYYY-MM) and resets when it rolls over. Within the month it starts at the later of the month's start and the moment the tenant's current plan took effect — when a subscription first became current, or when its tier last changed (metadata.stripe_allowance_from). So free-trial usage does not eat into the first paid month, and characters used under a previous tier do not count against the new one — neither for this check nor for the prepaid draw-down below. 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 the month's allowance window 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.
- The ceiling of
10000000characters bounds the top-up headroom — and, for a subscriber whosemonthly_char_limitis below it, the whole calendar month as well: whatever the allowance window does, the month's recorded usage, plus everything in flight and the request, may not pass the ceiling — so a plan change restarts the allowance but never the ceiling. An operator-raisedmonthly_char_limitat or above the ceiling — an enterprise account provisioned throughPOST /api/tenants— is not clamped down to it; it simply gets no headroom on top, and no top-up can be bought for it. - 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. Five exact detail strings, chosen from the tenant's billing state:
| Condition | detail |
|---|---|
Billing current, monthly_char_limit below the ceiling, and the calendar month's usage plus everything in flight and this request would pass the ceiling |
tenant usage limit exceeded — this month's character ceiling is reached; contact us at /contact to raise your allowance |
Billing current, monthly_char_limit below the ceiling, and a top-up Price is configured |
tenant usage limit exceeded — buy a prepaid top-up on /billing to keep translating this month; upgrading your plan there raises your allowance immediately (the new allowance counts from the upgrade; a downgrade applies at renewal) |
Billing current, monthly_char_limit below the ceiling, no top-up Price configured |
tenant usage limit exceeded — upgrade to a larger plan on /billing to raise your allowance immediately (the new allowance counts from the upgrade; a downgrade applies at renewal) |
Billing current, monthly_char_limit at or above the ceiling (an operator-provisioned enterprise allowance) |
tenant usage limit exceeded — contact us at /contact to raise your allowance |
| Anything else (trial, no subscription, billing disabled) | tenant usage limit exceeded |
The first row wins over the two after it: at the ceiling, nothing bought on /billing can be spent this month. The top-up prompt is omitted where top-ups are not on sale, since POST /api/billing/topup would answer 503; the upgrade prompt is omitted for a tenant with no subscription to change; and an allowance at or above the ceiling is offered neither, since no pack and no self-serve plan can raise it.
The two remedies do not work the same way, which is why the second string names both. A prepaid top-up is credited as soon as its payment is confirmed, so it raises the allowance within the current month and clears the refusal immediately. A plan change goes through the Customer Portal. An upgrade applies as soon as it is made, and the prorated difference for the rest of the current billing period is charged at once; a downgrade is scheduled for the end of the current billing period and lands at renewal. Either way the new tier's monthly_char_limit is written when the resulting subscription event arrives, and the allowance window restarts at that moment — the new allowance counts only usage from then on. A client surfacing this 429 can therefore offer a top-up or an upgrade as the fix for now: the top-up for this month, the upgrade for this month and every month after it.
Layout document jobs (POST /api/documents/translate, or a PDF, DOCX or XLSX uploaded to POST /api/translations) cannot know their character count before extraction, so they are admitted in two steps. At submission the gate asks only for one character of headroom, counting everything in flight; with none left, the submission is refused with the same 429. Once extraction has counted the document's characters, they are reserved against the month and the job is admitted only if the month still has headroom — counting recorded usage, text requests in flight and the tenant's other in-flight documents. A document that straddles the remaining headroom is admitted whole; with no headroom left the job fails with failure.code: "quota_exceeded" at stage admit, before any translation, and nothing is billed (a wait=true submission gets 429 with error_code: "quota_exceeded"). See Documents.
Prepaid top-up draw-down. For a subscription-current tenant, each recorded job is charged to exactly one ledger, judged by the usage in the month's allowance window:
| 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 — never more than the balance that remains |
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. A job bigger than the balance left — in practice a layout document, whose size is unknown at submission — draws only what remains, taking the balance to zero; the overflow is absorbed rather than carried as a debt, so the balance never goes negative and the next pack bought is spendable in full. The third row is normally unreachable: the gate refuses in advance any submission the allowance plus the balance cannot cover, and because it counts every request admitted but not yet recorded, concurrent jobs cannot race past it.
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 request — together with the tenant's other text requests still in flight — 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 |
A layout document (PDF, DOCX, XLSX) submitted while the trial is still live — the trial covers text only (on POST /api/translations, an API key meets the row above first) |
document translation (PDF, DOCX, XLSX) is not included in the free trial — it requires an active subscription; subscribe at /billing |
No trial grant and no current subscription (also: a layout document on POST /api/documents/translate once the trial has ended) |
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. A signup whose email or password the policy rejects is refused before the gate runs, so it creates no tenant and spends none of the IP's grants. 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 nine platform-owned prefixes — stripe_, trial_, topup_, mfa_, sso_, invites_, storage_, google, or azuread — 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 route that adds to it.
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.
google and azuread are the organization-identity keys, which sign-in trusts as verified identity-provider claims: a new user from a Google Workspace domain is mapped to the tenant whose google.hd names it (azuread is the equivalent key for Microsoft organizations, reserved the same way). A signup arriving with {"google": {"hd": "example.com"}} would have every new Workspace user from that domain auto-joined into the signer's tenant, so outside operator provisioning only sign-in itself writes these keys, when it founds a tenant from a claim it has just verified; and should two tenants ever carry the same domain, the earliest-created one wins. These two prefixes have no trailing underscore, so any key that merely begins with those letters — googleDriveFolder, say — is dropped as well.
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 — the429againstmonthly_char_limitalone, since the prepaid-balance term and the calendar-month ceiling apply only to a subscriber, which needs billing.
POST /api/tenants
Create a tenant account and mint its API key.
Auth: operator only — the operator API key, or a designated operator session (see Admin gate behavior).
Tenant provisioning is operator-only.
global_administratoris a per-tenant role that every self-serve founder holds for their own tenant, and this route writes platform-owned metadata and an unclampedmonthly_char_limitverbatim — so a tenant admin session passes the admin gate and is then refused with403 {"detail": "tenant provisioning is operator-only"}.Self-service signup (
POST /api/auth/signup/email) creates a tenant with no credentials at all and is the path most integrators want — and the only one open to anyone who is not a platform operator; this endpoint exists for operator-driven provisioning.The two paths are not equivalent on
metadataor onmonthly_char_limit. Self-serve signup strips the nine platform-owned prefixes (stripe_/trial_/topup_/mfa_/sso_/invites_/storage_/azuread) 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 | Monthly character allowance 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": 150000
}
},
"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. |
403 |
{"detail": "tenant provisioning is operator-only"} — a global_administrator session that is not a platform operator. |
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. Pending invites in metadata.invites_pending are returned without their token hashes, to either caller.
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": 150000,
"trial_chars_used": 150000,
"stripe_customer_id": "cus_QjT2mP4wLbXk9A",
"stripe_subscription_id": "sub_1QpZ8kR2eZvKYlo2CqTr4Vd7",
"stripe_subscription_status": "active",
"stripe_allowance_from": "2026-07-28T16:40:11.204877+00:00",
"stripe_allowance_subscription": "sub_1QpZ8kR2eZvKYlo2CqTr4Vd7",
"stripe_subscription_event_at": 1785256809
}
}
]
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 — a designated operator session, which acts on the workspace it is signed into unless it names another with ?tenant_id, 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.
Apart from operator provisioning (POST /api/tenants), this route is the only writer of the mfa_ and sso_ tenant-metadata namespaces: like stripe_, trial_, topup_, invites_, storage_, google, and azuread, 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 — a designated operator session, which acts on the workspace it is signed into unless it names another 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, email is not a valid address, or label is longer than 64 characters. |
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 (never earlier than 0001-01-01). |
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"
import os
import requests
resp = requests.get(
"https://trueidiom.com/api/tenants/usage/daily?api-version=2026-09-01",
headers={"X-API-Key": os.environ["TRUEIDIOM_OPERATOR_KEY"]},
params={
"since": "2026-07-01",
"until": "2026-07-26",
"tenant_id": "9f2c1a7b4e6d0835",
},
timeout=60,
)
resp.raise_for_status()
for bucket in resp.json():
print(bucket["day"], bucket["billable_chars"])
// Node 18+
const url = new URL("https://trueidiom.com/api/tenants/usage/daily?api-version=2026-09-01");
url.searchParams.set("since", "2026-07-01");
url.searchParams.set("until", "2026-07-26");
url.searchParams.set("tenant_id", "9f2c1a7b4e6d0835");
const res = await fetch(url, {
headers: { "X-API-Key": process.env.TRUEIDIOM_OPERATOR_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
for (const bucket of await res.json()) {
console.log(bucket.day, bucket.billable_chars);
}
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"
import os
import requests
resp = requests.get(
"https://trueidiom.com/api/tenants/9f2c1a7b4e6d0835/billing?api-version=2026-09-01",
headers={"Authorization": f"Bearer {os.environ['TRUEIDIOM_ACCESS_TOKEN']}"},
params={"billing_period": "2026-07"},
timeout=60,
)
resp.raise_for_status()
summary = resp.json()
print(summary["billing_period"], summary["monthly_char_limit"])
// Node 18+
const url = new URL(
"https://trueidiom.com/api/tenants/9f2c1a7b4e6d0835/billing?api-version=2026-09-01",
);
url.searchParams.set("billing_period", "2026-07");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.TRUEIDIOM_ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const summary = await res.json();
console.log(summary.billing_period, summary.monthly_char_limit);
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 monthly character allowance as it stands now (the same value whichever period is asked for). |
input_tokens |
integer | Operator only. Sum of usage.input_tokens over all the period's records. Default 0. |
output_tokens |
integer | Operator only. Sum of usage.output_tokens over all the period's records. Default 0. |
total_tokens |
integer | Operator only. Sum of usage.total_tokens over all the period's records. Default 0. |
billable_chars |
integer | Source characters billed in the period's allowance window (see below), 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 counted in billable_chars. Default 0. |
The allowance window. For the period in which the tenant's current allowance took effect (metadata.stripe_allowance_from — a subscription starting, or a tier change; see Quotas), billable_chars, remaining_chars, over_limit and billable_records count only records created from that moment on, exactly as the quota gate does — so they can be lower than the sum of that period's ledger export. Every other period counts all of its records. The operator-only token totals always cover the whole period.
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 — echoed as given — or the current UTC month. When that name holds anything outside printable ASCII, or a ", \, % or /, the plain filename= carries _ in place of each such character and a filename*=UTF-8''… parameter carries the exact name, percent-encoded (RFC 6266).
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
import os
import requests
resp = requests.get(
"https://trueidiom.com/api/tenants/9f2c1a7b4e6d0835/billing/export?api-version=2026-09-01",
headers={"X-API-Key": os.environ["TRUEIDIOM_OPERATOR_KEY"]},
params={"billing_period": "2026-07", "format": "csv"},
timeout=300,
)
resp.raise_for_status()
with open("northwind-2026-07.csv", "wb") as handle:
handle.write(resp.content)
// Node 18+
import { writeFile } from "node:fs/promises";
const url = new URL(
"https://trueidiom.com/api/tenants/9f2c1a7b4e6d0835/billing/export?api-version=2026-09-01",
);
url.searchParams.set("billing_period", "2026-07");
url.searchParams.set("format", "csv");
const res = await fetch(url, {
headers: { "X-API-Key": process.env.TRUEIDIOM_OPERATOR_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await writeFile("northwind-2026-07.csv", Buffer.from(await res.arrayBuffer()));
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.
In the month a subscription starts, its allowance counts from the moment the subscription becomes current rather than from the start of the month: that moment opens the tenant's allowance window (metadata.stripe_allowance_from), so characters used earlier in the month — during the free trial, say — do not count against the tier just paid for. A later tier change restarts the window; see Quotas.
On first use the tenant's Stripe customer is created and its id persisted to metadata.stripe_customer_id (and replaced by a fresh one if the stored customer no longer exists on Stripe). 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.
Before the new session is created, any subscription Checkout Session the customer still has open is expired (up to five per call — normally there is at most one, since each new Checkout expires the one before it). So only the newest link can be paid, and a stale Checkout tab cannot be completed into a second subscription billed alongside the first. The clean-up is best-effort — a Stripe error during it never blocks subscribing — and top-up Checkout Sessions are left alone.
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"}'
import os
import requests
resp = requests.post(
"https://trueidiom.com/api/billing/checkout?api-version=2026-09-01",
headers={
"X-API-Key": os.environ["TRUEIDIOM_TENANT_API_KEY"],
"X-Tenant-ID": "9f2c1a7b4e6d0835",
},
json={"tier": "pro"},
timeout=60,
)
resp.raise_for_status()
print(resp.json()["url"]) # send the browser here as a top-level navigation
// Node 18+
const res = await fetch("https://trueidiom.com/api/billing/checkout?api-version=2026-09-01", {
method: "POST",
headers: {
"X-API-Key": process.env.TRUEIDIOM_TENANT_API_KEY,
"X-Tenant-ID": "9f2c1a7b4e6d0835",
"Content-Type": "application/json",
},
body: JSON.stringify({ tier: "pro" }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { url } = await res.json();
console.log(url); // send the browser here as a top-level navigation
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. |
409 |
{"detail": "your subscription needs attention — fix payment or change plans in the billing portal"} — the tenant's subscription is lapsed but still live on Stripe (past_due, unpaid, incomplete, paused). Stripe is still retrying that card, so a second Checkout would stack a parallel subscription just as it would for a paying tenant; the card is fixed in POST /api/billing/portal. A subscription that has ENDED (canceled, incomplete_expired) is not refused — the Portal cannot reactivate one, so Checkout is the only way to subscribe again. |
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. monthly_char_limitalready at or above the10000000ceiling →409. Top-ups are only ever spent in the headroom between the allowance and the ceiling, and an allowance at the ceiling — an operator-provisioned enterprise one — has none, so a pack sold to it could never be spent. The/billingpage hides the top-up section on the same condition.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}'
import os
import requests
resp = requests.post(
"https://trueidiom.com/api/billing/topup?api-version=2026-09-01",
headers={"Authorization": f"Bearer {os.environ['TRUEIDIOM_ACCESS_TOKEN']}"},
json={"packs": 3},
timeout=60,
)
resp.raise_for_status()
print(resp.json()["url"]) # send the browser here as a top-level navigation
// Node 18+
const res = await fetch("https://trueidiom.com/api/billing/topup?api-version=2026-09-01", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TRUEIDIOM_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ packs: 3 }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { url } = await res.json();
console.log(url); // send the browser here as a top-level navigation
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": "your allowance already reaches the monthly character ceiling, so top-ups cannot add to it — contact us at /contact to raise your allowance"} — monthly_char_limit is at or above the ceiling. |
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"} — no customer is recorded yet, or the recorded one no longer exists on Stripe (the stale id is then cleared). 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; if that makes the subscription current for the first time, it also opens the allowance window (stripe_allowance_from, stripe_allowance_subscription). 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. Ignored for a subscription already mirrored as ended. 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, records the event's Stripe created time in stripe_subscription_event_at, and opens the allowance window if this makes the subscription current for the first time. 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); a change from one tier to another also restarts the allowance window. 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 and records the deleted id as stripe_subscription_id, so later events for that subscription are ignored. Ignored itself when the tenant's mirrored subscription is a different one that is still live (a replacement). The allowance is left as it is. Tenant resolved from metadata.tenant_id. |
invoice.payment_failed |
Logged. Dunning and retries are left to Stripe. |
Any other event type is accepted and ignored.
Stripe does not deliver events in order, and retries a failed delivery for days with its original snapshot, so the subscription mirror refuses to go backwards:
- A
customer.subscription.createdorcustomer.subscription.updatedevent older, by Stripe'screated, than the newest subscription event already applied changes nothing — neither status nor tier. - Once a subscription id is mirrored as ended (
canceled,incomplete_expired), nothing revives it: its creation, update or Checkout completion arriving late is ignored. Subscribing again creates a new id. - An
incompletestatus for a subscription the mirror already shows pastincomplete(active,trialing,past_due,unpaid,paused) is a stale retry and is ignored. - A deletion is final for its id and is never set aside as out of date.
The allowance window opens when a status write makes the tenant current and it was not before — a first subscription, one that finally paid after incomplete, or a new one after a cancellation. Recovering from past_due, unpaid or paused resumes the same window, and a subscription opens its window only once.
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 nine platform-owned namespaces (stripe_ / trial_ / topup_ / mfa_ / sso_ / invites_ / storage_ / google / azuread) 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 include:
| 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, and a job never draws more than the balance left; 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 — replaced (or, by POST /api/billing/portal, cleared) when the stored customer no longer exists on Stripe. |
stripe_subscription_id |
string | Stripe webhook / direct reconciliation. After a customer.subscription.deleted, the deleted subscription's id. |
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. |
stripe_subscription_event_at |
integer | Stripe webhook. Stripe's created time (Unix seconds) of the newest customer.subscription.* event applied; an older one arriving later changes nothing. |
stripe_allowance_from |
string (date-time) |
Stripe webhook / direct reconciliation. When the current allowance took effect — stamped when a subscription first becomes current and when its tier changes. The allowance counts usage from the later of this moment and the start of the UTC month. |
stripe_allowance_subscription |
string | Stripe webhook / direct reconciliation. The subscription id that opened that allowance window; a subscription opens it only once. |
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. |
sso_auto_join |
boolean | PATCH /api/tenants/security only. false closes the Google Workspace domain to new members without an invite; absent means true. |
invites_pending |
object[] | POST /api/tenants/invites, DELETE /api/tenants/invites/{invite_id}, and redemption. The pending-invite ledger; returned without token hashes. |
google |
object | Google sign-in, when it founds a tenant for a verified Workspace account: {"hd": "<domain>"}, the domain new colleagues are mapped by. |
All nine namespaces — stripe_, trial_, topup_, mfa_, sso_, invites_, storage_, google, azuread — are platform-owned: self-serve signup cannot write them (see Protected metadata on self-serve signup).
TenantUsageRecord
One metered translation job's usage (which jobs are metered: Usage accounting model). 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, and decides whether the record falls inside its period's allowance window. |
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: ... |
400 |
/api/tenants/security, /api/tenants/invites* |
tenant_id query parameter is required with API-key access |
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 |
layout-document submissions | document translation (PDF, DOCX, XLSX) is not included in the free trial — it 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 |
403 |
POST /api/tenants |
tenant provisioning is operator-only |
404 |
{tenant_id}/billing, {tenant_id}/billing/export, /api/tenants/security, /api/tenants/invites* |
tenant not found |
404 |
DELETE /api/tenants/invites/{invite_id} |
invite not found |
409 |
POST /api/tenants/invites |
this tenant already has 20 pending invites — revoke one before minting another |
409 |
/api/billing/portal |
no Stripe customer for this tenant yet — subscribe first |
409 |
/api/billing/topup |
your allowance already reaches the monthly character ceiling, so top-ups cannot add to it — contact us at /contact to raise your allowance |
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 — this month's character ceiling is reached; contact us at /contact to raise your allowance when the calendar month would pass the ceiling, otherwise — buy a prepaid top-up on /billing to keep translating this month; upgrading your plan there raises your allowance immediately (the new allowance counts from the upgrade; a downgrade applies at renewal) when top-ups are configured, or — upgrade to a larger plan on /billing to raise your allowance immediately (the new allowance counts from the upgrade; a downgrade applies at renewal) when they are not — or, for an allowance at or above the ceiling, — contact us at /contact to raise your allowance. See Quotas |
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, and the declared bodies of the /api/tenants* routes (POST /api/tenants, PATCH /api/tenants/security, POST /api/tenants/invites), produce 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 and whose allowance is below the monthly ceiling. /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.
What's next
- Authentication — get the credential every call on this page needs.
- Conventions & errors — the error catalog every endpoint here can return.
- Text translation — the billable text work a tier's character allowance covers.
- Documents — the billable document work counted against that same allowance.