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.

CredentialHow it is sentWhat it is
Operator API keyX-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 sessionAuthorization: Bearer <access_token>, or the ll_session cookie for browser navigationsAn 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 keyX-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:

EndpointRequired credentialScope
POST /api/tenantsOperator API key or tenant admin sessionAdministrative
GET /api/tenantsOperator API key or tenant admin sessionOperator sees all tenants; an admin session sees only its own
GET /api/tenants/usage/dailyOperator API key or tenant admin sessionOperator 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 sessionAn admin session acts on its own tenant only; naming another returns 404
GET /api/tenants/{tenant_id}/billingOperator API key or tenant admin sessionAn admin session may read only its own tenant_id, and its response omits the token fields
GET /api/tenants/{tenant_id}/billing/exportOperator API key or tenant admin sessionAn admin session may export only its own tenant_id, and its export omits the token fields and the model roster
POST /api/billing/checkoutTenant API key or tenant access tokenActs on the resolved tenant
POST /api/billing/topupTenant API key or tenant access tokenActs on the resolved tenant; the tenant must already hold a current subscription
POST /api/billing/portalTenant API key or tenant access tokenActs on the resolved tenant
POST /api/stripe/webhookStripe webhook signature (Stripe-Signature)Called by Stripe, not by integrators

Admin gate behavior

The /api/tenants* routes call the same gate, in this order:

  1. If an operator key is configured on the server and the request presents it, the request passes as a platform operator.
  2. Otherwise, if the request carries a resolvable session (bearer access token or ll_session cookie):
    • role is global_administrator → passes, scoped to that principal's tenant.
    • any other role → 403 {"detail": "admin role required"}.
  3. 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.
  4. 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:

  1. An Authorization: Bearer <token> value is first tried as an access token (optionally narrowed by X-Tenant-ID); a valid token resolves to that principal's tenant.
  2. Failing that, the X-API-Key header (or the same bearer value) is matched against tenant API keys, optionally narrowed by X-Tenant-ID.
  3. No match → 401 {"detail": "missing or invalid tenant credentials"}.
  4. 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:

FieldMeaning
input_tokensOperator only. Processing counter recorded for the job
output_tokensOperator only. Processing counter recorded for the job
total_tokensOperator 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 — jazh-Hans — is still weighted 3.5, never squared.
  • The same weighted number gates admission and reports usage. It is what the 402 trial checks and the 429 allowance check evaluate before a job is admitted, and it is what billable_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 the 429 earlier 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:

CallerWhat 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 sessionThe 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_month is 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 /billing goes 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 10000000 characters bounds the top-up headroom only. An operator-raised monthly_char_limit above the ceiling — an enterprise account provisioned through POST /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:

Conditiondetail
Billing current and a top-up Price is configuredtenant 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 configuredtenant 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 jobCharged to
Inside monthly_char_limitThe 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 > 0metadata.topup_chars_used
At or past monthly_char_limit, with no balanceNothing — 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:

Conditiondetail
Trial grant fully consumedyour 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 grantyour free trial has ended — add a payment method at /billing to continue
This single request would overrun what is left of the grantthis 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 runningthe 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, and POST /api/stripe/webhook all return 503 {"detail": "billing is not enabled"}.
  • No 402 gate 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-limit 429 all 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_administrator is 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 any global_administrator may call this and will receive the new tenant's api_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 metadata or on monthly_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 bespoke trial_chars_granted or an enterprise cap above the top-up ceiling. See Protected metadata on self-serve signup.

Request body

Content-Type: application/json — schema TenantAccountCreate.

NameTypeRequiredDescription
namestringYesDisplay name for the tenant. Leading/trailing whitespace is stripped.
monthly_char_limitinteger | nullNoPer-period ceiling in source characters — weighted ones, as the gate measures them. Minimum 1. Omit or send null to inherit the server default (500000).
metadataobjectNoFree-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

StatusCause
401{"detail": "admin credentials required"} — no usable operator key or session.
403{"detail": "admin role required"} — authenticated, but not global_administrator.
422HTTPValidationErrorname 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 200TenantAccount[]

