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:

  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 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:

  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:

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 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. A batch is weighed input by input — each input's own Language against each of its Targets — exactly as its jobs will be billed. The one unweighted figure is a document job's own billable_chars field, 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 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:

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_month is 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 /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.
  • The ceiling of 10000000 characters bounds the top-up headroom — and, for a subscriber whose monthly_char_limit is 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-raised monthly_char_limit at or above the ceiling — an enterprise account provisioned through POST /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, 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 — the 429 against monthly_char_limit alone, 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_administrator is a per-tenant role that every self-serve founder holds for their own tenant, and this route writes platform-owned metadata and an unclamped monthly_char_limit verbatim — so a tenant admin session passes the admin gate and is then refused with 403 {"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 metadata or on monthly_char_limit. Self-serve signup strips the nine platform-owned prefixes (stripe_ / trial_ / topup_ / mfa_ / sso_ / invites_ / storage_ / google / azuread) 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.

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"
}

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

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

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.

  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. Velocity → 429. 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. monthly_char_limit already at or above the 10000000 ceiling → 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 /billing page hides the top-up section on the same condition.
  7. packs above the per-purchase maximum → 400; packs below 1 → 422 (schema validation).
  8. 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}'
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.created or customer.subscription.updated event older, by Stripe's created, 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 incomplete status for a subscription the mirror already shows past incomplete (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 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

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"
    }
  ]
}

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.