Skip to content

API reference

Every endpoint below is on https://api.tanzanite.ai. All of /v1 is versioned: changes are additive only, and anything breaking arrives as /v2. See the two API surfaces.

/v1 serves two kinds of caller with two kinds of tanzanite_… key:

  • A host app — a product that signs its users in with Tanzanite (Connect) and runs Tanzanite sessions for them. Its key is the user’s connection to that app, issued by the Connect exchange, and it reaches the host-app routes: connection, projects, sessions, tickets and the spend primitives, gated by scopes.
  • An agent key — the account holder’s own key for their own software (ADR-0117). It reaches the object plane and the hosted gadget backends, gated by the resource.verb permissions below.
Credential Header Reaches
Session Authorization: Bearer <Firebase ID token> /api/* (the app) and /v1
Agent key Authorization: Bearer tanzanite_agent_… or X-Tanzanite-Key: tanzanite_agent_… /v1 agent-key routes only

A key calling /api/* gets 403 {"error":"not_available_to_api_keys"} — key minting, billing, 2FA, account settings and secrets are not part of the public contract, whatever the key was granted.

Keys are their own records in agentKeys/{id}, managed at /api/agent-keys (list, mint with step-up, revoke). They are not host-app connections and never appear in one.

A key’s grants are a list of resource.verb chosen when it is minted:

{ "permissions": ["gadgets.read", "stores.read", "stores.write", "vaults.read"] }
Resource Verbs Covers
gadgets read, write, delete, deploy Gadget documents, gadget types, per-gadget rate limits
stores read, write, delete Gadget stores and sync floors
vaults read, write, delete The account vault’s cloud copy
artifacts read, write, delete Artifact files and metadata
settings read, write Account settings (server-managed keys are refused)
workstations read, deploy Workstation state (read) and deploy/recall (deploy) — never a running workstation
permissions read The account’s permission policy (read-only)
spend read, write Hosted gadget backends (/v1/llm, /v1/image, /v1/video)

A missing grant is denied with 403 {"error":"insufficient_permission","required":"store.write"}. resource.* and * are accepted. A key cannot widen its own grants, write the permission policy, mint keys, or answer a prompt.

Header Direction Notes
Authorization: Bearer tanzanite_… Request A host app’s connection key (tanzanite_ + hex) or an agent key (tanzanite_agent_…). The two never reach each other’s routes. Backend only.
Authorization: Bearer <ID token> Request A signed-in user’s session instead of a key.
X-Tanzanite-Key: tanzanite_… Request Equivalent alternative to the key header.
X-Tanzanite-Embed-Ticket Request A session-scoped ticket, for browsers. Session routes only.
Idempotency-Key Request Optional on the spend endpoints. Up to 200 characters, valid 24 hours.
Content-Type: application/json Request Required on every body-carrying call.
Method Path Permission
GET /v1/gadgets gadgets.read
POST PATCH DELETE /v1/gadgets[/:id] gadgets.write / gadgets.delete
GET PUT /v1/gadgets/:id/limits gadgets.read / gadgets.write
GET /v1/stores/… stores.read
PUT DELETE /v1/stores/… stores.write / stores.delete
GET /v1/vaults/… vaults.read
PUT DELETE /v1/vaults/… vaults.write / vaults.delete
GET /v1/artifacts/… artifacts.read
POST DELETE /v1/artifacts/… artifacts.write / artifacts.delete
GET PATCH /v1/settings settings.read / settings.write
GET /v1/permissions/policy permissions.read
GET /v1/workstations/:id/state workstations.read
POST /v1/workstations/:id/deploy workstations.deploy

An agent key reads and writes objects; it does not run a workstation — no presence, no live gadgets, no lease.

Object Limit
Gadget document 1 MB
Store document 4 MB
Vault file streamed; per-file cap enforced by the vault lane
Artifact capped by the artifact lane

Route index — host apps and spend primitives

Section titled “Route index — host apps and spend primitives”
Method Path Scope
GET /v1/connection none
DELETE /v1/connection none
POST /v1/stream-ticket none
POST /v1/sessions/:id/embed-ticket none
POST /v1/sessions agent.sessions
GET /v1/sessions/:id agent.sessions
POST /v1/sessions/:id/signal agent.sessions
POST /v1/sessions/:id/human-input/:inputId agent.sessions
GET /v1/sessions/:id/messages agent.sessions
GET /v1/sessions/:id/subagents/:childId/messages agent.sessions
POST /v1/projects projects
GET /v1/projects projects
GET /v1/projects/:id projects
PATCH /v1/projects/:id projects
GET /v1/projects/:id/sessions projects
POST /v1/media/generate media.generate
GET /v1/media/jobs/:jobId media.generate
POST /v1/media/vision media.vision
POST /v1/media/transcribe media.vision
POST /v1/completions completions
POST /v1/decisions decisions
GET /v1/estimates none
GET /v1/usage any spend scope (read)
GET /v1/models any spend scope (read)
GET /v1/wallet any spend scope

Auth tenant key · Scope none

Who this key is. Call it at start-up to confirm a stored key still authenticates and still holds the scopes your feature needs, rather than discovering both from a 403 in front of a user.

{
"id": "conn_9f3…",
"hostAppId": "acme-studio",
"subject": "Qm9Ft4c…",
"name": "Acme Studio",
"status": "active",
"scopes": ["agent.sessions", "projects", "media.generate"],
"hostAppVersion": 3,
"toolCount": 7,
"baseUrl": "https://api.acme.example/v2"
}

status is active, suspended or revoked; only active authenticates. hostAppVersion is the definition version the user consented at — compare it against what a capability needs before you use one that is newer. subject is your app’s stable id for the key’s user, the same one the Connect exchange returned (Connect); a personal access token has none (null).

Auth tenant key · Scope none

Revoke this connection.

{ "id": "conn_9f3…", "status": "revoked" }

Immediate and final. The key stops authenticating, running sessions fail closed at their next round, and there is no un-revoke — the user reconnects through Connect, which mints a new connection.

Call this when a user disconnects your app on your side. You do not need to call it before a reconnect: the platform already revokes a user’s previous connection to the same app.

Tickets exist so a browser can stream a session without ever holding the tenant key. Both are short-lived and session-scoped: a ticket reaches session routes only, and never a connection-scoped one. A widget holding a ticket therefore cannot create sessions, cannot list projects, and cannot spend money on its own — your backend does all of that, then hands the browser a ticket for the one session it should see.

Auth tenant key · Scope none

Mint a ticket for the WebSocket stream.

Auth tenant key · Scope none

Mint a multi-use embed ticket bound to one session. This is what an embedded widget uses: pass it, with the sessionId and the API base URL, to a ticket-only SDK client.

Send it as X-Tanzanite-Embed-Ticket on session routes. Tickets expire; mint a fresh one when the widget reconnects rather than caching one indefinitely.

Session events arrive over a WebSocket at wss://api.tanzanite.ai/v1/stream, authenticated with a stream ticket or an embed ticket. The SDK handles the connection, the reconnect backoff and the resync marker for you; the event shapes are listed there.

Auth tenant key · Scope agent.sessions · Spends

{
"projectId": "proj_7bd…",
"goal": "Add three ambush encounters to the crypt level.",
"context": { "sceneId": "scene_main" },
"guardrails": { "maxSubagents": 2, "requireToolApproval": false }
}
Field Required Notes
projectId Yes The project to run in. There is no implicit project.
goal Yes What the agent should accomplish.
context No Pinned onto every tool call; the pin wins over model-supplied values.
guardrails No maxSubagents, requireToolApproval.

201:

{ "sessionId": "sess_4c1…", "roundId": "round_1", "status": "queued" }

Errors

Status Body
400 { "error": "project_required" }
403 { "error": "insufficient_scope", "scope": "agent.sessions" }
403 { "error": "reconsent_required", "hostAppVersion": 3, "requiredVersion": 4 }
402 { "error": "insufficient_credits" }
402 { "error": "host_app_spend_cap" }
429 { "error": "too_many_sessions", "limit": 3 }

Auth tenant key or embed ticket · Scope agent.sessions

{
"id": "sess_4c1…",
"status": "orchestrating",
"projectId": "proj_7bd…",
"hostAppId": "acme-studio",
"goal": "Add three ambush encounters to the crypt level.",
"cost": {
"totalCost": 0.184,
"totalTokens": 41233,
"promptTokens": 37110,
"completionTokens": 4123,
"ownCost": 0.121,
"subagentsCost": 0.043,
"mediaCost": 0.02
},
"createdAt": "2026-09-10T09:12:04.221Z",
"updatedAt": "2026-09-10T09:14:51.008Z",
"completedAt": "2026-09-10T09:14:51.008Z",
"completionReport": "…",
"context": {
"usedTokens": 42310,
"contextWindow": 200000,
"compactAtTokens": 153600,
"estimated": false,
"compacting": false
}
}

completedAt, completionReport and error are present only when they apply. context is how full the model’s context window is: null until the first LLM call has been measured; compactAtTokens is where the agent compacts on its own (null when compaction is off); estimated is true right after a compaction, before the next call measures it. The stream’s context frame carries the same reading as it changes. cost.totalCost is USD, billed — the whole of what the session cost the user: its own work, every subagent’s, and the /v1 spend you attributed to its tool calls (Tanzanite sends your tool endpoint an X-Tanzanite-Tool-Call token; forward it unchanged on the media, vision, decisions and completions requests you make while serving that call). Each dollar is counted once, so a host app that reads only cost.totalCost has the right total. ownCost, subagentsCost and mediaCost break it down — they sum to totalCost — and each is absent when it is 0 or unknown (mediaCost is absent for a session whose host-app spend predates the breakdown; totalCost still includes it). /v1 spend made without the header belongs to no session and is in none of them. The token counts are the session’s own agent’s. A running session’s cost can trail a few seconds behind its spend; a completed one is final. The sessions list’s cost and the stream’s cost_update / complete totalCost are this same whole figure. status is one of the SDK’s SESSION_STATUSES; complete, failed, cancelled and enqueue_error (the session could not be started) are terminal — treat the list as open and keep a default branch.

404 if the session does not exist or belongs to another connection’s user.

Auth tenant key or embed ticket · Scope agent.sessions

{ "type": "redirect", "message": "Two encounters, not three — the level is short." }
type Extra fields Effect
pause reason Halt after the current step.
resume newBudget Continue; optionally raise the cost budget.
cancel reason Stop for good.
redirect message Push a new instruction into the running session.
compact — Compact the agent’s context now. Running: it compacts after its current step. Idle (complete, failed, cancelled, waiting_human): a compaction starts at once.

redirect is how you say “not that, do this instead” without cancelling and paying for a fresh start.

Every signal answers 202 { sessionId, signal, message }. compact adds mode — "latched" (after the current step) or "started" (with the compaction’s roundId). A refused compact answers 409 { error, code }:

code Meaning
compaction_running A compaction is already in flight.
nothing_to_compact No LLM call has been measured yet.
compaction_disabled Compaction is off for this session.
not_compactable The session’s status allows neither (e.g. paused, waiting_wake).

POST /v1/sessions/:id/human-input/:inputId

Section titled “POST /v1/sessions/:id/human-input/:inputId”

Auth tenant key or embed ticket · Scope agent.sessions

Answer a question the agent asked. inputId comes from the human_input_needed event.

{ "approved": true }

Send choiceId for a selection, freeText for a clarification, approved for a confirmation, an approval or a tool approval. A session that is never answered stays waiting rather than timing out into a decision on your behalf.

Auth tenant key or embed ticket · Scope agent.sessions

Query: limit (rounds per page), after (a previous page’s lastRoundId).

{
"sessionId": "sess_4c1…",
"messages": [
{
"roundId": "round_3",
"role": "assistant",
"content": "…",
"timestamp": "2026-09-10T09:13:02.114Z"
}
],
"hasMore": true,
"lastRoundId": "round_3"
}

role is user, assistant, system or tool; toolName and toolCallId are present on tool messages.

GET /v1/sessions/:id/subagents/:childId/messages

Section titled “GET /v1/sessions/:id/subagents/:childId/messages”

Auth tenant key or embed ticket · Scope agent.sessions

A subagent’s transcript, paged exactly like GET /v1/sessions/:id/messages (same query, same response, with sessionId set to the subagent). childId is the subagentId from the subagent_spawned event.

Authorized through the parent: the caller needs the session (its embed ticket is enough), and childId must be one of that session’s subagents. Anything else is 404 with { "error": "Subagent not found" } — a ticket still cannot reach any other session.

Auth tenant key · Scope projects

{
"externalId": "level_42",
"label": "Level 42 — Crypt",
"url": "https://studio.acme.example/levels/42"
}
Field Required Limit
externalId Yes 200 characters
label Yes 200 characters
url No 2048 characters

Idempotent per connected user × host app × externalId. 201 with created: true for a new project, 200 with created: false when it already existed. Both are success.

{
"project": {
"id": "proj_7bd…",
"name": "Level 42 — Crypt",
"externalRef": {
"hostAppId": "acme-studio",
"externalId": "level_42",
"label": "Level 42 — Crypt",
"url": "https://studio.acme.example/levels/42"
},
"hostAppId": "acme-studio",
"createdAt": "2026-09-10T09:12:04.221Z",
"updatedAt": "2026-09-10T09:12:04.221Z"
},
"created": true
}

The user is part of the idempotency key on purpose — see why the user is in the key.

Auth tenant key · Scope projects

Query: limit.

{ "projects": [ /* ProjectInfo */ ], "limit": 50 }

