Bring Your Own Storage (BYOS)

Start here: README · Related: Authentication · Conventions & errors · Text translation · Documents

Bring Your Own Storage routes a tenant's translation data to an Azure Data Lake Storage Gen2 account that the customer owns, instead of the platform store. This page documents the four endpoints that manage a tenant's storage binding — the record that names the account, the credential mode, and the verification state — the read-only usage endpoint that reports a tenant's position against the platform storage cap, plus what data lands in customer storage and what stays on the platform. You need this page if you are onboarding a tenant to their own storage account, automating the bind/verify cycle, or handling the fail-closed errors that appear on translation endpoints when a binding is not verified.

Base URL: https://trueidiom.com

Requests take an optional api-version=2026-09-01 query parameter; omitting it serves the oldest supported version. See Versioning.

Method Path Purpose
GET /api/storage/binding Read the calling tenant's binding and verification status
PUT /api/storage/binding Create or replace the binding (resets it to unverified)
POST /api/storage/binding/verify Run a live write/read/delete probe against the account
DELETE /api/storage/binding Remove the binding and revert to platform storage
GET /api/storage/usage Read platform-storage bytes used, the cap, and whether it is enforced

Availability

All four binding endpoints require Bring Your Own Storage to be switched on for the deployment. When it is off, each of them returns:

HTTP/1.1 409 Conflict
Content-Type: application/json

{"detail": "Bring Your Own Storage is not enabled on this service"}

The translation kill switch does not gate these endpoints — binding management stays available while translation is disabled.

GET /api/storage/usage is deliberately not behind the switch. The platform storage cap it reports matters most where BYOS is switched off entirely, because there every tenant is on platform storage; the endpoint answers on any deployment.

sas and connection_string bindings rely on the platform's secret storage — but its configuration is a startup condition, not a request-time one: a deployment that switches on BYOS without secret storage fails to boot. So any service that answers these endpoints at all has secret storage behind it, and a PUT carrying a secret never fails for want of it. What can happen is a brief outage of that storage. A call that needs it — PUT, POST .../verify or DELETE on a binding that holds a secret, or a PUT that stores one — then answers 503 {"detail": "secret storage is temporarily unavailable — try again shortly"} and changes nothing: the binding and its secret stay as they were, and a verified binding stays verified. Retry shortly.


Authentication

The generated document declares no securitySchemes and no global security requirement, so the interactive /docs page shows every endpoint as unauthenticated. They are not. Every endpoint on this page resolves the caller's tenant through the same identity path used by /api/translations, and the resolved tenant id is the only thing that selects which binding is read or written — no request field can name a tenant, a binding, or a storage target belonging to someone else.

Identity resolution depends on the deployment's state:

Deployment state Credentials required Binding is filed under
Tenant accounts exist Authorization: Bearer <access_token> (signed-in user) or the tenant API key in X-API-Key (a bearer token is also accepted as the API key) the authenticated tenant's id
No tenant accounts, an operator key configured the operator key in X-API-Key or Authorization: Bearer <api_key> default

X-Tenant-ID is optional and only narrows API-key authentication to a specific tenant; a mismatch between the key and the named tenant fails authentication.

The curl examples below use $TRUEIDIOM_API_KEY — the same tenant API key the translation and document endpoints take, returned once as tenant_api_key by POST /api/auth/signup/email. See Authentication.

Failure responses:

Condition Status Body
Tenant accounts exist, credentials missing or invalid 401 {"detail": "missing or invalid tenant credentials"}
No accounts, an operator key configured, key missing or wrong 401 {"detail": "missing or invalid API key"}
A signed-in member who is not an admin calls PUT, DELETE or POST .../verify 403 {"detail": "admin role required"}

