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.

ScopeKey looks likeAuthenticates
inferencellmb_i_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/v1/ (OpenAI-compat) and the native invocation, package and batch routes
managementllmb_m_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXeverything 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:

PrefixManagement-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/

MethodPathWhat it does
GET/management/keysList this account’s API keys
POST/management/keysIssue a key (plaintext returned once)
POST/management/keys/{key_id}/rotateRotate a key (new plaintext returned once)
DELETE/management/keys/{key_id}Revoke a key
GET/management/accountAccount identity and posture
GET/vault/providersList 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/secretsList named secrets (metadata only)
PUT/vault/secrets/{name}Store or rotate a named secret
DELETE/vault/secrets/{name}Revoke a named secret
GET/management/capabilitiesList 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}/promotePromote a draft to active
GET POST/management/selection-policiesList / create a named selection policy
GET PATCH DELETE/management/selection-policies/{slug}Read / update / delete one
GET POST/management/model-groupsList / create a model group
GET PATCH DELETE/management/model-groups/{slug}Read / update / delete one
GET/management/modelsList 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}/deactivateRetire a version
GET POST/validators/List / register a result validator
GET/validators/{name}Read one
POST/validators/{name}/deactivateDeactivate 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}/rotateRotate the signing secret
DELETE/management/webhook-endpoints/{endpoint_id}Deactivate an endpoint
GET/usageUsage aggregates
GET/quota/Limits, current counters, window resets

/api/billing/v1/

MethodPathWhat it does
GET/plansThe sellable plan catalog
GET/ratesWhich 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}/historyEvery published rate for the task — in force, replaced or scheduled
GET/rates/{task}/quoteWhich published rate a request of a given shape resolves to
GET/subscriptionThis account’s billing state
POST/checkout-sessionStart a Stripe Checkout for a plan
GET/consentsWhat a tier requires, and what you still owe
POST/consentsAccept consent documents
POST/portal-sessionOpen the Stripe Billing Portal
POST/top-upBuy prepaid credit
GET PUT/auto-rechargeRead / set automatic recharge
POST/payment-method/setup-intentBegin card capture
POST/payment-methodSave 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.

  • 400api_key is empty, config carries 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:

  • verified vs is_active. is_active means the key is stored and will be used. verified means the last authentication ping actually succeeded. Nothing on the serving path reads verified — a key that fails a probe may still complete a call — so treat it as advisory, not as “broken”.
  • cache_lag_seconds is 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:

FieldNotes
slug, display_name, descriptionIdentity. slug is what you call.
modelsOmit 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.
objectivebest, cheapest_good_enough or fastest. Mutually exclusive with selection_policy_slug.
selection_policy_slugA policy you created (see below).
rollout_policy_slug, fanout_policy_slugOptional policy pointers.
evaluation_criteria_overrideYour own judging rubric. See the note below — this one has consequences.
default_result_validatorName of a registered validator.
variant_axese.g. ["domain"], matching variant_values on the call.
batch_eligibleWhether batch lines may run in the batch lane.
idempotency_ttl_secondsDefault 86400.
prompts{"system": {"name": ..., "content": ...}, "user": {...}}.
output_schema / output_schema_refAn inline JSON Schema, or a ref you already registered.
pricing_modebyok (your credentials) or managed (ours; requires a billing account). Defaults to byok.
lifecycledraft or active. Default active.
is_activeDefault 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 models list 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 with dropped_models naming each model and its reason.
  • If the capability is byok and the resolved pool contains no provider you have vaulted, the create returns 422 with missing_providers as 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 omit name these 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 and stats_pooled reads false. 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:

rolemeans
deliveredthe call whose output you received
explorationan alternative candidate run on the same route
judgean evaluation call comparing candidates
unattributeda 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_usd is a string. A JSON float is a rounding bug with a customer attached.
  • idempotency_key is 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 with replayed: 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:

StatuscodeMeans
400amount_out_of_rangeThe amount is missing, unparseable, or outside the permitted per-purchase range
400missing_idempotencyidempotency_key was omitted
400monthly_ceiling_exceededThis purchase would take you past your account’s monthly ceiling on credit purchases. Purchases count from the moment they are booked
503no_payment_methodNo card on file. A purchase would be booked and could never be collected, so we refuse it instead
503not_provisionedYour billing account is still being set up — the same condition billing_not_ready reports on the data plane
503not_configuredAn operator-side setting is missing. Not your request
503vendor_error / topup_failedThe 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 PUT never writes suspended_reason, and no route on this page clears one. Two reasons — rate_limited and grant_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:

SurfaceError 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_hour300 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/models gives you catalog identity only. Pricing and per-model quality figures are on the published benchmark.