Only this host app’s projects, for this user. The query is scoped server-side and there is no parameter that widens it.

Auth tenant key · Scope projects

{ "project": { /* ProjectInfo */ } }

A 404 here is the lifecycle signal: the user deleted the project. There is no webhook — drop your mapping and create a fresh project on next use. See project lifecycle.

Auth tenant key · Scope projects

{
"label": "Level 42 — Catacombs",
"url": "https://studio.acme.example/levels/42"
}
Field Required Limit
label At least one of the two 200 characters
url At least one of the two 2048 characters; absolute http(s). null clears it.

Rename an entity. POST /v1/projects never changes an existing project — on a repeat it returns the first project’s fields unchanged, which is what makes it safe to call on every open — so when a user renames the thing on your side, or your deep links move, this is how the new label or url reaches Tanzanite. name follows label. externalId is not writable: it is the idempotency key, and a different id is a different entity. See renaming an entity.

200:

{ "project": { /* ProjectInfo, with the new label and url */ } }

Errors

Status Body
400 { "error": "invalid_request", "message": "…" } — empty patch, a field over its limit, or a url that is not an absolute http(s) URL.
404 { "error": "Project not found" } — the same answer as GET, for a deleted project and for one this connection cannot reach.

Auth tenant key · Scope projects

Query: limit, after (the sessionId of the last row of the previous page).