Reading is open to every member; changing needs an admin. GET /api/storage/binding and GET /api/storage/usage answer any principal that authenticates for the tenant. The calls that change where the workspace's data lands — PUT /api/storage/binding, DELETE /api/storage/binding and POST /api/storage/binding/verify — take the tenant API key or a global_administrator's own sign-in (bearer access token or session cookie); a signed-in member is refused with 403. Each change is recorded in the auth audit log — storage_binding_updated, storage_binding_verified or storage_binding_deleted — with who made it (the user's id, or tenant_api_key) and what changed: the provider, account URL and auth_mode, or the verify outcome. The secret is never recorded. The resolved tenant id is the only selector for which binding is read or written, so these endpoints cannot reach another tenant's binding regardless of what the request body contains.

Every response carries an X-Request-ID header. Send your own X-Request-ID on the request to have it echoed back and bound to the server-side logs for that call.


What a binding is

One binding per tenant, held in the platform control plane (never in customer storage). It has two axes:

  • provider — platform means the tenant uses the platform store (the default when no binding exists). adls means the tenant has nominated its own Azure account.
  • status — unverified, verified, or error. Data routes to the customer account only when provider="adls" and status="verified".

Routing fails closed. With provider="adls" and any status other than verified, nothing silently falls back to the platform store. New work is refused before it starts: text and document submissions answer 409 storage_binding_not_verified before any job is created or anything is translated. Work already under way when the account stops accepting writes fails where it stands, and what happens next depends on the pipeline: a text job whose golden record cannot be written is parked as persistence_failed and republished later, while a document job is marked failed and its scratch purged, so it must be resubmitted after the binding is fixed (see Effects on other endpoints). Even a verified binding receives data only while its stored account_url still passes the validation PUT applies.

Any successful PUT resets status to unverified, clears last_error, and clears verified_at, even when the submitted values are identical to what was stored. Re-run verify after every edit: after a PUT with provider="adls", the tenant's translation and document submissions are refused with 409 until verify succeeds.

Supported backend

Azure Data Lake Storage Gen2 only (provider="adls"). account_url names the account by its dfs endpoint — https://<account>.dfs.core.windows.net. The account's blob endpoint (https://<account>.blob.core.windows.net) and either host without the https:// scheme are accepted too, and whichever form you send is stored as https://<account>.dfs.core.windows.net. The account name is 3–24 letters and digits (case-insensitive; stored lowercase). Anything else is refused with 422 before anything is stored: another host (private-endpoint privatelink and sovereign-cloud hostnames included), a scheme other than https, a port, a path beyond a trailing /, a query string or fragment, credentials in the URL, or a storage account this service itself uses. The account needs to be StorageV2 with hierarchical namespace enabled, and filesystem is the container name. No other cloud or storage backend is accepted.

Data planes

Within the container, three logical prefixes are used. Defaults come from the server; prefixes overrides them per plane.

Plane key Default prefix What lands there Implemented
golden golden Text translation records — one JSON blob per published or approved job (autonomous publishes included, not only human-reviewed ones) under <prefix>/YYYY/MM/DD/<job_id>.json, plus a daily manifest Yes
documents documents Document job files (source file, translated output, layout JSONs and the quality report) under <prefix>/<job_id>/<file_name> Yes
terminology terminology Terminology exports Not wired — resolving this plane raises a not-implemented error

Approval of a document job does not write a record to the golden plane in your account. It writes a counts-only audit record — document id, tenant, reviewer, language pair, pair and corrected counts, timestamp, no text — into the platform's own golden store, filed as <document_id>.docapproval-<n>.json alongside the <job_id>.json blobs there. The document's own files land on the documents plane as usual.

Document processing writes to a local scratch directory because the extractor and reconstructor operate on real files; for each finalized document file that is uploaded to the customer account, the scratch file is removed on the same code path, success or failure. Downloads fetch from the customer account once scratch is gone. The exception is the pipeline telemetry log: it is pipeline internals end to end, it is never uploaded to a customer account, and it is retained platform-side only.

What lands in the customer account is the customer's content: the layout JSONs and the quality report are written there with the document text, geometry and review signals (scores, needs_review, terminology compliance), while pipeline execution internals — engine and model identifiers, per-segment step traces, token counts — stay in the platform control plane. The golden plane follows the same rule: the records written to your account are redacted copies, with model names, token usage, reasoning and per-step agent metadata stripped and the source and target text, review fields, scores and decisions kept — the platform's full-fidelity record stays in the control plane.

Stays in the platform control plane regardless of the binding: tenant accounts, users, billing and usage records, job status and job metadata (including checksums and review history), the binding record itself, and any stored credential ciphertext.

BYOS covers data at rest. The translation work itself still runs on platform compute.


Credential model

auth_mode selects how the platform authenticates to the customer account.

auth_mode Secret stored How it works
managed_identity (default) none Keyless. The platform authenticates with its own Azure identity — the principal GET reports as principal_client_id. The customer grants that identity Storage Blob Data Contributor on the container or account. Revoking the role assignment cuts off access, subject to the same Azure role-propagation delay that applies to granting it.
sas yes A container SAS token with read/write/create/delete/list permissions. Submitted in secret.
connection_string yes An account connection string. Submitted in secret. Handled identically to sas.

Secret handling

secret is write-only. It is accepted on PUT and handed to the platform's secret storage — Azure Key Vault on a deployment configured with one; otherwise it is encrypted and kept in the same platform database as the binding records themselves (a single-node deployment that keeps its bindings in a local file keeps the encrypted secrets in a local file beside them). Only an opaque reference is kept on the binding record.

No endpoint on this page returns secret or the stored reference. Reads expose the boolean has_secret and nothing else about the credential. This is verified behavior, not merely intent: GET after storing a SAS token returns has_secret: true with no secret material anywhere in the response body.

Secret lifecycle rules applied by PUT:

  • Submitting secret with auth_mode=sas or connection_string replaces the stored secret for that tenant.
  • Omitting secret reuses the stored secret only when an existing binding has the same auth_mode and already holds one. Otherwise the request is rejected with 422 a secret is required for auth_mode=....
  • A reused secret must still be readable. If it is missing or can no longer be decrypted, the PUT is rejected with 422 the stored secret for this storage binding is missing or unreadable — re-enter the SAS token or connection string, then verify the binding. Elsewhere the same condition shows as a verify that records that sentence as last_error; a submission — text or document — refused up front with 409 storage_binding_not_verified; a document download, review or approval refused with 409 and workspace storage (BYOS) cannot be used: the stored secret for its binding is missing or unreadable — re-enter the SAS token or connection string in Storage settings and verify the binding, or switch the workspace back to platform storage; or, for a text job that was already being published when the secret disappeared, 502 persistence_failed quoting it. Send the secret again with PUT, then verify.
  • Switching to auth_mode=managed_identity, switching to provider=platform, or calling DELETE deletes the stored secret.

The binding object

Returned by GET, PUT, and POST .../verify. A PUT back to platform storage, and a verify that succeeds or finds nothing to probe, also carry a replay summary (see POST /api/storage/binding/verify). The OpenAPI document types these responses as a free-form object; the concrete fields below come from the router's serializer.

Field Type Description
tenant_id string Tenant id the binding belongs to. Server-derived from the authenticated caller. On a single-tenant deployment authenticated by the operator key, the binding is filed under the literal id default.
provider string platform or adls.
account_url string ADLS Gen2 dfs endpoint. Empty string for platform.
filesystem string Container name. Empty string for platform.
prefixes object (string → string) Per-plane directory prefixes. Defaults to {"golden": "golden", "documents": "documents", "terminology": "terminology"}.
auth_mode string managed_identity, sas, or connection_string.
status string unverified, verified, or error.
last_error string | null Probe failure text from the most recent verify attempt. null after a successful verify.
verified_at string (ISO 8601) | null Timestamp of the last successful verify, in UTC with a +00:00 offset rather than Z (see Datetimes). null when never verified or after a failed one.
has_secret boolean Whether a credential secret is stored for this binding. The secret itself is never returned.
principal_client_id string | null GET only. Client (application) id of the TrueIdiom principal that reaches your account when auth_mode=managed_identity — the one to grant Storage Blob Data Contributor. Deployment-wide, not per-tenant. null when the deployment authenticates with an identity whose application id is not knowable from its configuration; ask support for it in that case rather than guessing.

GET /api/storage/binding

Read the calling tenant's binding and its verification status.

Auth: tenant identity (see Authentication).

No path, query, or body parameters.

curl -sS "https://trueidiom.com/api/storage/binding?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
import os
import requests

resp = requests.get(
    "https://trueidiom.com/api/storage/binding?api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    timeout=60,
)
resp.raise_for_status()
binding = resp.json()
print(binding["provider"], binding["status"])
print(binding["principal_client_id"])  # grant this principal Storage Blob Data Contributor
// Node 18+
const res = await fetch("https://trueidiom.com/api/storage/binding?api-version=2026-09-01", {
  headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const binding = await res.json();
console.log(binding.provider, binding.status);
// Grant this principal Storage Blob Data Contributor.
console.log(binding.principal_client_id);

Call this first when setting up keyless access: principal_client_id is the id you pass to az role assignment create --assignee, so a GET before any PUT tells you which principal to authorize.

Tenant with a verified ADLS binding:

{
  "tenant_id": "8f21c9d0b47a3e15",
  "provider": "adls",
  "account_url": "https://acmelocalization.dfs.core.windows.net",
  "filesystem": "translations",
  "prefixes": {
    "golden": "golden",
    "documents": "documents",
    "terminology": "terminology"
  },
  "auth_mode": "managed_identity",
  "status": "verified",
  "last_error": null,
  "verified_at": "2026-07-24T09:14:52.118374+00:00",
  "has_secret": false,
  "principal_client_id": "6b1f0c74-2d9e-4a83-9f5b-1c07ae43d2f8"
}

When no binding has ever been stored, the response is a synthesized platform binding (nothing is persisted by this call):

{
  "tenant_id": "8f21c9d0b47a3e15",
  "provider": "platform",
  "account_url": "",
  "filesystem": "",
  "prefixes": {
    "golden": "golden",
    "documents": "documents",
    "terminology": "terminology"
  },
  "auth_mode": "managed_identity",
  "status": "unverified",
  "last_error": null,
  "verified_at": null,
  "has_secret": false,
  "principal_client_id": "6b1f0c74-2d9e-4a83-9f5b-1c07ae43d2f8"
}

Note that status reads unverified on a platform binding. Treat provider as the switch; status is meaningful only when provider="adls".

Errors

Status Cause
401 Tenant credentials missing or invalid.
409 BYOS is not enabled on the service.

PUT /api/storage/binding

Create or replace the binding. This is a full replace, not a patch: omitted fields take their schema defaults, so omitting filesystem blanks it and omitting prefixes resets all three prefixes to the defaults. Send the complete desired state on every call.

Auth: tenant admin — the tenant API key or an admin's own sign-in; a signed-in member gets 403 (see Authentication). Recorded in the audit log as storage_binding_updated.

Request body (application/json, required) — schema StorageBindingUpsert:

Name Type Required Description
provider string enum: platform, adls No — default adls platform opts the tenant back to platform storage and drops any stored secret.
account_url string Conditional — default "" ADLS Gen2 dfs endpoint, e.g. https://acmelocalization.dfs.core.windows.net. Required (non-blank) when provider="adls"; leading/trailing whitespace is trimmed. Validated as an Azure Storage account endpoint and stored in canonical https://<account>.dfs.core.windows.net form — see Supported backend. Reachability is not tested here; a wrong account or container surfaces at verify time.
filesystem string No — default "" Container name; whitespace-trimmed. Effectively required: the verify probe uses this value raw, so a blank filesystem cannot pass verification — and since data only routes to a verified binding, the golden plane's fallback to a container named golden never actually routes.
prefixes object (string → string) | null No — default null Per-plane directory overrides, keyed golden, documents, terminology. null (or omitted) applies the server defaults.
auth_mode string enum: managed_identity, sas, connection_string No — default managed_identity See Credential model.
secret string | null Conditional — default null Write-only. SAS token or connection string. Required when auth_mode is sas or connection_string, unless a secret is already stored for the same auth_mode. Never returned by any endpoint.

Ignored when provider="platform": account_url, filesystem, prefixes, auth_mode, and secret. The stored binding becomes a bare platform binding.

Keyless bind

curl -sS -X PUT "https://trueidiom.com/api/storage/binding?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "provider": "adls",
        "account_url": "https://acmelocalization.dfs.core.windows.net",
        "filesystem": "translations",
        "auth_mode": "managed_identity"
      }'
import os
import requests

resp = requests.put(
    "https://trueidiom.com/api/storage/binding?api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    json={
        "provider": "adls",
        "account_url": "https://acmelocalization.dfs.core.windows.net",
        "filesystem": "translations",
        "auth_mode": "managed_identity",
    },
    timeout=60,
)
resp.raise_for_status()
binding = resp.json()
print(binding["provider"], binding["auth_mode"], binding["status"])
// Node 18+
const res = await fetch("https://trueidiom.com/api/storage/binding?api-version=2026-09-01", {
  method: "PUT",
  headers: {
    "X-API-Key": process.env.TRUEIDIOM_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    provider: "adls",
    account_url: "https://acmelocalization.dfs.core.windows.net",
    filesystem: "translations",
    auth_mode: "managed_identity",
  }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const binding = await res.json();
console.log(binding.provider, binding.auth_mode, binding.status);
{
  "tenant_id": "8f21c9d0b47a3e15",
  "provider": "adls",
  "account_url": "https://acmelocalization.dfs.core.windows.net",
  "filesystem": "translations",
  "prefixes": {
    "golden": "golden",
    "documents": "documents",
    "terminology": "terminology"
  },
  "auth_mode": "managed_identity",
  "status": "unverified",
  "last_error": null,
  "verified_at": null,
  "has_secret": false
}

SAS bind with custom prefixes

curl -sS -X PUT "https://trueidiom.com/api/storage/binding?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "provider": "adls",
        "account_url": "https://acmelocalization.dfs.core.windows.net",
        "filesystem": "translations",
        "prefixes": {
          "golden": "approved",
          "documents": "docs",
          "terminology": "glossaries"
        },
        "auth_mode": "sas",
        "secret": "sv=2025-01-05&ss=b&srt=sco&sp=rwdlac&se=2026-12-31T00:00:00Z&sig=REPLACE_WITH_YOUR_SAS_SIGNATURE"
      }'
{
  "tenant_id": "8f21c9d0b47a3e15",
  "provider": "adls",
  "account_url": "https://acmelocalization.dfs.core.windows.net",
  "filesystem": "translations",
  "prefixes": {
    "golden": "approved",
    "documents": "docs",
    "terminology": "glossaries"
  },
  "auth_mode": "sas",
  "status": "unverified",
  "last_error": null,
  "verified_at": null,
  "has_secret": true
}

Opt back out to platform storage

curl -sS -X PUT "https://trueidiom.com/api/storage/binding?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider": "platform"}'

On a deployment that gives each tenant a managed platform container, this path provisions it before anything is mutated, so it can fail with 502 when provisioning does not succeed. That failure is clean — the existing binding and its stored secret are untouched, and the tenant stays on whatever they had. DELETE /api/storage/binding is the always-available exit: it never blocks on provisioning, so use it when a PUT back to platform keeps returning 502.

The response is the bare platform binding object plus a replay summary: switching back publishes the tenant's text jobs parked as persistence_failed to platform storage (see replay fields).

Errors

Status Body / cause
401 Tenant credentials missing or invalid.
403 {"detail": "admin role required"} — a signed-in member who is not a global_administrator. Nothing changed.
409 {"detail": "Bring Your Own Storage is not enabled on this service"}
413 {"detail": "request body exceeds MAX_UPLOAD_BYTES"} — body above the server's upload ceiling (default 30,000,000 bytes).
422 {"detail": "account_url is required for provider=adls"}
422 {"detail": "account_url must be an Azure Storage endpoint of the form https://<account>.dfs.core.windows.net"} — not the dfs or blob endpoint of an Azure Storage account (see Supported backend). Nothing was stored, the secret included. A URL that cannot be parsed at all answers account_url is not a valid URL: <reason> instead.
422 {"detail": "account_url names this service's own storage account (<account>); bind a storage account your organization owns"}
422 {"detail": "a secret is required for auth_mode=sas"} (or ...=connection_string) — no secret supplied and no reusable stored secret for that same auth_mode.
422 {"detail": "the stored secret for this storage binding is missing or unreadable — re-enter the SAS token or connection string, then verify the binding"} — secret omitted to keep the stored one, which no longer exists or can no longer be read.
422 Schema validation failure (unknown provider/auth_mode value, wrong JSON types). Body is the FastAPI HTTPValidationError shape: {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}.
502 {"detail": "could not provision platform storage container — nothing was changed; retry shortly"} — provider="platform" only: the managed platform container is provisioned before anything is mutated, and provisioning failed. Nothing changed; retry, or use DELETE, which never blocks on provisioning.
503 {"detail": "secret storage is temporarily unavailable — try again shortly"} — the platform's secret storage could not be reached while storing, reading or deleting the secret. The binding is unchanged; retry.

The two 422 shapes differ: business-rule rejections put a string in detail, schema failures put an array there. Parse defensively.


POST /api/storage/binding/verify

Run a live health probe against the bound account and record the outcome on the binding. A binding is trustworthy only after this succeeds — the resolver refuses to route data to an adls binding in any other status.

Auth: tenant admin — the tenant API key or an admin's own sign-in; a signed-in member gets 403 (see Authentication). A probe's outcome is recorded in the audit log as storage_binding_verified.

No path, query, or body parameters. Send no body.

What it checks. The stored account_url is first re-checked against the rules PUT applies; a value that no longer passes is recorded as the failure without the account being contacted. Then, using the binding's credential (the platform's own Azure identity for managed_identity, the decrypted stored secret for sas/connection_string), it performs a real round-trip against the customer account: upload a small probe object at <prefixes.golden>/.byos-probe, read it back, then delete it. Any failure in that sequence — wrong account_url, missing container, missing role assignment, expired SAS, a stored secret that is missing or unreadable — is captured. The probe has a fixed budget — 10 s to connect, 30 s per read, one retry — so an unresponsive account fails the verify quickly rather than holding the request.

