API Reference: Control Plane
Everything you can do with a management-scope key: API keys, account, provider credentials, capabilities, routing policies, schemas, validators, webhooks, usage and billing.
API reference: control plane
The control plane is everything you do around inference: issuing and rotating API keys, vaulting your provider credentials, configuring capabilities and routing, registering output schemas and webhook endpoints, reading usage and quota, and managing billing.
It is a separate plane from the OpenAI-compatible endpoint, with a separate key type.
Two key types, two planes
Every key looks like llmb_ + a one-letter scope tag + _ + a 43-character random secret.
| Scope | Key looks like | Authenticates |
|---|---|---|
| inference | llmb_i_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX | /v1/ (OpenAI-compat) and the native invocation, package and batch routes |
| management | llmb_m_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX | everything on this page |
Both use Authorization: Bearer <key>. Scope is not interchangeable. Presenting an inference key on a control-plane route returns 403 (key scope not permitted for this route), not 401 — you are authenticated, you are just on the wrong plane. A missing, unknown, revoked or expired key, or an inactive account, returns 401.
We store only a SHA-256 digest of the key. The plaintext is returned exactly once, in the response to the call that created or rotated it. A lost key cannot be recovered — only revoked and replaced. Everywhere else we show a hint of the form llmb_i_…wxyz (the first seven characters and the last four); that hint is not a credential and cannot be used to authenticate.
Base URLs
The control plane spans two path prefixes, both reached with a management key:
| Prefix | Management-key routes cover |
|---|---|
https://<host>/api/inference/v1/ | keys, account, vault, capabilities, routing, schemas, validators, prompts, webhooks, usage, quota |
https://<host>/api/billing/v1/ | plans, subscription, consents, checkout, credit and payment methods |
/api/inference/v1/ is a shared prefix, not a management-key mount. The routes listed below sit alongside inference-key routes (invocations, batches, packages) and operator-only routes, so a path on that prefix that is not in the tables below may reject your key. GET /management/models is yours; GET /models is a different, operator-only route on the same prefix. /api/billing/v1/ likewise carries operator-only routers beside the customer billing routes.
<host> is the host you were given at signup — the same one whose /v1/ your OpenAI SDK points at.
Trailing slashes are literal. Some routes end in / and some do not; the tables below show the exact path. A POST to the wrong variant is not silently redirected.
Every route acts on the tenant that owns the key. There is no tenant parameter anywhere, and you cannot read or write another tenant’s rows.
Route index
/api/inference/v1/
| Method | Path | What it does |
|---|---|---|
GET | /management/keys | List this account’s API keys |
POST | /management/keys | Issue a key (plaintext returned once) |
POST | /management/keys/{key_id}/rotate | Rotate a key (new plaintext returned once) |
DELETE | /management/keys/{key_id} | Revoke a key |
GET | /management/account | Account identity and posture |
GET | /vault/providers | List vaulted provider credentials (metadata only) |
PUT | /vault/providers/{provider_slug} | Store or rotate a provider key |
DELETE | /vault/providers/{provider_slug} | Revoke a provider key |
GET | /vault/secrets | List named secrets (metadata only) |
PUT | /vault/secrets/{name} | Store or rotate a named secret |
DELETE | /vault/secrets/{name} | Revoke a named secret |
GET | /management/capabilities | List your capabilities (paged) |
POST | /management/capabilities/ | Create a capability |
GET | /management/capabilities/{slug} | Read one capability |
PATCH | /management/capabilities/{slug} | Update a capability |
POST | /management/capabilities/{slug}/promote | Promote a draft to active |
GET POST | /management/selection-policies | List / create a named selection policy |
GET PATCH DELETE | /management/selection-policies/{slug} | Read / update / delete one |
GET POST | /management/model-groups | List / create a model group |
GET PATCH DELETE | /management/model-groups/{slug} | Read / update / delete one |
GET | /management/models | List model slugs a group can be built from |
GET POST | /output-schemas/ | List / register a JSON output schema |
GET | /output-schemas/{name} | Latest active version |
GET | /output-schemas/{name}/versions/{version} | A pinned version |
POST | /output-schemas/{name}/versions/{version}/deactivate | Retire a version |
GET POST | /validators/ | List / register a result validator |
GET | /validators/{name} | Read one |
POST | /validators/{name}/deactivate | Deactivate one |
GET | /management/prompts/{name} | Read a prompt |
PUT | /management/prompts/{name} | Governed — see below (returns 405) |
GET POST | /management/webhook-endpoints/ | List / register a webhook endpoint |
POST | /management/webhook-endpoints/{endpoint_id}/rotate | Rotate the signing secret |
DELETE | /management/webhook-endpoints/{endpoint_id} | Deactivate an endpoint |
GET | /usage | Usage aggregates |
GET | /quota/ | Limits, current counters, window resets |
/api/billing/v1/
| Method | Path | What it does |
|---|---|---|
GET | /plans | The sellable plan catalog |
GET | /rates | Which tasks are on a published rate, since when, and what changed last |
GET | /rates/{task} | The rate in force for every published key of one task |
GET | /rates/{task}/history | Every published rate for the task — in force, replaced or scheduled |
GET | /rates/{task}/quote | Which published rate a request of a given shape resolves to |
GET | /subscription | This account’s billing state |
POST | /checkout-session | Start a Stripe Checkout for a plan |
GET | /consents | What a tier requires, and what you still owe |
POST | /consents | Accept consent documents |
POST | /portal-session | Open the Stripe Billing Portal |
POST | /top-up | Buy prepaid credit |
GET PUT | /auto-recharge | Read / set automatic recharge |
POST | /payment-method/setup-intent | Begin card capture |
POST | /payment-method | Save a confirmed card as the default |
Authorisation on all of the above is by key scope. Two surfaces additionally require a named grant on the key — PUT /management/prompts/{name} and the learning routes under /management/learning — and the learning routes are also behind operator feature flags. Everything else in the tables needs only a management key.
Keys and account
List keys
curl https://<host>/api/inference/v1/management/keys \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY"
Each entry carries id, scope, name, display_hint, created_at, last_used_at, expires_at, is_active and a grants list. No key material.
Issue a key
curl -X POST https://<host>/api/inference/v1/management/keys \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"scope": "inference", "name": "prod-worker"}'
scope is inference or management; anything else is 422. The 201 body is:
{
"key": { "id": 42, "scope": "inference", "name": "prod-worker",
"display_hint": "llmb_i_…wxyz", "is_active": true, "grants": [] },
"token": "llmb_i_EXAMPLE_TOKEN_NOT_A_REAL_KEY_xxxxxxxxxx"
}
token appears here and nowhere else. The response is sent with Cache-Control: no-store. Store it before you close the connection.
Rotate a key
POST /management/keys/{key_id}/rotate retires the old key and returns a new one in the same {key, token} shape, also no-store. A key id that does not belong to your account returns 404, identical to a key id that does not exist.
Revoke a key
DELETE /management/keys/{key_id} returns 204.
Revoking your last active management key is refused with 409. Because we hold only a hash, that revoke would lock you out of your own control plane with no way back. Issue the replacement first, then revoke. A key counts as a usable sibling only if it is both active and unexpired. Inference keys carry no such guard — you can revoke all of them.
Account
curl https://<host>/api/inference/v1/management/account \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY"
{"tenant_slug": "acme", "tenant_name": "Acme Inc", "kind": "customer",
"tier": "free", "is_active": true,
"active_management_keys": 2, "active_inference_keys": 5}
Identity and posture only. Usage is on /usage; balance is deliberately not here.
Provider credentials (the vault)
If a capability runs in bring-your-own-key mode, we call the provider as you, using a key you supply. This is where you supply it.
Store or rotate a provider key
curl -X PUT https://<host>/api/inference/v1/vault/providers/openai \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"api_key": "sk-EXAMPLE-NOT-A-REAL-KEY", "config": {}}'
This stores the key and enables bring-your-own-key routing to that provider for your account, then runs an authentication ping against it. It does not change whether that provider is available to you in managed mode — that is an operator setting, not yours to write. config is for non-secret provider settings only.
- 400 —
api_keyis empty,configcarries a key we do not recognise, or that provider uses out-of-band authentication and cannot take a bring-your-own key at all. - 404 — no such provider.
- 409 — that provider is not yet available in your account’s catalog. A stored key would activate nothing; contact support.
A PUT over an existing credential supersedes rather than overwrites: the new key becomes the serving one and the previous key moves to a draining state, kept only for as long as some batch the provider already accepted is still pinned to it. That is why one provider can legitimately show two rows for a while.
List credentials
GET /vault/providers returns metadata only — never the key, never ciphertext:
[{"provider_slug": "openai", "is_active": true, "state": "active",
"key_hint": "f4a2", "config": {},
"verified": true, "verified_at": "2026-08-20T09:12:04Z", "last_error": "",
"created_at": "2026-08-01T10:00:00Z", "rotated_at": null,
"last_used_at": "2026-08-24T07:55:00Z", "cache_lag_seconds": 300}]
key_hint is the last four characters of the stored key, with nothing else around them.
Two fields are worth understanding:
verifiedvsis_active.is_activemeans the key is stored and will be used.verifiedmeans the last authentication ping actually succeeded. Nothing on the serving path readsverified— a key that fails a probe may still complete a call — so treat it as advisory, not as “broken”.cache_lag_secondsis the upper bound on how long a revocation or rotation takes to be reflected in an already-warm worker.
state is active or disabled_pending_drain. Retired rows are audit history and are not listed.
Revoke a credential
DELETE /vault/providers/{provider_slug} returns the row it deactivated. The row itself is retained for audit; the secret stops being usable.
Named secrets
GET /vault/secrets, PUT /vault/secrets/{name} (body {"value": "..."}), DELETE /vault/secrets/{name} are exact mirrors of the provider trio for non-provider secrets. Reads return name, is_active, key_hint, timestamps and cache_lag_seconds — never the value.
Capabilities
A capability is the named unit of work you put in the model field of a request. It is not a specific LLM. This is where you create and configure them.
Create
curl -X POST https://<host>/api/inference/v1/management/capabilities/ \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "invoice-extract",
"display_name": "Invoice field extraction",
"objective": "cheapest_good_enough",
"models": ["gpt-5.6", "claude-sonnet-5"],
"output_schema_ref": "schema:invoice@1",
"batch_eligible": true,
"lifecycle": "draft"
}'
Fields you can set:
| Field | Notes |
|---|---|
slug, display_name, description | Identity. slug is what you call. |
models | Omit and the pool is seeded with every eligible model in the catalog — the vault is not consulted, so the wired pool can name providers you have never vaulted. Supply a list to restrict it; a slug whose provider you have not vaulted is dropped with reason not_vaulted. |
objective | best, cheapest_good_enough or fastest. Mutually exclusive with selection_policy_slug. |
selection_policy_slug | A policy you created (see below). |
rollout_policy_slug, fanout_policy_slug | Optional policy pointers. |
evaluation_criteria_override | Your own judging rubric. See the note below — this one has consequences. |
default_result_validator | Name of a registered validator. |
variant_axes | e.g. ["domain"], matching variant_values on the call. |
batch_eligible | Whether batch lines may run in the batch lane. |
idempotency_ttl_seconds | Default 86400. |
prompts | {"system": {"name": ..., "content": ...}, "user": {...}}. |
output_schema / output_schema_ref | An inline JSON Schema, or a ref you already registered. |
pricing_mode | byok (your credentials) or managed (ours; requires a billing account). Defaults to byok. |
lifecycle | draft or active. Default active. |
is_active | Default true. |
Two fields are operator-owned and rejected with 422 if you send them: evaluation_policy_slug and human_evaluation_policy_slug. Your rubric is yours; how much evidence must exist before a model is believed is not a per-account setting.
A create either produces a capability that can serve a call, or it fails with no row persisted. There are two refusals:
- If you supplied an explicit
modelslist and nothing survived it — unknown slug, a provider you have not vaulted, or a model missing a flag the capability needs — the create returns 422 withdropped_modelsnaming each model and its reason. - If the capability is
byokand the resolved pool contains no provider you have vaulted, the create returns 422 withmissing_providersas data. One covering provider is enough; partial coverage passes, because selection filters per model at call time.
The 201 body echoes the resolved configuration, including two fields you will need:
system_prompt_name/user_prompt_name— the names your prompts were actually written under. When you omitnamethese are derived from the slug, and they are what you pass on each invoke.stats_pooled— whether this task’s quality evidence joins the cross-tenant pool, and therefore whether its bands come from the pool or from your own calls alone.
Editing the rubric takes you off the pool. Writing
evaluation_criteria_override(or changing prompts, schema or variant axes) means the task is no longer measuring the same thing as everyone else’s, so from that point its verdicts are yours alone andstats_pooledreadsfalse. That is not a downgrade — it is what makes your rubric mean anything. It does mean your bands restart from your own volume.
Draft, then promote
lifecycle: "draft" mints a capability that is fully servable but not yet promoted. Exercise it, then:
curl -X POST https://<host>/api/inference/v1/management/capabilities/invoice-extract/promote \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY"
Promotion stamps the promotion time and re-reconciles the model pool against the current catalog. An already-active capability returns 409.
Read and list
GET /management/capabilities?q=invoice&source=own&limit=50&after=<last_id>
q matches slug or display name. source is own or system_provided. The page is {"data": [...], "has_more": bool, "last_id": "<slug>"}; pass last_id back as after. An after value that matches nothing simply anchors past everything below it — it is not an error.
List rows are a light projection: slug, display_name, description, lifecycle, is_active, pricing_mode, batch_eligible, variant_axes, selection_policy_slug, evaluation_policy_slug, default_result_validator, provenance, created_at.
GET /management/capabilities/{slug} returns the fuller record including the wired model pool. A slug that belongs to another account returns exactly the same 404 as one that does not exist — the body never lets you tell the two apart.
Update
PATCH /management/capabilities/{slug} takes the same fields (minus slug and lifecycle), all optional. prompts is patchable, so a typo in a prompt no longer means recreating the capability under a new slug.
Routing: selection policies and model groups
The per-call selection block narrows routing for one request. Storing that same object under a name is all a selection policy is — attach it to a capability and your calls carry no selection block at all.
curl -X POST https://<host>/api/inference/v1/management/selection-policies \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"slug": "cheap-but-good", "strategy": "relative",
"quality_threshold": 0.9, "model_group": "eu-only"}'
Fields: slug, description, strategy, quality_threshold, model_group, and the bring-your-own-key-only trio min_confidence, fan_out_strategy, auto_live_fanout, plus forced_model. The BYOK-only fields are accepted here but refused when you attach the policy to a managed capability — one policy can serve capabilities of both modes.
Reads add two derived fields: is_active, and is_customer_managed. is_customer_managed: false means a service-managed default — readable and shareable, but not writable through this API.
DELETE /management/selection-policies/{slug} returns 409 if the policy is attached to a capability. Detach it first.
Model groups are the same shape:
curl -X POST https://<host>/api/inference/v1/management/model-groups \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"slug": "eu-only", "members": ["gpt-5.6", "claude-sonnet-5"],
"display_name": "EU-served models"}'
Reads return slug, display_name, description, members, notes, is_active and is_system.
To discover the slugs a group is built from:
GET /management/models
[{"slug": "gpt-5.6", "display_name": "GPT-5.6", "provider": "openai",
"supports_batch": true, "supports_vision": true}]
Catalog identity only — no pricing and no per-model statistics. Those live on the published benchmark.
Errors on this whole surface are {"error": "...", "code": "..."} with 404 not_found, 409 conflict / in_use, and 422 otherwise.
Output schemas and validators
Register a JSON output schema
curl -X POST https://<host>/api/inference/v1/output-schemas/ \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "invoice",
"json_schema": {"type": "object",
"properties": {"total": {"type": "number"}},
"required": ["total"]}}'
name is optional; omit it and one is derived from the schema’s own hash. The response carries the addressable ref:
{"ref": "schema:invoice@1", "name": "invoice", "version": 1,
"schema_hash": "…", "source": "…", "is_active": true}
Put that ref in a capability’s output_schema_ref, or in a per-call response_format. Registering the same document again returns 200 with the existing ref rather than minting a new version. A construct outside the supported subset returns 400 with a pointer at the offending location.
POST /output-schemas/{name}/versions/{version}/deactivate retires a version from new resolution; already-pinned references to it are unaffected.
Register a result validator
curl -X POST https://<host>/api/inference/v1/validators/ \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "total-is-positive", "spec_type": "declarative",
"spec": { }, "notes": "reject zero-value invoices"}'
spec_type is declarative or named_builtin. The spec is compiled and validated at write time by the same interpreter the invoke path uses, so a bad spec fails here rather than at 3am. The name is what a capability puts in default_result_validator.
POST /validators/{name}/deactivate returns 200 with an empty error string on success, 404 if no active validator by that name exists.
Prompts
GET /management/prompts/{name} returns name, content, content_hash, version_counter, template and is_active.
Writing is governed. PUT /management/prompts/{name} returns 405 with code: "governed_release_required", pointing at the learning-release surface; without a config:direct_edit grant on your key it returns 403 first. Prompt content for your own capabilities is set through POST /management/capabilities/ and PATCH /management/capabilities/{slug}, which is the supported path.
Webhook endpoints
Registered endpoints are the only way a webhook URL enters the system, and they are the only place batch lifecycle events are delivered. With zero active endpoints, zero events are sent — there is no fallback target, so poll GET /v1/batches/{id} instead.
curl -X POST https://<host>/api/inference/v1/management/webhook-endpoints/ \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/llmbench", "description": "batch events"}'
{"id": 7, "url": "https://example.com/hooks/llmbench",
"description": "batch events", "is_active": true,
"secret": "whsec_EXAMPLE_NOT_A_REAL_SECRET"}
secret is returned only on create and rotate. GET /management/webhook-endpoints/ never re-exposes it, and it is not recoverable — rotate mints a new one.
Verify deliveries with that secret. Each POST carries X-Timestamp (unix seconds) and X-Signature: sha256=<hex>, where the HMAC-SHA256 is computed over the bytes "<timestamp>." + <raw body>. Compute it over the raw body before parsing.
POST /management/webhook-endpoints/{endpoint_id}/rotate returns the new secret once. DELETE /management/webhook-endpoints/{endpoint_id} deactivates the endpoint. Both return 404 for an unknown id and 422 for anything else.
Usage
GET /usage?from=2026-08-01&to=2026-08-24&group_by=capability
from and to are required ISO dates and the window may not exceed 92 days. group_by is a comma-separated list of capability, model, day, role.
{"rows": [{"key": "invoice-extract", "calls": 1204,
"list_cost_usd": 3.4102, "actual_cost_usd": null,
"input_tokens": 812004, "output_tokens": 96110,
"cached_input_tokens": 0, "reasoning_tokens": 12044}],
"totals": {"calls": 1204, "list_cost_usd": 3.4102, "actual_cost_usd": null,
"input_tokens": 812004, "output_tokens": 96110,
"cached_input_tokens": 0, "reasoning_tokens": 12044}}
With more than one axis, key is the parts joined by | in the order you asked for, and each row additionally carries key_parts as a map.
actual_cost_usd is always null for a customer account. It is an operator-side reconciliation figure that is meaningless for a bring-your-own-key account, so it is withheld rather than approximated.
The role axis splits calls four ways, which is how you separate warm-up from steady state:
| role | means |
|---|---|
delivered | the call whose output you received |
exploration | an alternative candidate run on the same route |
judge | an evaluation call comparing candidates |
unattributed | a call not linked to a route — report it if you see a non-zero figure |
Optional status accepts terminal (success or failure, excluding in-flight) or one call status. The default is no filter, so in-flight rows are included.
An unknown or duplicated axis returns 400 with a single message naming the accepted axes.
Quota
GET /quota/
{"limits": {"schema_version": 1,
"qps_inference": 10, "qps_management": 5,
"calls_day": null, "calls_month": null,
"spend_usd_day": null, "spend_usd_month": null,
"drafts_per_day": 100, "control_mutations_per_hour": 300,
"golden_runs_per_day": null},
"counters": {"calls_day": 411, "calls_month": 9822,
"spend_usd_day": 4.11, "spend_usd_month": 98.2,
"drafts_day": 1, "fanout_spend_usd_day": 0.84},
"resets": {"day": "2026-08-25T00:00:00+00:00",
"month": "2026-09-01T00:00:00+00:00"}}
null means unlimited. Decimal limits are rendered as strings. fanout_spend_usd_day is reported but never enforced — exploration width is decided by policy, not by a spend counter. Counter reads are best-effort; if they fail, counters comes back empty rather than reporting zero.
Billing
All billing routes are on /api/billing/v1/ and take a management key.
Plans and current state
GET /plans returns the catalog. Read is_purchasable — it is a map keyed by interval, not a boolean:
[{"code": "private", "tier": "private", "name": "Private",
"description": "Private-tier plan", "is_active": true, "sort_order": 30,
"prices": [{"kind": "flat", "interval": "month", "currency": "usd",
"unit_amount": 29900, "stripe_price_id": "price_EXAMPLE"}],
"is_purchasable": {"month": true, "year": false},
"has_monthly_price": true, "has_annual_price": false, "features": {}}]
The amounts and codes above are illustrative. Additional fields may be present on any response object on this page — parse leniently and key off the fields you need.
A plan can be listed and yet not checkoutable for one interval. Render your buy button from is_purchasable[interval], never from the presence of a price. When no amount is published, unit_amount is null — there is no zero and no placeholder.
GET /subscription never 404s. No subscription row means the free tier:
{"plan_code": null, "tier": "free", "status": null,
"billing_status": null, "current_period_end": null, "cancel_at_period_end": false}
Published rates
Some catalogue tasks are billed at a published rate of ours instead of the provider’s price passed through — the pricing terms §1.3 say how such a rate is set and corrected. These four reads are that section’s “published … through the API”: they show what the meter would stamp, from the same table it reads, and nothing else.
GET /rates lists every task with a rate in force right now:
{"unit": "usd_per_1m_tokens", "as_of": "2026-09-14T16:00:00Z", "count": 2,
"tasks": [{"task": "atomic-fact-claim-extraction",
"on_published_rate_since": "2026-08-25T00:00:00Z",
"latest_change": "2026-09-08T00:00:00Z", "next_change": null, "keys": 240,
"lanes": ["batch", "sync"],
"context_bands": ["0-200k", "200k-272k", "272k-524k", "524k-1m", "1m+"],
"quality_steps": ["0.75", "0.80", "0.85", "0.90", "0.95", "1.00"],
"eligibility_floors": ["HIGH", "LOW", "MEDIUM", "RANKED"],
"model_groups": ["all_models"]}]}
A task that is not in this list is billed as the pricing terms describe while it explores — at the provider’s own price, every model call in the chain. next_change is set when a change has been published ahead of the day it takes effect.
GET /rates/{task} returns the rate in force for each published key — the five axes a rate is published on. Filter on any of them (?lane=batch&context_band=0-200k):
{"task": "atomic-fact-claim-extraction", "unit": "usd_per_1m_tokens",
"as_of": "2026-09-14T16:00:00Z", "count": 1,
"rates": [{"lane": "batch", "context_band": "0-200k", "quality_step": "0.90",
"eligibility_floor": "MEDIUM", "model_group": "all_models",
"input": {"rate": "0.115000", "effective_from": "2026-09-08T00:00:00Z"},
"output": {"rate": "0.690000", "effective_from": "2026-09-08T00:00:00Z"}}]}
Three things about that shape:
- Amounts are strings, in US dollars per 1,000,000 tokens. A JSON float is a rounding bug with a customer attached.
- Input and output are dated separately. They are two separately published prices and one can be corrected without the other, so a single date for the pair would be wrong about one of them.
- A rate that no request can be priced at is not shown. Only the lanes the meter can stamp (
sync,batch) appear.
GET /rates/{task}/history is every rate ever published for the task, newest effective date first, with the same filters. Each entry is in_force, replaced (a later rate for that key and direction has taken over) or scheduled (published ahead of its effective day), and carries published_at and, on a correction, the rate it replaces:
{"lane": "batch", "context_band": "200k-272k", "quality_step": "0.80",
"eligibility_floor": "MEDIUM", "model_group": "all_models", "direction": "in",
"rate": "0.116000", "effective_from": "2026-09-08T00:00:00Z",
"published_at": "2026-09-08T11:23:37Z", "status": "in_force",
"replaces": {"rate": "0.128000", "effective_from": "2026-09-02T00:00:00Z"}}
That is the record of the pricing terms’ commitment to correct a rate in both directions — a rate that ran high comes down, one that ran low goes up — and a replaced rate stays readable, because it is what priced the usage of its period. A change is published before it applies: effective_from is the following midnight UTC or later, so published_at precedes it. (Rates released before 14 September 2026 were dated the midnight of their release day and read the other way round.)
GET /rates/{task}/quote answers a different question: which published rate would a request of this shape resolve to? It runs the same resolver the meter runs. quality_step and eligibility_floor resolve upward to the nearest published rung; context_band and lane are exact; model_group defaults to all_models, the pool a request with no group keys on.
curl "https://<host>/api/billing/v1/rates/atomic-fact-claim-extraction/quote?quality_step=0.88&eligibility_floor=MEDIUM&context_band=0-200k&lane=sync" \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY"
{"task": "atomic-fact-claim-extraction", "unit": "usd_per_1m_tokens", "as_of": "...",
"requested": {"quality_step": "0.88", "eligibility_floor": "MEDIUM",
"context_band": "0-200k", "lane": "sync", "model_group": "all_models"},
"priced": true,
"resolved": {"lane": "sync", "context_band": "0-200k", "quality_step": "0.90",
"eligibility_floor": "MEDIUM", "model_group": "all_models",
"input": {"rate": "...", "effective_from": "..."},
"output": {"rate": "...", "effective_from": "..."}}}
resolved names the key the price was actually published under — here the request’s 0.88 resolved to the published 0.90. priced: false means no published rate satisfies the shape; that is not “free” — a task still exploring is billed at cost (pricing terms §1.2). A task with no published rate at all is 404 on the three per-task routes.
The list is service-wide: every plan pays the same rates, so every management key reads the same list.
Consents
GET /consents # defaults to your current tier
GET /consents?tier=private
{"tier": "private",
"required": [{"kind": "tos", "tier": "", "version": "2026-08-01",
"url": "/legal/terms/", "title": "Terms of Service"}],
"missing": []}
missing is exactly what to accept next. Accept with:
curl -X POST https://<host>/api/billing/v1/consents \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"tier": "private", "consents": {"tos": "2026-08-01", "dpa": "2026-08-01"}}'
A version that is not the current one is refused, never upgraded: you get 422 with the same missing list. The acceptance is recorded against the authenticating key rather than a fabricated human.
tier is a target, not your current tier — you accept the target tier’s documents before the tier moves.
Checkout and the billing portal
curl -X POST https://<host>/api/billing/v1/checkout-session \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"plan_code": "private", "interval": "month",
"success_url": "https://app.example.com/ok",
"cancel_url": "https://app.example.com/cancel"}'
Returns {"url": "..."} to redirect to. Consent is verified before Stripe takes any money: if you have not accepted the target tier’s documents, you get 422 with missing, and nothing is charged. An unknown plan is 404; an unsellable plan or a Stripe failure is 400.
POST /portal-session with {"return_url": "..."} returns a Stripe Billing Portal URL for the account.
Buying credit
Usage is prepaid: you buy credit and calls draw it down. A 5.5% purchase fee applies to each credit purchase, with a minimum of $0.80 — charged on the purchase, never on your usage. Credit is valid for 12 months, oldest spent first. See the pricing terms for the authoritative statement.
curl -X POST https://<host>/api/billing/v1/top-up \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"amount_usd": "100.00", "idempotency_key": "topup-2026-08-24-a1"}'
Two things about this endpoint:
amount_usdis a string. A JSON float is a rounding bug with a customer attached.idempotency_keyis required and must be yours. A server-generated key would turn your own retry into a second purchase. Repeating a key never charges again. If the original purchase succeeded you get 200 carrying it again withreplayed: true, answered from our own ledger without going back to the payment vendor. If it never completed there is no record to replay, so the key is free to be used: retrying with it is the correct thing to do, and it is what finally records the purchase if the vendor took it and we failed to write it down. Do not retry under a fresh key — a fresh key is a fresh purchase.
{"tenant": "acme", "credit_usd": "100.00", "charged_usd": "105.50",
"fee_usd": "5.50", "fee_basis": "percentage",
"edit_id": "edit_EXAMPLE", "replayed": false}
credit_usd and charged_usd are different numbers and the difference is the point. credit_usd is what you can spend; charged_usd is what your card is billed, which is the credit plus the purchase fee. fee_basis says which half of max(amount x 5.5%, $0.80) bound.
No balance is echoed back. The figure this endpoint could return is an admission cache rather than an authoritative balance, and under a payment gate the credit is not spendable until collection succeeds — so a balance here would state a number that is not yet true. edit_id is empty on a replay.
This endpoint raises on every failure; it never degrades to a 200. Each failure carries a stable code:
| Status | code | Means |
|---|---|---|
| 400 | amount_out_of_range | The amount is missing, unparseable, or outside the permitted per-purchase range |
| 400 | missing_idempotency | idempotency_key was omitted |
| 400 | monthly_ceiling_exceeded | This purchase would take you past your account’s monthly ceiling on credit purchases. Purchases count from the moment they are booked |
| 503 | no_payment_method | No card on file. A purchase would be booked and could never be collected, so we refuse it instead |
| 503 | not_provisioned | Your billing account is still being set up — the same condition billing_not_ready reports on the data plane |
| 503 | not_configured | An operator-side setting is missing. Not your request |
| 503 | vendor_error / topup_failed | The payment vendor refused or could not be reached. Nothing was bought |
daily_cap_exceeded is not in that list, and its absence is deliberate: the daily cap is a bound you set on automatic top-ups, and it is checked only on those. A purchase you make yourself is bounded by the per-purchase range and your account’s monthly ceiling. The monthly ceiling is per account, not a system figure: it bounds payment exposure while an account is new, it is shown on the Billing page of the portal (and echoed by GET /auto-recharge below), and to have it raised, contact support. null there means your account carries no monthly ceiling.
Automatic recharge
GET /auto-recharge returns your settings and the limits that apply to you — the per-purchase range is system-wide, monthly_ceiling_usd is your account’s own — so you never have to discover a bound through a refusal:
{"enabled": false, "threshold_usd": null, "amount_usd": null, "daily_cap_usd": null,
"monthly_ceiling_usd": "2000.00", "min_usd": "5.00", "max_usd": "500.00",
"suspended_reason": "", "suspended_until": null, "has_payment_method": true,
"mandate": {"published": true, "version": "2026-08-01",
"url": "/legal/off-session-mandate/",
"title": "Off-session payment mandate", "accepted": false}}
The mandate block is here because nothing else surfaces it: an off-session mandate is not a signup precondition, so neither consent route names it.
curl -X PUT https://<host>/api/billing/v1/auto-recharge \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"threshold_usd": "20.00", "amount_usd": "100.00",
"off_session_mandate_version": "2026-08-01"}'
Three rules travel with this call:
- Turning automatic recharge on is how you give the off-session mandate, and turning it off withdraws it. A call that enables it for an account not already holding the current mandate must carry
off_session_mandate_version; without it the call is refused, the refusal names the version to accept, and nothing changes. Omit it when disabling, or when re-writing settings for an account that already accepted. - Your recharge amount must be at least twice your threshold. Below that it is a treadmill: the grant lands, the balance is still under the floor, and the next call arms another charge.
- This endpoint does not clear a suspension. A settings
PUTnever writessuspended_reason, and no route on this page clears one. Two reasons —rate_limitedandgrant_slots_exhausted— describe an external condition and are cleared by a background re-check once that condition has lifted. Every other reason (card_declined,card_unusable,card_reported,authentication_required,attempts_exhausted,consent_missing,insufficient_funds) is not cleared by anything on this API; contact support to have it lifted.
PUT /auto-recharge returns 404 for an account not enrolled in prepaid billing. GET /auto-recharge does not — an unenrolled account gets 200 with enabled: false and null settings.
Saving a card
Two calls, in order:
# 1. begin capture
curl -X POST https://<host>/api/billing/v1/payment-method/setup-intent \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY"
# -> {"setup_intent_id": "seti_EXAMPLE", "client_secret": "seti_EXAMPLE_secret_xxx"}
# 2. after confirming with Stripe.js
curl -X POST https://<host>/api/billing/v1/payment-method \
-H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"setup_intent_id": "seti_EXAMPLE"}'
# -> {"payment_method_id": "pm_EXAMPLE"}
Nothing is stored until step 2 sees a succeeded intent. A saved card does not by itself authorise an automatic charge — the off-session mandate above is a separate act, and the charge gates on both.
Errors and limits
The control plane does not use the OpenAI error envelope. Bodies here are:
| Surface | Error body |
|---|---|
| Vault, keys, account, usage, prompts | {"error": "..."} |
| Selection policies, model groups, webhooks | {"error": "...", "code": "..."} |
| Capabilities | {"error": "...", "code": "...", "slug": "...", "dropped_models": [...], "missing_providers": [...]} |
| Output schemas, validators | {"error": "...", "pointer": "..."} |
| Billing credit routes | {"error": "...", "code": "...", "payment_reference": "..."} |
Any 429 on /api/inference/v1/ | {"detail": "...", "code": "...", "retry_after": <int>} plus a Retry-After header |
One throttle applies to the routes on this page: control_mutations_per_hour — 300 writes per hour, counted per account (every management key you hold shares the one budget), against unsafe methods (POST, PUT, PATCH, DELETE) on the write routers. The window is a fixed clock hour, not a sliding one. Reads do not consume it, and /usage, /quota and /management/account carry no throttle at all. A breach returns 429 tenant_rate_limited with Retry-After: 3600 — the length of the window, not a measured wait.
qps_management is returned by GET /quota/ and governs nothing on this plane: it bounds the browser console’s own reads, per account. qps_inference is the data plane’s, and is described under Rate limits and quotas in the data-plane reference.
Call and spend quotas — calls_day, calls_month, spend_usd_day, spend_usd_month — are enforced on the inference path, not here. You can read all of them, and your current counters, at GET /quota/; a null limit is unlimited.
Honour Retry-After — it is an integer number of seconds, and it is always present on a 429 from /api/inference/v1/.
What is not on this plane
- Inference itself. Chat completions, batches and files are on
/v1/with an inference key. - Signup. Registration, email verification and provisioning are a separate anonymous funnel and are not part of the control plane.
- Team members and roles. Seats, roles and invitations are not managed through this API.
- Model performance data.
GET /management/modelsgives you catalog identity only. Pricing and per-model quality figures are on the published benchmark.