{
"sessions": [
{
"sessionId": "sess_4c1…",
"status": "complete",
"message": "Add three ambush encounters to the crypt level.",
"createdAt": "2026-09-10T09:12:04.221Z",
"updatedAt": "2026-09-10T09:14:51.008Z",
"completedAt": "2026-09-10T09:14:51.008Z",
"cost": 0.184,
"hostAppId": "acme-studio"
}
],
"hasMore": false,
"limit": 50
}

Newest first, 50 per page by default and 200 at most. message is the goal the session was created with; all three timestamps are ISO 8601, and completedAt is absent while a session is still running.

Auth tenant key · Scope media.generate · Spends · Optional Idempotency-Key

{
"mediaType": "image",
"prompt": "A moss-covered stone archway at dusk",
"model": null,
"size": "1024x1024",
"aspectRatio": null,
"resolution": null,
"quality": null,
"seed": null,
"sourceImage": null,
"projectId": "proj_7bd…"
}

sourceImage is either a URL the platform can fetch, or inline base64 plus mimeType.

With "mediaType": "speech" the body is { "input" (≤ 4000 chars), "model"?, "voice"?, "format"?: "mp3" | "wav", "speed"?, "instructions"?, "projectId"? } and the result carries mimeType (audio/mpeg or audio/wav — the model decides), durationMs, model, voice, chars, costUSD and costSource (provider-actual — OpenRouter’s record of the call — or catalog), plus generationId / provider when known, beside url / storagePath — see Generate speech.