The probe writes, reads, and deletes under the golden prefix. Grant the platform identity access to every prefix you configure — documents as well as golden — so that document artifacts land as reliably as approved records do.

On success, the new destination takes effect immediately, and any of this tenant's text translation jobs parked in persistence_failed are replayed to the now-reachable account. Document jobs are not replayed — a document job that failed against a broken binding has to be resubmitted (see Document jobs: failed, not parked).

No binding, or provider="platform". There is nothing to probe, and the call still succeeds: it returns 200 with the platform binding object and a replay summary, after publishing the tenant's parked text jobs to platform storage. This is how a tenant on platform storage retries a job that a 502 persistence_failed parked.

A replay publishes each parked job through the steps its original publish never reached — writing the golden record, which also feeds the tenant's golden translation memory — and lands it in the state that publish was headed for: published, or human_edited when a reviewer supplied the final text. Its usage is recorded once; a job already metered when it was submitted is not charged again. Replays for one tenant run one at a time, so overlapping calls never publish a job twice.

curl -sS -X POST "https://trueidiom.com/api/storage/binding/verify?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
import os
import requests

resp = requests.post(
    "https://trueidiom.com/api/storage/binding/verify?api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    timeout=120,
)
resp.raise_for_status()
binding = resp.json()
print(binding["status"], binding["last_error"])
replay = binding.get("replay")  # absent when the probe failed
if replay:
    print(replay["replayed"], replay["failed"])
