Base URL and key
- Base URL
- The address of your TerraFlow plane,
https://<your-terraflow-host>. The console's Connect screen shows yours, with snippets ready to copy. OpenAI SDKs take the base URL with/v1on the end; Anthropic SDKs take the host alone. - API key
- Starts with
tf_. Send it the way your SDK already does:Authorization: Bearer tf_…orx-api-key: tf_…. Both are accepted on every/v1endpoint. - Key storage
- TerraFlow stores only a SHA-256 hash of the key; the console shows its first characters, never the key. An admin can rotate it, which shows the new key once and revokes the old one at once.
Keep the key in an environment variable, as every example below does. A missing, wrong or
revoked key is refused with APP_UNAUTHENTICATED (401).
Choosing a workload
A workload is one AI use case with its own signed contract: the data it may carry, the models it may use, its budgets and whether answers need human approval. Every request runs under exactly one.
X-TerraFlow-Workload: <workload-id>chooses it. An unknown id is refused withUNKNOWN_WORKLOAD(403).- Without that header, a
modelthat is a workload id (for examplestarter-assistant) chooses it. - Otherwise your default workload applies.
Pinning a model. A model that is a model's TerraFlow id (such as
anthropic-sonnet) or its vendor id (such as claude-sonnet-5-5) pins that model. The request
is still checked against the rules; if that model is not allowed, the request is refused with the reason, never
silently sent elsewhere. Any other value, including auto, lets TerraFlow choose within the contract.
Read x-terraflow-capability to see which model answered.
OpenAI-compatible: chat completions
POST /v1/chat/completions accepts the Chat Completions body: messages,
stream, tools, response_format, max_tokens or
max_completion_tokens, temperature, user.
curl
curl https://<your-terraflow-host>/v1/chat/completions \
-H "Authorization: Bearer $TERRAFLOW_API_KEY" \
-H "Content-Type: application/json" \
-H "X-TerraFlow-Workload: starter-assistant" \
-H "X-TerraFlow-Data-Class: internal" \
-H "Idempotency-Key: 6f1c2a9e-ticket-4821" \
-d '{"model": "auto",
"messages": [{"role": "user", "content": "In two sentences, what does an AI control plane do?"}]}'
Python · openai
# pip install openai
import os
from openai import OpenAI
client = OpenAI(base_url="https://<your-terraflow-host>/v1",
api_key=os.environ["TERRAFLOW_API_KEY"]) # starts with tf_
reply = client.chat.completions.create(
model="starter-assistant", # a TerraFlow workload, not a vendor model: TerraFlow picks the route
messages=[{"role": "user", "content": "In two sentences, what does an AI control plane do?"}],
extra_headers={"X-TerraFlow-Data-Class": "internal"},
)
print(reply.choices[0].message.content)
Every governed answer also carries a terraflow block in its body: request_id,
outcome, reason_code, capability_id, cost_usd,
savings_usd and review_id.
Anthropic-compatible: messages
POST /v1/messages accepts the Messages body: system, messages,
tools, stream, max_tokens, temperature,
metadata.user_id.
curl
curl https://<your-terraflow-host>/v1/messages \
-H "x-api-key: $TERRAFLOW_API_KEY" \
-H "Content-Type: application/json" \
-H "X-TerraFlow-Data-Class: internal" \
-d '{"model": "starter-assistant", "max_tokens": 300,
"messages": [{"role": "user", "content": "In two sentences, what does an AI control plane do?"}]}'
Python · anthropic
# pip install anthropic
import os
import anthropic
client = anthropic.Anthropic(base_url="https://<your-terraflow-host>", # host only, no /v1
api_key=os.environ["TERRAFLOW_API_KEY"])
reply = client.messages.create(
model="starter-assistant",
max_tokens=300,
messages=[{"role": "user", "content": "In two sentences, what does an AI control plane do?"}],
extra_headers={"X-TerraFlow-Data-Class": "internal"},
)
print(reply.content[0].text)
All endpoints
| Endpoint | Use it for |
|---|---|
POST /v1/chat/completions | OpenAI SDKs and anything OpenAI-compatible |
POST /v1/messages | Anthropic SDKs |
POST /v1/invocations | TerraFlow's native API: options as body fields (workload, data_class, idempotency_key, deadline_ms, …) and the whole decision back in the body, including cost_usd, baseline_cost_usd, savings_usd, timing and evidence_seq. Does not stream. |
GET /v1/models | Your workloads and the chat models in your catalogue |
GET /v1/requests/{id}/evidence | The audit events of one of your own requests |
DELETE /v1/requests/{id} | Cancel a request that is still queued or running |
POST /v1/requests/{id}/feedback | outcome (accepted, rejected, reworked), optional rating and reason_code; no free text |
GET /v1/reviews/{id} | Fetch an answer that was held for human approval, once a reviewer released it |
Headers
Request headers (all optional)
On /v1/chat/completions and /v1/messages. An invalid value is refused with a 400 before
anything is governed, so a typo can never silently lower a protection.
| Header | What it does |
|---|---|
X-TerraFlow-Workload | Chooses the workload (its contract) |
X-TerraFlow-Data-Class | public, internal, confidential or restricted. TerraFlow also scans the request and uses whichever is more sensitive. Undeclared, the request is treated as the most sensitive class the workload accepts |
X-TerraFlow-Sensitive-Types | Comma list of pii, pci, phi, credentials. Adds to what the scan detects: you can raise sensitivity, never hide a detection |
X-TerraFlow-Category | A category from the workload's contract, for workloads that pick a model size by category |
X-TerraFlow-Deadline-Ms | How long you will wait. A request that cannot finish in time ends DEADLINE_EXPIRED (504) |
X-TerraFlow-User | The end user the request is for; honoured only for an app allowed to assert users |
Idempotency-Key | Makes a retry safe (see below) |
X-Correlation-Id, traceparent | Your own ids; recorded in the audit record and echoed back |
Response headers
| Header | Meaning |
|---|---|
x-terraflow-request-id | TerraFlow's id for the request; quote it to find the request and its audit events |
x-terraflow-outcome | EXECUTED (cache hits included), HELD, DENIED, FAILED, EXPIRED, CANCELLED or SHED |
x-terraflow-reason | The reason code, for example PERMITTED, CACHE_HIT, DATA_CLASS_NOT_PERMITTED |
x-terraflow-capability | The model that answered; empty when none did |
x-terraflow-cost-usd | What the request cost at list price for its real usage |
x-terraflow-savings-usd | Estimated cost of the same request on the workload's baseline model, minus the real cost. A per-request estimate: the baseline was not run |
x-terraflow-cache | hit or miss |
x-terraflow-review-id | On an answer held for human approval: the id to fetch it with |
retry-after, x-should-retry | See retries |
What a governed refusal looks like
A refusal is an ordinary HTTP error in the shape your SDK expects, with TerraFlow's reason code inside, so your SDK raises its usual exception. A request refused by its rules never reaches a model, and the refusal is still recorded in the audit record.
/v1/chat/completions · 403
{"error": {"type": "permission_error", "code": "DATA_CLASS_NOT_PERMITTED",
"message": "TerraFlow denied: DATA_CLASS_NOT_PERMITTED",
"terraflow": {"request_id": "req_01m3j…", "reason_code": "DATA_CLASS_NOT_PERMITTED"}}}
/v1/messages · 403
{"type": "error",
"error": {"type": "permission_error", "message": "TerraFlow denied: DATA_CLASS_NOT_PERMITTED"},
"terraflow": {"request_id": "req_01m3j…", "reason_code": "DATA_CLASS_NOT_PERMITTED"}}
The same code is in the x-terraflow-reason header. Common codes:
| Code | Status | What to do |
|---|---|---|
APP_UNAUTHENTICATED | 401 | Send a valid tf_ key; after a rotation, use the new one |
UNKNOWN_WORKLOAD | 403 | Use a workload id from the console's Workloads screen |
DATA_CLASS_NOT_PERMITTED | 403 | The workload does not accept this data class: use a workload that does, or remove the data |
SENSITIVE_TYPE_NOT_ALLOWED_ON_CAPABILITY | 403 | Personal, card or health data, or a credential, that the pinned model may not receive: remove it, or let TerraFlow choose |
NO_POLICY_PERMITTED_ROUTE | 403 | No allowed model may take this request under the rules |
TOOL_NOT_PERMITTED | 403 | A tool that is not on the workload's contract |
VALIDATION_BLOCKED | 422 | The answer failed its checks and was not released |
BUDGET_EXHAUSTED | 429 | A budget has no room: wait for the period to roll over, or have FinOps raise it. Do not retry now |
RATE_LIMITED, QUEUE_SHED | 429 | Wait for Retry-After, then retry |
EVIDENCE_UNAVAILABLE | 503 | The audit record could not be written, so nothing was sent to a model. Retry later |
DEADLINE_EXPIRED | 504 | Raise X-TerraFlow-Deadline-Ms or shorten the request |
Held for approval. When a workload's rules require a person to approve an answer, the outcome is
HELD with reason REVIEW_PENDING. On /v1/chat/completions and
/v1/messages it is a 200 whose answer is a placeholder naming the review id (so SDKs do not break);
on /v1/invocations it is a 202. Fetch the released answer with GET /v1/reviews/{id}.
Every code, its status and the action to take is in the reason-code reference,
generated from docs/public/reason-codes.yaml and docs/public/error-codes.yaml, which the
build checks against the code.
Idempotency and retry-later
- Same key, same body, within 24 hours: the original outcome is returned without running or
charging again, marked
x-terraflow-idempotent-replay: true. - Same key, different body:
IDEMPOTENCY_CONFLICT(409). - Answer no longer available to replay:
IDEMPOTENCY_REPLAY_NO_CONTENT(409), with the original request id. A refused or failed original replays as itself. retry-after(seconds) is set on refusals that a later retry can pass: rate limits, a shed queue, a spend ceiling.x-should-retry: falseis set when an automatic retry cannot succeed soon: a spent budget, an exhausted deadline.
The official SDKs retry 409, 429 and 5xx on their own, and each retry is a new governed request.
They honour x-should-retry: false. To handle retries yourself, set max_retries=0
(Python) or maxRetries: 0 (TypeScript), and send an Idempotency-Key so a retry can never
run or charge twice.
Streaming
Set stream: true as usual; both channels stream server-sent events in their own format.
- Held back, then streamed when the workload needs the whole answer checked first: a customer or public audience, consequence tier T2 or above, an output schema, content rules, or human review. Every header is sent first.
- Passed straight through otherwise. The answer is checked once complete; the outcome and cost are in the console and the request's audit events.
- A request refused before it starts is an ordinary error response, not a stream.
Limits
| Limit | What happens |
|---|---|
| Budgets per workload, business unit and tenant, hourly or monthly, as the contract sets them | BUDGET_EXHAUSTED (429) |
| Per-request cost limit in the contract | BUDGET_REQUEST_LIMIT |
| Throughput cap per workload, in the contract | RATE_LIMITED (429) with Retry-After |
| A model's context window (measured conservatively, in bytes) | Routed to a larger model, or CONTEXT_WINDOW_EXCEEDED |
| Request too large to scan | 413 |
Hosted preview edge: 2 MB request body; per client address 10 sign-in, 120 /v1 and 600 other requests a minute | Refused at the edge |
Not supported
- A public
/v1/embeddingsendpoint, the OpenAI Responses and Assistants APIs, audio, image generation and files. Use Chat Completions or Messages. - Images, audio and files inside a prompt cannot be scanned, so they are treated as restricted data with every sensitive type; a workload that does not accept that refuses them.
- TerraFlow never runs tools: tool calls come back to your code unexecuted, after the same sensitive-data checks as text.
Source of truth: terraflow/gateway/ (openai_router.py,
anthropic_router.py, native_router.py, auth.py) and
terraflow/core/reasons.py.