{
"mediaType": "image",
"url": "https://storage.example/signed/…",
"expiresAt": "2026-09-10T10:12:04.000Z",
"storagePath": "media/…/7f3c1e.png",
"mimeType": "image/png",
"width": 1024,
"height": 1024,
"model": "…",
"costUSD": 0.042,
"idempotent": false
}

Synchronous, with a 90-second server-side ceiling. Set your HTTP client timeout above it — the default 30 seconds will abort a healthy call. url expires after 60 minutes; storagePath is the durable handle. Full contract on the Media page.

Errors

Status Body
400 { "error": "unsupported_media_type" } — video is not served in v1.
402 { "error": "insufficient_credits" }
402 { "error": "host_app_spend_cap" }
403 { "error": "insufficient_scope", "scope": "media.generate" }
409 { "error": "idempotency_key_reused" }
409 { "error": "idempotency_in_progress" } — the first request under this key is still running; nothing charged.
504 { "error": "media_timeout", "ceilingMs": 90000 } — nothing charged.

mediaType: "model" is the asynchronous exception: it answers 202 with a job (or 200 with the finished job on an idempotent replay), and the same scope covers polling it. Request, result and error codes are on the Media page; 3D-specific errors are 400 invalid_request, 429 too_many_jobs and 503 model_generation_unavailable. mediaType: "audio" (music and sound effects: kind, prompt, durationSec, loop, bpm, key, layers, channels, format: "wav") is a job of the same shape, on the same poll; see Media. Its own errors are 400 invalid_request, 429 too_many_jobs and 503 media_unavailable; a refused prompt is a failed job (prompt_blocked). mediaType: "motion" (an animation clip on the humanoid skeleton: prompt, duration, loop, inPlace, quality: draft | standard, name) is a job of the same shape too; its result is one GLB (a bare skeleton + one animation) and a summary — see Media. Its own errors are 400 invalid_request, 429 too_many_jobs and 503 motion_generation_unavailable; a refused prompt is a failed job (content_refused), uncharged.