// Node 18+
const res = await fetch(
  "https://trueidiom.com/api/storage/binding/verify?api-version=2026-09-01",
  { method: "POST", headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const binding = await res.json();
console.log(binding.status, binding.last_error);
// `replay` is absent when the probe failed.
if (binding.replay) console.log(binding.replay.replayed, binding.replay.failed);

Success (200) — the binding object plus a replay summary:

{
  "tenant_id": "8f21c9d0b47a3e15",
  "provider": "adls",
  "account_url": "https://acmelocalization.dfs.core.windows.net",
  "filesystem": "translations",
  "prefixes": {
    "golden": "golden",
    "documents": "documents",
    "terminology": "terminology"
  },
  "auth_mode": "managed_identity",
  "status": "verified",
  "last_error": null,
  "verified_at": "2026-07-24T09:14:52.118374+00:00",
  "has_secret": false,
  "replay": {
    "tenant_id": "8f21c9d0b47a3e15",
    "replayed": ["2c41f0a97e3b4d18a65c0f92d7b1e384", "9be7d3105a2c4e6f8b1d0c7a3e94f625"],
    "failed": []
  }
}

replay fields:

Field Type Description
replay.tenant_id string Tenant whose parked text jobs were drained.
replay.replayed array of string Text job ids published by this replay — each now published, or human_edited when a reviewer supplied the final text — with its golden record written to wherever the tenant's storage now resolves: the customer account after a successful verify, platform storage after a switch back.
replay.failed array of object Per-job failures, each {"job_id": "...", "error": "the job could not be published; it stays parked and is retried by the next replay"} — the error text is always that sentence; the reason is in the service's logs. Such a job stays parked for the next replay. Each job is replayed independently; one failure does not abort the rest.

A job that is no longer parked by the time its turn comes — deleted meanwhile, for example — appears in neither list. The same replay object is returned by PUT with {"provider": "platform"} and by DELETE.

Probe failure (200, not an error status) — the request succeeds, the binding is recorded as broken:

{
  "tenant_id": "8f21c9d0b47a3e15",
  "provider": "adls",
  "account_url": "https://acmelocalization.dfs.core.windows.net",
  "filesystem": "translations",
  "prefixes": {
    "golden": "golden",
    "documents": "documents",
    "terminology": "terminology"
  },
  "auth_mode": "managed_identity",
  "status": "error",
  "last_error": "403 AuthorizationPermissionMismatch: This request is not authorized to perform this operation using this permission.",
  "verified_at": null,
  "has_secret": false
}

A failed probe returns HTTP 200. Branch on status, not on the HTTP status code: treat status == "verified" as success and status == "error" as failure, reading last_error for the reason. The replay key is absent on failure.

Role assignments can take several minutes to propagate in Azure; a 403-flavored last_error immediately after granting Storage Blob Data Contributor is often resolved by retrying verify a few minutes later.

Errors

Status Body / cause
401 Tenant credentials missing or invalid.
403 {"detail": "admin role required"} — a signed-in member who is not a global_administrator. Nothing was probed or replayed.
409 BYOS is not enabled on the service.
503 {"detail": "secret storage is temporarily unavailable — try again shortly"} — the stored secret could not be read because the platform's secret storage is unreachable. Not a verdict on the binding: it is left exactly as it was, and a verified binding stays verified. Retry shortly.

A binding that fails the probe is not an error status — see the 200 above.


DELETE /api/storage/binding

Remove the binding entirely. Any stored credential secret is deleted, and the tenant reverts to platform storage for subsequent work. Text jobs parked as persistence_failed are then published to platform storage, and the response reports them in a replay summary.

Auth: tenant admin — the tenant API key or an admin's own sign-in; a signed-in member gets 403 (see Authentication). Recorded in the audit log as storage_binding_deleted.

No path, query, or body parameters. Deleting when no binding exists is not an error.

This is the always-available exit. Unlike PUT with {"provider": "platform"}, it is never blocked by platform-container provisioning — a tenant can always walk away from their own account, and the platform destination is provisioned lazily on their next job.

Data already written to the customer account is left untouched — nothing is migrated, copied back, or deleted from the customer's storage.

curl -sS -X DELETE "https://trueidiom.com/api/storage/binding?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
import os
import requests

resp = requests.delete(
    "https://trueidiom.com/api/storage/binding?api-version=2026-09-01",
    headers={"X-API-Key": os.environ["TRUEIDIOM_API_KEY"]},
    timeout=60,
)
resp.raise_for_status()
result = resp.json()
print(result["provider"], result["status"])
// Node 18+
const res = await fetch("https://trueidiom.com/api/storage/binding?api-version=2026-09-01", {
  method: "DELETE",
  headers: { "X-API-Key": process.env.TRUEIDIOM_API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const result = await res.json();
console.log(result.provider, result.status);
{
  "tenant_id": "8f21c9d0b47a3e15",
  "provider": "platform",
  "status": "deleted",
  "replay": {
    "tenant_id": "8f21c9d0b47a3e15",
    "replayed": [],
    "failed": []
  }
}

This response is not the binding object — it carries only these four fields, and status: "deleted" is a delete-acknowledgement value that never appears on a stored binding. Call GET afterwards if you need the full post-delete state.

Field Type Description
tenant_id string Tenant whose binding was removed.
provider string Always platform.
status string Always deleted.
replay object The parked text jobs published to platform storage — the same shape as on verify.

Errors

Status Cause
401 Tenant credentials missing or invalid.
403 {"detail": "admin role required"} — a signed-in member who is not a global_administrator. Nothing was removed.
409 BYOS is not enabled on the service.
503 {"detail": "secret storage is temporarily unavailable — try again shortly"} — the binding's stored secret could not be deleted because the platform's secret storage is unreachable. Nothing was removed; retry.

GET /api/storage/usage

Read the calling tenant's position against the platform storage cap: the document artifact bytes resting on the platform plane, the cap that governs them, and whether that cap is actually being enforced.

Auth: tenant identity (see Authentication).

No path, query, or body parameters. This endpoint is not gated by the BYOS switch — see Availability.

curl -sS "https://trueidiom.com/api/storage/usage?api-version=2026-09-01" \
  -H "X-API-Key: $TRUEIDIOM_API_KEY"
{
  "tenant_id": "8f21c9d0b47a3e15",
  "provider": "platform",
  "used_bytes": 41929216,
  "cap_bytes": 5368709120,
  "remaining_bytes": 5326779904,
  "enforced": true
}
Field Type Description
tenant_id string Tenant the figures describe. Server-derived from the authenticated caller.
provider string platform or adls, read from the binding — platform when no binding exists or BYOS is off.
used_bytes integer Document artifact bytes this tenant currently has on the platform plane.
cap_bytes integer The effective cap: the deployment's platform storage cap, or the tenant's operator-set override. 0 means uncapped.
remaining_bytes integer | null cap_bytes - used_bytes, floored at 0. null whenever enforced is false — there is no remainder to report against a cap that is not gating anything.
enforced boolean Whether document submissions are actually being gated: true only when the caller is a real tenant, cap_bytes is above 0, and provider is platform.

The 507 admission response

When the cap is enforced, it is admission control on document submission, checked against the declared Content-Length: POST /api/documents/translate refuses the upload before the body is parsed, and a PDF/DOCX/XLSX upload to POST /api/translations is refused before any job is created or any of its bytes are stored.

HTTP/1.1 507 Insufficient Storage

{"detail": "platform storage cap reached (5368709120 of 5368709120 bytes used) — delete document jobs you no longer need (DELETE /api/documents/{document_id}), or bring your own storage (PUT /api/storage/binding) to keep documents in your own account"}

Three things narrow when this can fire:

  • It is off by default — the deployment's cap defaults to 0, and a cap of 0 is uncapped.
  • It is skipped entirely for a tenant with a non-platform binding. Those artifact bytes rest in the customer's own account, so the platform cap has nothing to guard — this is the one place where BYOS changes an admission decision rather than a destination.
  • It gates the next submission, not the current one. A job already in flight can finish somewhat past the cap, because pipeline outputs are written after admission.

Text translation is not gated by this cap; only document artifact bytes count toward it.

Errors

Status Cause
401 Tenant credentials missing or invalid.

Effects on other endpoints

Once a tenant has provider="adls", the binding's status changes how persistence-bearing endpoints behave.

New work is refused before it starts while the binding is not verified, or cannot be used. Work already under way when storage fails recovers differently per pipeline, and the difference matters operationally: text jobs park and are republished, document jobs fail and must be resubmitted.

Submissions: refused until the binding verifies

While the binding is present but not verified — or is verified but unusable: its stored secret is missing or unreadable, or its stored account URL no longer passes the rules PUT applies — every submission that would start translation work is refused before any job is created, anything is translated, or anything is billed:

  • POST /api/translations — every shape: single text, batch, the paged text-document upload, and PDF/DOCX/XLSX uploads;
  • POST /api/documents/translate — before the upload is even parsed, so none of its bytes are stored on the platform.
HTTP/1.1 409 Conflict

{
  "detail": "workspace storage (BYOS) binding is not verified — verify it in Storage settings and resubmit, or switch the workspace back to platform storage",
  "error_code": "storage_binding_not_verified",
  "request_id": "4f1c9a02b6d84e7f9c3a5e81d2704b6a"
}

Note the literal detail says workspace — the web app's word for a tenant. Match on error_code, not on this text.

Verify the binding (or switch back to platform storage), then resubmit. For a binding that is verified but unusable, fix it first: re-enter a missing secret with PUT (or PUT a valid account_url), then verify. If instead the platform's own secret storage is briefly unreachable, a submission — text or document — answers 503 with error_code transient_error and secret storage is temporarily unavailable — try again shortly; nothing was created, so retry it shortly.

The same two answers reach a document download, review or approval that has to read or write the files in your account: 409 storage_binding_not_verified — with workspace storage (BYOS) cannot be used: the stored secret for its binding is missing or unreadable — re-enter the SAS token or connection string in Storage settings and verify the binding, or switch the workspace back to platform storage when the secret is gone — or 503 transient_error while the platform's secret storage is unreachable. The job is left unchanged either way.

Text translation: parked and replayed

Review approval while the binding is not verified. Persistence fails closed rather than writing to the platform store. Approving a job — POST /api/translations/{job_id}/review with action approve or modify — after its tenant's binding stopped being verified returns:

HTTP/1.1 409 Conflict

{
  "detail": "workspace storage (BYOS) binding is not verified — verify it in Storage settings and the parked job will publish automatically, or switch the workspace back to platform storage",
  "error_code": "storage_binding_not_verified",
  "request_id": "9e07c1d4b2a84f5e8d3c6b1a0f9e2d47"
}

The job is not lost: it is stored with status persistence_failed and is republished by the next successful POST /api/storage/binding/verify — or, if the workspace switches back instead, by the PUT with {"provider": "platform"} or the DELETE that does it. A submission can meet the same answer, with its job parked the same way, when the binding stops being usable between the up-front check and the publish.

Verified binding, write fails at persist time. The response is:

HTTP/1.1 502 Bad Gateway

{
  "detail": "golden persistence failed: <reason> — the job is parked as persistence_failed and can be retried",
  "error_code": "persistence_failed",
  "request_id": "b3d90f5c-7a41-4e6b-9c02-5f18d47ae3c1"
}

The job is parked the same way. <reason> is your account's own answer — or, when the write that failed went to platform storage, platform storage is temporarily unavailable. A text job that was already being published when its binding's stored secret disappeared parks this way too, quoting the missing-secret sentence; any later submission is refused up front instead. Retry it with POST /api/storage/binding/verify once the account accepts writes again — a platform-storage tenant whose publish failed retries the same way.

Either way, a parked job's translation is complete and readable through GET /api/translations/{job_id}, and it is billed even though the request answered 409 or 502; its replay is never billed again. The exception is a job deleted while its own request was still running: there is nothing left to park, so that request answers 404 not_found (translation job <id> was deleted while it was processing) and nothing is billed.

Document jobs: failed, not parked

There is no parking or replay on the document path. When a document job already under way cannot upload its artifacts to the bound account — the binding was edited (and so unverified) mid-job, access was revoked, the stored secret is gone, storage is unreachable — the job is marked failed with the reason on its failure detail, and its local scratch directory is purged so no tenant bytes linger on the platform volume (files a job had stored on the platform before the tenant bound its own storage are kept, as they are its only copy). Nothing is written to the platform store as a fallback, and nothing is held for later.

That job is not recoverable by verifying the binding: a successful POST /api/storage/binding/verify replays parked text jobs only. Fix the binding, verify it, then resubmit the document — unless its translation had already reached your account. A job whose translated document (or translated layout) was uploaded before a later step failed is billed like any delivered translation, and its failure message says so, ending … was delivered to your storage account at <documents prefix>/<job id>/ and has been billed; it does not need to be resubmitted.

Correlating these errors

These bodies all include error_code; branch on it rather than on the detail text. The full code vocabulary is in Conventions & errors.

The body's request_id is never null: it is the same id as the X-Request-ID response header — the one you sent, or the one the server generated when your request carried none. Quote either when reporting a problem.


Onboarding sequence

  1. The operator switches Bring Your Own Storage on for the deployment, with secret storage configured alongside it — the service will not start without it, even on a keyless-only deployment — and the platform's Azure identity in place if any tenant will use managed_identity.
  2. Customer creates a StorageV2 account with hierarchical namespace enabled and a container.
  3. For managed_identity: customer grants the platform identity (principal_client_id from GET) Storage Blob Data Contributor on that container (or account). For sas/connection_string: customer mints the credential.
  4. As a workspace admin (or with the tenant API key), PUT /api/storage/binding with provider: "adls", the dfs account_url, the container as filesystem, and the chosen auth_mode (plus secret where required).
  5. POST /api/storage/binding/verify and confirm the response has status: "verified". Until it does, the tenant's translation and document submissions are refused with 409 storage_binding_not_verified.
  6. Translate and approve as usual. Re-run step 5 after any subsequent PUT, and after rotating a SAS token (PUT the new secret, then verify).

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 text endpoints that fail closed while a binding is unverified.
  • Documents — the document endpoints that fail closed while a binding is unverified.