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.
Headers
Section titled “Headers”| Header | Direction | Notes |
|---|---|---|
Authorization: Bearer tanzanite_… |
Request | The tenant key. Backend only. |
X-Tanzanite-Key: tanzanite_… |
Request | Equivalent alternative to the above. |
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. |
Route index
Section titled “Route index”| 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 |
GET |
/v1/sessions/:id/artifacts/:artifactId |
agent.sessions |
GET |
/v1/sessions/:id/artifacts/:artifactId/download |
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/completions |
completions |
POST |
/v1/decisions |
decisions |
GET |
/v1/estimates |
none |
GET |
/v1/wallet |
any spend scope |
Connection
Section titled “Connection”GET /v1/connection
Section titled “GET /v1/connection”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).
DELETE /v1/connection
Section titled “DELETE /v1/connection”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
Section titled “Tickets”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.
POST /v1/stream-ticket
Section titled “POST /v1/stream-ticket”Auth tenant key · Scope none
Mint a ticket for the WebSocket stream.
POST /v1/sessions/:id/embed-ticket
Section titled “POST /v1/sessions/:id/embed-ticket”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.
Streaming
Section titled “Streaming”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.
Sessions
Section titled “Sessions”POST /v1/sessions
Section titled “POST /v1/sessions”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 } |
GET /v1/sessions/:id
Section titled “GET /v1/sessions/:id”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.
POST /v1/sessions/:id/signal
Section titled “POST /v1/sessions/:id/signal”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.
GET /v1/sessions/:id/messages
Section titled “GET /v1/sessions/:id/messages”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.
GET /v1/sessions/:id/artifacts/:artifactId
Section titled “GET /v1/sessions/:id/artifacts/:artifactId”Auth tenant key or embed ticket · Scope agent.sessions
One artifact in full. The stream’s artifact event clips long sources (it sets
truncated); this is where the whole of one is read. artifactId is the event’s
id.
{ "id": "art_2d9…", "title": "Crypt encounter table", "format": "markdown", "version": 2, "content": "…"}format is html, markdown or file. For a file, content is "" and
file carries filename, mimeType and sizeBytes. 404 with
{ "error": "Artifact not found" } when the session has no such artifact.
GET /v1/sessions/:id/artifacts/:artifactId/download
Section titled “GET /v1/sessions/:id/artifacts/:artifactId/download”Auth tenant key or embed ticket · Scope agent.sessions
The artifact as a file. For a file artifact: a short-lived signed link as JSON
({ "url", "filename", "mimeType", "sizeBytes" }) when one can be signed,
otherwise the bytes themselves, with a single Range honoured (206/416). For
an HTML or Markdown artifact: its source as an attachment (text/html or
text/markdown). The SDK’s session.downloadArtifact handles both answers.
Projects
Section titled “Projects”POST /v1/projects
Section titled “POST /v1/projects”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.
GET /v1/projects
Section titled “GET /v1/projects”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.
GET /v1/projects/:id
Section titled “GET /v1/projects/:id”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.
PATCH /v1/projects/:id
Section titled “PATCH /v1/projects/:id”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. |
GET /v1/projects/:id/sessions
Section titled “GET /v1/projects/:id/sessions”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.
POST /v1/media/generate
Section titled “POST /v1/media/generate”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 and audio (music) are 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.
GET /v1/media/jobs/:jobId
Section titled “GET /v1/media/jobs/:jobId”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.
POST /v1/media/vision
Section titled “POST /v1/media/vision”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}Completions
Section titled “Completions”POST /v1/completions
Section titled “POST /v1/completions”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}Decisions
Section titled “Decisions”POST /v1/decisions
Section titled “POST /v1/decisions”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.
Estimates
Section titled “Estimates”GET /v1/estimates
Section titled “GET /v1/estimates”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);
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.
GET /v1/wallet
Section titled “GET /v1/wallet”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.
Latency
Section titled “Latency”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.
Error taxonomy
Section titled “Error taxonomy”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 as a job) in v1. |
Two distinctions worth wiring into your error handling:
host_app_spend_capis notinsufficient_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_sessionsis 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.
Not in /v1
Section titled “Not in /v1”- No session list endpoint. Sessions are listed per project, via
GET /v1/projects/:id/sessions. - No webhooks. Project deletion is discovered through a
404; session progress is discovered by streaming or polling. - No async media jobs, yet. Video and long audio will arrive on
POST /v1/media/generateas a202plus aGET /v1/media/jobs/:id, keyed onmediaType. See whymediaTypeis a field.