[
  {
    "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

StatusCause
401No usable operator key or session.
403Authenticated, but not global_administrator.

PATCH /api/tenants/security

Set a tenant's sign-in policy — three flags:

FlagMeaningDefault
mfa_requiredEvery member must present a second factor to sign infalse
mfa_required_for_ssoExtend that to completed Google sign-ins, which are otherwise exempt because the IdP enforced its own factor policyfalse
sso_auto_joinAllow a new user matching the tenant's Google Workspace domain (hd) to join without an invitationtrue — 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.

NameTypeRequiredDescription
rolestringNoRole the joiner receives. global_administrator or agent_id_developer (default). An unknown value is 422 — never normalized down silently.
expires_daysintegerNoLink lifetime, default 7. Clamped to 1–30 rather than rejected.
emailstring | nullNoBind the link to one address. A redemption from any other address is refused, and the link is not consumed.
labelstring | nullNoAdmin-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"
}

token and invite_url are returned exactly once. Only a SHA-256 hash is stored, so the link cannot be re-read — GET below 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

StatusCause
400tenant_id query parameter is required with API-key access
401No usable operator key or session.
403Authenticated, but not global_administrator.
404Unknown tenant, or another tenant's id from an admin session.
409The tenant already holds 20 pending invites — revoke one first. The request was well-formed; the tenant's state is wrong for it.
422role 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

NameTypeRequiredDescription
sincestring (date, YYYY-MM-DD)NoInclusive start. Defaults to until - 29 days.
untilstring (date, YYYY-MM-DD)NoInclusive end. Defaults to today (UTC).
tenant_idstring | nullNoNarrow 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

StatusCause
400{"detail": "since must be on or before until"}
400{"detail": "date range must not exceed 366 days"}
401No usable operator key or session.
403Authenticated, but not global_administrator.
422since 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

NameTypeRequiredDescription
tenant_idstringYesTenant account id.

Query parameters

NameTypeRequiredDescription
billing_periodstring | nullNoYYYY-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 200TenantUsageSummary

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
}
FieldTypeDescription
tenant_idstringTenant account id.
tenant_namestringTenant display name.
billing_periodstringThe period these totals cover, YYYY-MM.
monthly_char_limitintegerThe tenant's configured ceiling for the period.
input_tokensintegerOperator only. Sum of usage.input_tokens over the period's records. Default 0.
output_tokensintegerOperator only. Sum of usage.output_tokens. Default 0.
total_tokensintegerOperator only. Sum of usage.total_tokens. Default 0.
billable_charsintegerSource characters billed this period, weighted by language pair — the quantity the limit is measured against. Default 0.
remaining_charsintegermax(0, monthly_char_limit - billable_chars). Default 0.
over_limitbooleantrue when billable_chars > monthly_char_limit. Default false.
billable_recordsintegerNumber 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

StatusCause
401No usable operator key or session.
403Authenticated, 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

NameTypeRequiredDescription
tenant_idstringYesTenant account id.

Query parameters

NameTypeRequiredDescription
billing_periodstring | nullNoYYYY-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.
formatstringNoOne 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_period and the rows span every period the tenant has, but the json envelope's billing_period field — 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 own billing_period to know which period a row belongs to; do not attribute the whole file to the envelope's value.

formatContent-TypeBody
jsonapplication/jsonObject: {"tenant_id": ..., "billing_period": ..., "records": [TenantUsageRecord, ...]}, indented 2 spaces
jsonlapplication/x-ndjsonOne TenantUsageRecord JSON object per line, newline-separated
csvtext/csvHeader 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

StatusCause
401No usable operator key or session.
403Authenticated, but not global_administrator.
404An admin session requested a tenant_id other than its own.
422format 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.

NameTypeRequiredDescription
tierstringYesOne of personal, pro, studio. Anything else — including a missing body — is 422.

The three self-serve tiers, cheapest first (the render order on /billing):

tierPriceIncluded per month
personal$25 / month500,000 source characters
pro$59 / month1,500,000 source characters
studio$149 / month5,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

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

NameTypeRequiredDescription
packsintegerNoHow 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.

  1. Billing disabled → 503.
  2. Top-ups not on sale on this deployment → 503. There is nothing to charge against; the /billing page hides the top-up section on the same condition.
  3. No tenant resolvable → 401.
  4. Velocity429. Two independent budgets — one keyed on client IP, one on tenant id — of 5 attempts per 3600-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.
  5. 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.
  6. packs above the per-purchase maximum → 400; packs below 1422 (schema validation).
  7. topup_chars_remaining + packs × 1000000 above the 10000000 ceiling → 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

StatusCause
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"}.
422packs 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

StatusCause
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

NameTypeRequiredDescription
Stripe-SignaturestringYesTimestamped HMAC produced by Stripe.

Handled events

Event typeEffect 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 modeTreated 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_succeededCredits 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_failedLogged. Nothing is credited.
customer.subscription.createdSets 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.updatedSame as created.
customer.subscription.deletedSets stripe_subscription_status to canceled.
invoice.payment_failedLogged. 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 to metadata.tenant_id on 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.completed for a Session already in topup_credited_sessions is 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_succeeded does the crediting.