Auth tenant key · Scope media.generate · Free (reads a job; the spend happened at POST)

Returns the job the POST accepted — queued, running, succeeded (with result) or failed (with error) — while it is under 24 hours old. A job that belongs to another connection is 404 job_not_found, the same as one that does not exist. Poll no faster than pollAfterMs; the response carries Retry-After while the job is pending and Cache-Control: no-store.

Auth tenant key · Scope media.vision · Spends

{
"images": [{ "url": "https://storage.example/signed/…" }],
"question": "Does the archway read as passable?",
"measuredContext": "1024x1024 PNG generated from: a moss-covered stone archway at dusk.",
"model": null,
"projectId": "proj_7bd…"
}

images takes 1 to 4 entries. More than four is refused rather than truncated.

{
"text": "…",
"model": "…",
"costUSD": 0.003,
"truncated": false,
"imageCount": 1
}

Auth tenant key · Scope media.vision · Spends · Optional Idempotency-Key

Speech-to-text for a short clip (push-to-talk). The answer is the bare words spoken — no labels, no bracketed events — and "" when nothing was said.

{
"audio": "GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQRChYEC…",
"mimeType": "audio/webm;codecs=opus",
"language": "en",
"prompt": "Brannoc, Eldermoor",
"model": null,
"projectId": "proj_7bd…"
}

audio is bare base64 (no data: prefix), at most 2 MB decoded. mimeType is one of audio/webm, audio/ogg, audio/mp4, audio/wav, audio/mpeg (a ;codecs= parameter and common aliases accepted). language is an optional BCP-47 hint; prompt (≤ 500 characters) lists names and words to expect and is read as a spelling hint, never as instructions. A clip that is too big, empty, not base64 or of an unsupported type is a 400 invalid_request before any spend.

{
"text": "Brannoc, open the gate.",
"language": "en",
"durationMs": 2380,
"durationEstimated": false,
"model": "openrouter/google/gemini-3.5-flash-lite",
"costUSD": 0.0011,
"idempotent": false
}

language echoes the hint (null without one). durationEstimated is true when the container did not state its length and it was estimated from the size. A replay under the same Idempotency-Key answers idempotent: true and costUSD: 0. 502 transcription_failed bills what the failed call spent; past the 90 s ceiling, 504 media_timeout charges nothing. Details: Media → Transcribe speech.

Auth tenant key · Scope completions · Spends · Optional Idempotency-Key

One model call, no harness. For the parts of a host app that want a model but not an agent: a rewrite, a classification, a structured extraction. If the work needs tools, a plan, or more than one turn, create a session instead.

{
"messages": [
{ "role": "system", "content": "You rewrite level descriptions." },
{ "role": "user", "content": "Crypt, dark, three rooms." }
],
"model": null,
"maxTokens": 512,
"temperature": 0.4,
"reasoning": "off",
"jsonSchema": null,
"projectId": "proj_7bd…",
"tag": "level-copy",
"label": "Level 42 · description rewrite"
}