Response 200

{"received": true}

Every verified event returns 200 — including unhandled types — so Stripe does not retry.

Errors

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

FieldTypeRequiredDefaultNotes
namestringYes
monthly_char_limitinteger | nullNonullMinimum 1; null inherits the server default. Not clamped by the top-up ceiling on this operator route.
metadataobjectNo{}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.

FieldTypeRequiredDefaultNotes
tierstringYesNon-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.

FieldTypeRequiredDefaultNotes
packsintegerNo1Minimum 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.

FieldTypeDefaultNotes
idstringgenerated16-character hex id.
namestringRequired in the schema.
api_key_prefixstringRequired in the schema. First 8 characters of the tenant API key.
monthly_char_limitinteger500000Minimum 1.
activebooleantrueInactive accounts fail tenant authentication.
created_atstring (date-time)generatedUTC.
metadataobject{}See below.

Keys the platform writes into metadata:

KeyTypeWritten by
trial_chars_grantedintegerTenant creation, where a free-trial grant is configured. Stamped as 0 when the per-IP grant budget is spent.
trial_chars_usedintegerJob accounting, for tenants without a current subscription.
topup_chars_purchasedintegerStripe webhook, on a verified paid top-up payment. Counts up only.
topup_chars_usedintegerJob accounting, for a subscription-current tenant past its monthly limit. Counts up only; remaining is purchased - used, floored at zero.
topup_credited_sessionsstring[]Stripe webhook. The redelivery ledger — Checkout Session ids already credited, bounded to the 20 most recent.
stripe_customer_idstringFirst POST /api/billing/checkout or POST /api/billing/topup.
stripe_subscription_idstringStripe webhook / direct reconciliation.
stripe_subscription_statusstringStripe webhook / direct reconciliation. active and trialing permit translation work.
stripe_tierstringStripe 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_requiredbooleanPATCH /api/tenants/security only. Every member must present MFA to sign in.
mfa_required_for_ssobooleanPATCH /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.

FieldTypeDefaultNotes
idstringgenerated32-character hex record id.
tenant_idstring
tenant_namestringDenormalized at write time.
job_idstringUnique per record; re-recording the same job is a no-op.
job_statusJobStatusOne of pending, translated, refined, awaiting_review, approved, human_edited, rejected, published, failed, persistence_failed.
model_rosterstring[][]Operator only. Deduplicated list of engine identifiers recorded for the job, in first-seen order.
usageTokenUsageOperator only. See below.
billable_charsinteger0Source 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_langstring
target_langstring
billing_periodstringYYYY-MM, from the job's updated_at.
created_atstring (date-time)generatedUTC; 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

FieldTypeDefault
input_tokensinteger0
output_tokensinteger0
total_tokensinteger0

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.

StatusWheredetail
400usage/dailysince must be on or before until
400usage/dailydate range must not exceed 366 days
400stripe/webhookinvalid webhook signature
400/api/billing/topupat most {n} packs per purchase
400/api/billing/checkout, /api/billing/topupinvalid JSON body: ...
401/api/tenants*admin credentials required
401/api/billing/*billing requires an authenticated tenant
401/api/billing/*missing or invalid tenant credentials
402translation endpointsyour free trial is used up — add a payment method at /billing to continue
402translation endpointsyour free trial has ended — add a payment method at /billing to continue
402translation endpointsthis request exceeds your remaining free trial characters — add a payment method at /billing
402translation endpointsthe free trial covers the workspace only — API access requires an active subscription; subscribe at /billing
402translation endpointsan active subscription is required to run translations — add a payment method at /billing
402/api/billing/topuptop-ups require an active subscription — subscribe at /billing first
403/api/tenants*admin role required
404{tenant_id}/billing, {tenant_id}/billing/exporttenant not found
409/api/billing/portalno Stripe customer for this tenant yet — subscribe first
409/api/billing/topupthis purchase would exceed the monthly character ceiling — use your current balance first
413anyrequest body exceeds MAX_UPLOAD_BYTES (enforced by middleware over the whole request stream)
422/api/billing/checkouttier 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/topupthe raw validation message for a packs below 1 or of the wrong type — a plain string, not the array shape
429translation endpointstenant 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/topuptoo many top-up attempts — slow down and retry later (with Retry-After)
503/api/billing/*, stripe/webhookbilling is not enabled
503/api/billing/topuptop-ups are not configured
503/api/billing/checkoutthis plan is not available on this deployment — no Price configured for the requested tier
503stripe/webhookbilling 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"
    }
  ]
}

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.