reasoning is "off" by default: the model answers without hidden thinking, which is what a short, awaited reply wants — a reasoning model otherwise spends seconds, and part of your maxTokens, before the first visible word. Set a level (minimal, low, medium, high, xhigh, max; snapped to the nearest one the model offers) to opt in for a call that needs it, and leave room in maxTokens for the thinking. Anything else is 400 invalid_request. Completions are routed to the model’s fastest available host, not its cheapest.

role is system, user or assistant. jsonSchema is sent to the provider as a forced structured output — the difference between parsing the answer and hoping to — but a model can still answer off-schema, so the response says whether it did: structuredResult (the parsed output) with schemaValid, or structuredRaw (the raw text, when it did not parse as JSON) with schemaValid: false. schemaValid is absent when your schema itself does not compile.

tag and label are optional and name the spend on the user’s Usage tab: tag is 1–64 letters, digits, _ - : . (level-copy, or one per thing you serve, like game:abc123); label is one line of at most 120 characters. An invalid one is 400 invalid_request.

{
"content": "…",
"model": "…",
"finishReason": "stop",
"usage": { "promptTokens": 122, "completionTokens": 88 },
"costUSD": 0.0009,
"idempotent": false,
"structuredResult": { "line": "…" },
"schemaValid": true
}

Auth tenant key · Scope decisions · Spends · Optional Idempotency-Key

Fast, typed answers about your app’s state — a game bot choosing its next move between ticks, a classifier in a hot path. Each named question is a noul (yes/no), a choice (one of N) or a score (an ordered rubric), and every answer carries a probability.

{
"requests": [
{
"id": "bot:1",
"state": { "me": { "x": 3, "hp": 2 }, "enemies": [{ "x": 7, "hp": 5 }] },
"questions": {
"move": {
"type": "choice",
"instructions": "Best move now?",
"criteria": { "chase": "close in", "flee": "get away" }
}
}
}
],
"model": null,
"projectId": "proj_7bd…",
"tag": "bot-live",
"label": "Crypt · Hunter · match 3"
}
Field Required Notes
requests Yes 1 to 16 entries — batch the seats of one moment into one call.
requests[].id No Echoed on the result. Unique, 1–64 of letters, digits and _:.-; defaults to the index.
requests[].state Yes A string, object or array; at most 60 000 characters serialized.
requests[].questions Yes An object of named questions.
model No An enabled decision model. Omit for the platform default.
projectId, tag, label No The project to bill under, and the Usage-tab attribution — as on completions.
{
"model": "…",
"results": [
{
"id": "bot:1",
"ok": true,
"answers": {
"move": { "type": "choice", "choice": "flee", "probabilities": { "chase": 0.18, "flee": 0.82 }, "confidence": 0.82 }
},
"latencyMs": 212,
"truncated": false,
"backend": "…"
}
],
"costUSD": 0.0004,
"idempotent": false
}

One result per request, in order. A request that fails is reported in its own result as { "id", "ok": false, "error", "code"? }; the others still answer, and the batch is billed once.

Auth tenant key · Scope none

What one call would cost the connected user, billed — for a price you show before a user says yes. Spends nothing.

Query: kind=decision with stateChars (default 4 000), kind=completion with promptTokens (default 1 000) and completionTokens (default 300), kind=speech with chars (default 1 000; a mediaType: "speech" call), or kind=vision with promptTokens (default 2 000, the images included) and completionTokens (default 700) — priced on the model POST /v1/media/vision would use for the same model (the platform’s vision model when none is named), or kind=transcribe with seconds (default 15; a POST /v1/media/transcribe clip, priced from its audio tokens per second on the model that call would use and answered as { "kind": "transcribe", "model", "perCallUSD", "basis": { "seconds" } }), or kind=audio with layers (default 1; a mediaType: "audio" job, every layer at the music model’s catalog price plus the prompt screen — what the job holds — answered as { "kind": "audio", "model", "perCallUSD", "basis": { "generations", "perGenerationUSD" } }), or kind=motion with quality (draft | standard, default standard; a mediaType: "motion" job: one keyframe-writer call of ~6k prompt + ~4k completion tokens, answered as { "kind": "motion", "model", "perCallUSD", "maxUSD", "basis": { "quality" } } — maxUSD is the hold; the quality picks the model); each with an optional model.

{
"kind": "decision",
"model": "…",
"perCallUSD": 0.0003,
"basis": { "stateChars": 4000 },
"typicalLatencyMs": 180
}

typicalLatencyMs is present for a decision model with a measured latency. A speech estimate adds "pricing" (per_character, per_second, per_token, …) and basis: { chars, estimatedSeconds }; per-second and per-token models are estimated at a typical narration pace (≈ 15 characters a second), and the actual charge uses the measured audio. Any other kind, or a model that is not enabled, is 400 invalid_request.

Auth tenant key · Scope any spend scope

What this connection can spend of the user’s credits right now — for a credits check before you start something billed. Spends nothing.

{
"availableUSD": 12.5,
"connectionDailyRemainingUSD": 3,
"accountDailyRemainingUSD": null,
"accountMonthlyRemainingUSD": 79.5,
"autoReload": { "enabled": true, "thresholdUSD": 1, "amountUSD": 10 },
"spendableUSD": 3
}

availableUSD is credits − reserved − spent (it can be negative). connectionDailyRemainingUSD is the daily cap the user set on this connection, less today’s spend and in-flight holds; the account fields are the user’s own daily and monthly caps, likewise. null means no such cap. spendableUSD is the least of those that apply; with autoReload on, the wallet also counts its reload amount (a wallet under its threshold tops itself up on the next spend). A call is still refused if the money is gone by the time you make it.

Warm up before a burst. GET /v1/wallet and GET /v1/estimates also load, on the instance that answers, what the first paid call of a burst would otherwise load on its own latency (the payer’s billing checks, the model catalogs, prices). Call one of them when a user is about to start something that makes calls in quick succession — a game with bots that think — rather than letting the first call pay for it. It spends nothing.

Where the time went. Every /v1 answer carries a Server-Timing header: queue (event-loop wait), rl_ip / auth / rl_conn (the limiters and the key lookup), the route’s own stages — on POST /v1/decisions and POST /v1/completions: consent, project, idem_claim, routing, admit, hold, model, settle, idem_finish — and handler (the total). All in milliseconds. model is the provider call; the rest is Tanzanite. Log it next to your own timing when a call feels slow.

Every error body carries a machine-readable error field and a human-readable message. Branch on error, never on the message text.

error Status Retryable Remedy
insufficient_scope 403 No The connection never held the scope. The user must reconnect granting it.
reconsent_required 403 No Send the user through Connect again.
project_required 400 No Create a project and pass projectId.
invalid_request 400 No The body failed validation; message names the field. Fix the request.
too_many_sessions 429 Yes Wait for an in-flight session to finish.
insufficient_credits 402 No The user must add credits.
host_app_spend_cap 402 No (today) This connection’s daily cap is spent. Not “add credits”.
billing_unavailable 503 Yes The balance could not be read. Retry.
media_timeout 504 Yes Outran the 90-second ceiling. Nothing charged. Retry with the same Idempotency-Key.
idempotency_key_reused 409 No Same key, different body. Pick a fresh key.
idempotency_in_progress 409 Yes The first request under this key is still running. Nothing charged. Retry with the same key for its result.
unsupported_media_type 400 No Only image and speech (and model and audio as jobs) in v1.

Two distinctions worth wiring into your error handling:

  • host_app_spend_cap is not insufficient_credits. The wallet has money; this connection is not allowed to spend more of it today. Rendering “add credits” sends the user to the wrong screen.
  • too_many_sessions is not a rate limit. It is a concurrency ceiling, and it clears when one of your sessions finishes rather than when a time window rolls over.

A 429 that is not too_many_sessions is a request-rate limit — 1 200 per minute per IP before authentication, 300 per minute per connection after. Respect Retry-After when it is present.

  • No session list endpoint. Sessions are listed per project, via GET /v1/projects/:id/sessions.
  • No webhooks. A deleted object is discovered through a 404; session progress is discovered by streaming or polling.
  • No video yet. It will arrive on POST /v1/media/generate as a 202 plus a GET /v1/media/jobs/:id, keyed on mediaType, the way 3D models and audio did. See why mediaType is a field.