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://api.fronset.ai/api/inference/v1/keys, account, vault, capabilities, routing, schemas, validators, prompts, webhooks, usage, quota
https://api.fronset.ai/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.

Your dashboard shows the base URL for your own account; if it differs from https://api.fronset.ai, use the one it shows. It is the same host 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
GET/management/capabilities/{slug}/evaluation-settingsThe criteria it is judged on, and the review window
GET/management/capabilities/{slug}/evaluationsJudged exchanges kept for the review window
GET/management/capabilities/{slug}/evaluations/{id}One judged exchange
GET/management/capabilities/{slug}/verdictsVerdict history on a task of your own
GET/management/capabilities/{slug}/verdicts/summaryVerdicts grouped by model, template or failure mode
GET/management/capabilities/{slug}/verdicts/by-call/{call_id}Every verdict on one of your calls
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 of your own
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 portal for your plan
POST/top-upBuy prepaid credit
GET PUT/auto-rechargeRead / set automatic recharge
POST/payment-method/setup-intentBegin card capture
POST/payment-methodMake a confirmed card your account’s only card

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://api.fronset.ai/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://api.fronset.ai/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://api.fronset.ai/api/inference/v1/management/account \
  -H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY"
{"tenant_slug": "ws-7k2m9q4xpa", "tenant_name": "Acme Inc", "tenant_id": 3,
 "task_prefix": "3.", "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://api.fronset.ai/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_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

A task you create says what it is when it is born: its templates (the prompt texts and the variables they read) and its output (a JSON schema, or text). Optionally its validators and the model group it runs on.

curl -X POST https://api.fronset.ai/api/inference/v1/management/capabilities/ \
  -H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "slug": "press-release-claims",
        "display_name": "Press-release claims",
        "pricing_mode": "byok",
        "templates": [
          {"key": "default",
           "system": "You extract factual claims. {schema_json_string}",
           "user": "Company: {company}\n\nRelease:\n{release_text}",
           "variables": [
             {"name": "company", "required": true, "description": "Issuer name."},
             {"name": "release_text", "required": true, "description": "The full release text."}
           ]}
        ],
        "output_format": "json_schema",
        "output_schema": {"type": "object",
                          "properties": {"claims": {"type": "array", "items": {"type": "string"}}},
                          "required": ["claims"], "additionalProperties": false},
        "validators": [{"name": "json_parseable", "params": {}}],
        "model_group": "us_models",
        "optimize_prompts": true
      }'

A new task starts by exploring: until its models are evidenced, a call that fans out runs several models and a judge picks the answer, and the create response’s exploration block says on which planes a call fans out; narrow the pool with model_group.

Fields you can set:

FieldNotes
slug, display_name, descriptionIdentity. Send the bare name; the task is stored and returned as <account id>.<name> (for example 3.press-release-claims), and that full slug is what you call. Names: lower case, start with a letter, letters, digits, - and _, up to 64 characters. A name that already exists, or folds to the same name with - and _ swapped, is refused with 409. The name of one of our tasks, current or retired, is reserved: it is refused with 409 task_name_taken and reason: "catalogue_name", because <account id>.<our task's name> is kept for your private copy of that task.
templatesRequired. One of three forms. (1) A list of 1 to 10 templates, each {"key", "is_default", "system", "user", "variables", "notes"}: exactly one is the default (the one with "is_default": true, else the first); keys are lower case letters, digits, - and _, unique, and two keys that differ only by - and _ are refused. (2) "passthrough": the task has no text of its own and a /v1/chat/completions call sends messages. (3) {"passthrough": true, "variables": [...]}: the same, with variables your validators read that no prompt renders (each "required": false). A template you register holds instructions, not data: keep personal data out of it on every plan (DPA §3.4).
variablesPer template, [{"name", "required", "description"}]. Every {placeholder} in system and user must be declared with "required": true; a declared name that is not a placeholder (an input your validator reads) is "required": false. {schema_json_string} is filled by us from the task’s schema: use it, never declare it. Names starting llmbench_ are ours. A placeholder your template does not declare is refused at create.
output_formatRequired. "json_schema" with exactly one of output_schema (an inline JSON Schema) or output_schema_ref (a schema you registered), or "text" with neither. Schema or text, never neither.
output_schema / output_schema_refThe schema for output_format: "json_schema".
validatorsOptional [{"name", "params"}], written on the task’s contract and run on every answer. Each must fit the schema (a params.output_field must name a field of it) and read only variables every template declares. The older default_result_validator: "<name>" is still accepted as a one-entry list; sending both is refused.
model_groupOptional. A group slug (all_models, one of our groups such as us_models, or one of yours, <account id>.<name>), or {"name", "models"} to create your group with the task. Omitted: all_models, every eligible model. Selection only ever picks from this group.
modelsThe older model list, kept: it restricts the models the task starts with and stores all_models. Send model_group or models, not both. Your task keeps the pool it was given: a model added to the catalog later is not added for you. GET /management/capabilities/{slug} lists such models under offered_models (models added since the task was created, your own tasks only; [] on ours), and you add one by sending models again with it included. A model you removed is not offered again.
optimize_promptsOptional, default true.
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.
variant_axese.g. ["domain"], matching variant_values on the call.
batch_eligibleWhether batch lines may run in the batch lane.
idempotency_ttl_secondsDefault 86400.
pricing_modebyok (your credentials) or managed (ours; requires a billing account). Defaults to byok.
is_activeDefault true.
judging_sample_per_10kYour own tasks only. How many successful calls in every 10,000 are judged after the fact (post-hoc and inline judging). 0 turns that judging off; null restores the evaluation policy’s default. An integer from 0 to 10000, else 400 invalid_judging_sample. The comparison that picks a fan-out winner is not affected: set fan_out_strategy to never for that. Every judgement is part of what the task passes through at cost. Refused on our tasks (below).

prompts is not accepted on a task you create: send templates. records_to_seed and allow_breaking_change are refused on a create (422 create_field_not_allowed): they apply to a private copy of one of our tasks.

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 task that can serve a call, or it fails with no row persisted, and a refused create does not count against the daily limit on creates. The refusals:

StatuscodeWhen
422invalid_templateAnything wrong with the templates, their variables, or a validator’s fit; problems lists every defect, one per entry. Also sent for prompts.
422output_format_requiredoutput_format missing, json_schema with neither or both schema fields, or text with a schema.
400 / 404—An inline schema that cannot be used (400), or an output_schema_ref that does not exist (404).
422model_group_conflictmodel_group and models together.
400model_group_not_found, model_group_emptyA group slug that does not exist or has no members.
422invalid_model_group_nameA new group’s name outside the grammar, or sent with your account prefix.
422invalid_model_group_membersA new group whose model list is empty or names an unknown model (model_group_error says which).
409model_group_name_takenA new group whose name you already use: name it as a string to use it.
409task_name_takenThe task name, or a prompt name one of its templates would take, is already used.
422create_field_not_allowedrecords_to_seed or allow_breaking_change on a create.
  • 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: slug (the full <account id>.<name>), name (the bare name you sent) and namespace (customer for your own tasks, catalogue for ours), and:

  • templates — each template’s key, whether it is the default, and its declared variables. Never the prompt text.
  • output_format (json_schema or text), output_contracts (the contract keys, ["default"]) and validators (each with the variables it reads).
  • model_group — the group the task runs on.
  • exploration — how the task starts: status, fans_out (native, v1: whether a call on that plane runs several models and a judge), model_group, eligible_model_groups, judging and a notice in words.
  • system_prompt_name / user_prompt_name — on a task of your own, the names your default template’s prompts were written under, returned for reference. An invoke may omit them: the task’s default template runs, and template on the call selects another published template by key.
  • 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. It is always false for a task of your own: a task of your own never pools.
  • notices — messages about the result, never a refusal. For example, when your task’s models all come from one model family (a free and a paid version of the same model count as one family), the response says “This task’s models come from one model family only, so it will not be measured by fan-out: fan-out needs two or more. Calls still run and bill as usual.” The create or change is applied. The same holds for a copy of one of ours narrowed to one model family: it is never compared across models. An empty list otherwise.

On your own tasks, the templates, rubric (evaluation_criteria_override), output schema and variant axes are yours to change. Their verdicts are yours alone and stats_pooled reads false. On a shared task (namespace: "catalogue", one of ours), those are what we measure and publish: a PATCH that changes any of them returns 422 shared_task_locked, names the fields, and changes nothing. To use your own rubric or templates, create your own task, or send the change with allow_breaking_change: true, which makes it on a copy of your own where copies are available (below).

How a task of your own is billed. A task you create is never put on a published rate. On our keys (managed) every model call behind a request passes through at the provider’s rates, alternatives and evaluation calls included, for as long as the task exists; on your keys (byok) your provider bills you for them. Either way the per-call fee applies, once per completed call.

Prompt optimisation (optimize_prompts). With it on (the default), we rewrite a template once per model, per template version and per task, so that it suits that model: we send the template text, the task’s description and evaluation criteria and the target model’s name to an adaptation model, never your variables. The rewrite is cached and keeps every placeholder. The first call per model is slower, because the rewrite runs inside it. On a task billed at cost the rewrite’s tokens are part of that call’s bill; on a task that runs on your keys they are not billed to you, and the rewrite runs on our keys. Turning it off sends the template as written. You can change it on your own tasks only; it cannot be changed on one of ours.

There is no draft or trial state: a capability is servable, and billed, from the moment it is created. is_active: false is the only way to hold one back — an inactive capability cannot be called. The lifecycle field no longer exists: a create or update that sends it (for example "lifecycle": "draft" from an older example) is refused with 422 lifecycle_removed and changes nothing.

Read and list

GET /management/capabilities?q=filing&source=own&limit=50&after=<last_id>

q matches slug or display name. source is own (a task of your own) or system_provided (one of ours). 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, is_active, pricing_mode, batch_eligible, variant_axes, selection_policy_slug, evaluation_policy_slug, default_result_validator (the first of the task’s validators), model_group, output_contracts, prompt_mode, templates (keys and declared variables, never prompt text), optimize_prompts, provenance, namespace, shared, charging, created_at. provenance and namespace say whose task it is: own and customer for a task of your own, system_provided and catalogue for one of ours; shared is true for one of ours. charging says how it is billed: published_rate (one of ours on a published rate), at_cost (charged at the provider’s rates, which every task of your own on our keys is) or own_keys (your provider bills you).

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), all optional. On your own tasks:

  • templates (any of the three forms) adds or edits templates by key; a template you do not send is left as it is. At most 10 in all, counting the ones the task already has. Only "is_default": true moves the default. A new template whose prompt name is already used by something else is refused (409).
  • output_format, output_schema and output_schema_ref are read as one choice over the one stored: "output_format": "json_schema" alone keeps the stored schema; to drop the schema send "output_format": "text" (an output_schema: null or output_schema_ref: "" without it is 422 output_format_required); a schema sent to a text task needs "output_format": "json_schema" beside it.
  • validators replaces the list, and must fit the resulting schema and templates. A schema change that the stored validators no longer fit is refused (422 invalid_template, with the problems); send the new validators with it.
  • prompts still edits the default template’s text, but every placeholder in it must already be declared: declare new variables through templates (else 422 invalid_template).
  • model_group (a slug) changes the group the task runs on; a new group restarts exploring. A call’s selection.model_group does not narrow it: any group other than the task’s own, a narrower one included, is refused with 422 model_group_differs_from_task, or runs on a private task carrying that group when you send create_private_task_for_new_model_group: true.

The response carries the same keys as the create’s 201 body.

On a shared task you may change display_name, description, is_active, idempotency_ttl_seconds and pricing_mode. A shared task keeps no review window, because its evaluation criteria are ours: evaluation_review_days above 0 on a shared task returns 422 shared_task_locked with read_only: true, for every caller; 0 is accepted. A copy made with allow_breaking_change (below) is a task of your own and keeps the window you send. A change to its templates or prompts, output format or schema, validators, rubric, variant axes or model group returns 422 shared_task_locked with the fields it refused and private_copy_available, and nothing in the request is applied — not even the fields you were allowed to change. Sending a value equal to the one already stored is not a change (a rubric is compared without surrounding whitespace and line-ending differences), so a form that sends every field back is fine. allow_breaking_change: true makes such a change on a copy of your own, where copies are available: the refusal’s private_copy_available says whether they are, and while it is false the answer is still 422. A change to the task’s selection_policy_slug, objective, fanout_policy_slug or models needs the same flag. rollout_policy_slug, batch_eligible and optimize_prompts cannot be changed on a shared task at all: 422 shared_task_locked with read_only: true, with or without the flag. judging_sample_per_10k is refused on a shared task, whatever the value and for every caller: 422 shared_task_locked with read_only: true (a shared task’s judging rate is its evaluation policy’s). DELETE /management/capabilities/{slug}/template on a shared task returns 422 shared_task_locked with template_delete: true: the template is ours, and to stop using the task you set is_active to false.

Making a copy of one of our tasks

Send a change that needs a copy, with allow_breaking_change: true, in a PATCH to one of our tasks, and the change is made on a new task of your own, answered with 201. Our task, its evidence and its published rate are unchanged, and calls naming it keep reaching it.

  • copy_name (optional) — the bare name of the copy; it defaults to our task’s name. The copy is stored and returned as <account id>.<name>, and a name you already use is 409 task_name_taken.
  • records_to_seed (optional, an integer from 0 to 25, default 25) — how many evaluations per model the copy starts from: a summary drawn from the cross-customer statistics, which carries no other customer’s content and stays in your account. 0 means none. A value above 25 is refused with 422 records_to_seed_out_of_range, not clamped. The seed informs which model is tried first and never counts toward the copy’s measurement, so the copy is measured, at your cost, like any new task.
  • copy_name or records_to_seed without a copy is 422 private_copy_field_without_copy.

The 201 body is the copy’s: its slug, namespace: "customer", and a private_copy block that says it is never on a published rate (billing: "pass_through": at cost on our keys, billed by your provider on yours), that it does not receive models we add to ours later (receives_new_catalogue_models: false, new_models_offered: true; GET /management/capabilities/{slug} lists them under offered_models), which task it was copied from (copied_from), and when its seed is applied (seed.status). A copy counts toward your daily task-create limit.

When a copy cannot be made, the answer is still 422 shared_task_locked, with private_copy_available: false and copy_refused_reason: not_in_catalogue, inactive_in_catalogue, replaced (a retired task: call its successor), judge_task, no_published_template, template_text_unavailable, bare_contract, unsupported_field:<key> (a change a copy cannot carry), or judging:<code> (judging for the copy could not be set up). Nothing is written.

Evaluation review

What a judge decided about your calls, on a task of your own. Six reads, all GET, all on a management key; a slug that belongs to another account returns the same 404 as one that does not exist.

PathWhat it returns
/management/capabilities/{slug}/evaluation-settingsthe criteria the task is judged on and the review window
/management/capabilities/{slug}/evaluationsthe judged exchanges kept for the review window, newest first
/management/capabilities/{slug}/evaluations/{id}one of them
/management/capabilities/{slug}/verdictsthe task’s verdict history, newest first, beyond the window too
/management/capabilities/{slug}/verdicts/summarythe verdicts of a period, grouped
/management/capabilities/{slug}/verdicts/by-call/{call_id}every verdict on one of your calls, and the comparison it was part of

Evaluation settings. criteria is the text the judge is given and criteria_source says whose it is: own (yours), policy (the evaluation policy’s), catalogue (ours: one of our shared tasks) or default (ours, when none of these is set). rubric_sha256 is the SHA-256 of that text; a verdict reports the hash of the criteria it was judged on wherever that was recorded, so you can tell which verdicts predate a change. review_days is the window, output_retention how long your plan keeps a model’s answer.

Judged exchanges (/evaluations) are kept only while the review window is open (Terms §11), as it is set now: shortening or closing it removes the exchanges older than the new window at once, and opening it again does not bring them back. Each holds what the judge was shown, each candidate’s answer, and its verdicts. A read that is too large or too slow answers 422 read_limit_exceeded: retry later, or ask for fewer with limit. Where the judge was shown no input, input_available is false; on shared_anon and private, where a judge that samples your calls is never given the input, input_note says so. Each candidate carries score (the plain mean of every verdict listed on it) and consensus_score (the mean of the verdicts that count toward the task’s measurement, consensus_basis: "uncorrected", and null unless at least two of them back it, with counted_verdicts). Each verdict carries judge_model_id, failure_mode as the judge wrote it and failure_mode_key normalised, and counted. winner_call_id is the candidate with the highest score; delivered_call_id is the answer your call actually returned. Pages use after=<next_cursor>.

Verdict history (/verdicts) lists every verdict on your task’s calls, past the window, without the exchange itself. Query parameters: since and until (ISO 8601; a bare date is UTC midnight), limit (1 to 100, default 50) and after (the previous page’s next_cursor, opaque; a value that is not one, or one we can no longer read, starts at the first page, so de-duplicate by review_id, which is the same for a verdict on every route). Each row:

FieldMeaning
review_ida stable, opaque id for the verdict
created_at, call_idwhen it was given, and the call it judged (null for a seed, below)
served_model, template_keythe model that answered and the template it ran
sourcewhat produced the verdict: organic (a sampled call), fanout (a comparison of several models’ answers), pool_seed, …
score, failure_modethe score, as the judge gave it or as we corrected it (a correction is not marked and its reason is not returned; no score here is adjusted for a judge’s bias), and {key, raw}: the normalised failure mode beside the judge’s own words
counted, counts_toward_maturity, excluded_reasonwhether the verdict counts toward the task’s measurement, toward maturity, and if not why (excluded, playground, not_absolute, no_score, not_pooled)
judge_modelthe judge’s model
judge_input{shown, note}: whether the judge was shown your call’s input (null where not recorded). On shared_anon and private a judge that samples your calls is never given the input, and note says so; a false without a note gives no reason
reference_call_ids{call_ids, withheld}: the reference examples the judge was shown, as your calls’ ids; one from another account is counted in withheld, never named. null where not recorded
rubric{sha256, source} of the criteria the verdict was judged on. source: own, policy, default, fallback (our generic text for a comparison judged without criteria) or empty, as recorded when the verdict was given; for a verdict recorded before we kept it (labels.rubric: "recovered"), the source of the text it matches today, or null when it matches none
rationale, content_labelthe judge’s reasoning, and whether it is shown: held (shown), outside_window or not_held
labelsfor each fact, how it is known: captured (recorded with the verdict), recovered (rebuilt from records the verdict points to: its call, its judge, the request it was dispatched with), not_recorded or not_held
noteset on a seed

The reasoning (rationale) is shown while the exchange it judged is kept for your review window (the exchanges /evaluations lists), on every plan. Beyond that, on free it is shown while we still hold the answer it judged; on shared_anon and private it is not (content_label: "outside_window"), because the answer it may quote is gone. A verdict whose exchange was not kept for review (a comparison of two answers that were not both kept, for one) reads as beyond the window. A verdict on another account’s call is never listed, and is in no total. A verdict recorded before we kept these details can read not_recorded; every route reports the same row and labels for the same verdict. A seed (source: "pool_seed") is a starting point drawn from statistics when a task is created as a copy: it informs which model is tried first, has no call or content, never counts toward maturity, and is left out of every consensus.

Summary (/verdicts/summary?group_by=model|template|failure_mode, with since and until) groups the counted verdicts of the period. verdicts is every verdict of the period, seeds and Playground verdicts included; counted is those that count toward the measurement; apart gives the seeds and Playground verdicts, which are never counted or grouped. data holds the groups: per group a key naming it (its model, template or failure_mode, by group_by), verdicts, mean_score and the breakdowns by failure mode, model and template. basis: "raw, per verdict": a plain mean of the verdicts’ scores, not the task’s quality estimate. disagreements is {min_spread, calls, top}: calls counts the calls whose judges differ by min_spread (2) points or more, and top lists up to 20 of them with their verdicts. low_scores is {min_gap, inputs, top}: inputs counts the inputs on which a model scored min_gap (2) or more below the best model on the same input, and top lists up to 20, each with its input (kind and the request or source call it groups by), best_mean and the calls behind it.

One call (/verdicts/by-call/{call_id}) returns its verdicts (rows as above), consensus_score (as above), delivered_call_id, and, when the call was one of several models’ answers to the same request, occasion: the candidates that are your calls, with their models and consensus (withheld_candidates counts any that are not, which are never named), judge_pick (the answer the judge scored highest, null when it is not one of yours), delivered_call_id and differs. A malformed id, an unknown one, another account’s call and a call of another of your tasks with no verdict filed under this one all return the same 404 call_not_found.

On one of our tasks these three verdict routes answer 200 {"available": false, "reason": "shared_task", "data": []}: its criteria and its judges are ours, so there is nothing of yours to review. The review never returns a score adjusted for a judge’s bias, or our notes. Errors on the verdict routes: 400 invalid_window or invalid_group_by; 422 read_limit_exceeded when a read is too large or too slow (narrow the window); 503 review_not_configured when the verdict review is temporarily unavailable on our side (nothing on your account changes it; retry later). The summary is not affected.


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://api.fronset.ai/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}'

Fields: slug, description, strategy, quality_threshold, min_confidence, fan_out_strategy, auto_live_fanout and forced_model. A model group is not a policy field: it is set on the task (model_group on the create or PATCH of the task). A policy that sends a model_group is refused with 422 model_group_is_task_level; "model_group": null on a policy PATCH clears one stored before. On your own tasks every one of them is yours, the fan-out fields included, on your keys or ours. On one of our tasks the choice of model and how the task explores are ours: attaching a policy to it is a change that needs allow_breaking_change and is made on a copy of your own where copies are available, and a policy already attached to one of ours cannot be edited (422).

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://api.fronset.ai/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"}'

You send the bare name; the group is stored and returned as <account id>.<name> (here 3.eu-only), and that full slug is what a task names. Reads return slug, display_name, description, members, notes, is_active and is_system. Our groups (all_models, us_models, …) are listed with is_system: true; they are read-only, and a name of yours never redefines one. A group a task runs on cannot be deleted or deactivated (409 in_use); its members may change.

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://api.fronset.ai/api/inference/v1/output-schemas/ \
  -H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "filing_facts",
       "json_schema": {"type": "object",
                       "properties": {"revenue_usd": {"type": "number"}},
                       "required": ["revenue_usd"]}}'

name is optional; omit it and one is derived from the schema’s own hash. The response carries the addressable ref:

{"ref": "schema:filing_facts@1", "name": "filing_facts", "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://api.fronset.ai/api/inference/v1/validators/ \
  -H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "revenue-is-positive", "spec_type": "declarative",
       "spec": { }, "notes": "reject a zero reported revenue"}'

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 for a prompt of your own. A prompt of our catalogue (those our shared tasks and judges run on) answers 404, as a name that does not exist does.

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://api.fronset.ai/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": "3.filing-facts-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. drafts_per_day / drafts_day keep an older name: they cap how many tasks you may create per day — through POST /capabilities, and private copies included. Past the cap a create returns 429 task_create_limit_reached with retry_after_seconds until midnight UTC. The tasks themselves are ordinary tasks — there is no draft state.


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

Published rates exist only for our tasks: some of our catalogue tasks are billed at a published rate of ours instead of the provider’s price passed through, and a task of your own never is — 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 the task’s own group, the group a call on that task with no group of its own keys on (all_models for a task open to every model). requested.model_group names the group quoted.

curl "https://api.fronset.ai/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 not on a published rate is billed at cost: one of ours still exploring, or any task of your own (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

At signup you accept, once, the Terms of Service, the Data Processing Agreement and the data grant of every plan. Moving between plans asks for nothing more; you accept again only when we publish a new version.

GET /consents            # the whole set: every plan's documents
GET /consents?tier=private
{"tier": null,
 "required": [{"kind": "tos", "tier": "", "version": "2026-10-10",
               "url": "/legal/terms/", "title": "Terms of Service"},
              {"kind": "data_grant", "tier": "shared_anon", "version": "2026-10-10",
               "url": "/legal/data-grant/shared-anon/", "title": "..."}],
 "missing": []}

required lists one entry per document, each naming its own tier ("" for the documents that cover every plan). missing is what you have not yet accepted at its current version. Accept with:

curl -X POST https://api.fronset.ai/api/billing/v1/consents \
  -H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"consents": {"tos": "2026-10-10", "dpa": "2026-10-10", "data_grant": "2026-10-10"}}'

One version per kind: the data_grant entry accepts the data grant of every plan. A version that is not the current one is refused, never upgraded: you get 422 with the same missing list, and nothing is recorded. The acceptance is recorded against the authenticating key rather than a fabricated human.

tier narrows either call to one plan’s documents.

Checkout and the billing portal

curl -X POST https://api.fronset.ai/api/billing/v1/checkout-session \
  -H "Authorization: Bearer llmb_m_YOUR_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"plan_code": "shared_anon", "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: you accepted every plan’s documents at signup, so this refuses only after we publish a new version you have not accepted yet, with 422 and missing, and nothing is charged. An unknown plan is 404. An account that already has a plan, or whose billing is still being set up, gets 400, as do an unsellable plan and a Stripe failure.

POST /portal-session with {"return_url": "..."} returns the URL of your plan’s Stripe portal. There you switch between monthly and annual, see invoices, or cancel: monthly to annual applies at once, with credit for the rest of the month; annual to monthly and cancelling apply at the end of the paid period. You can also manage your cards there; the card you make the default pays for credit and for the plan. Saving a card through POST /payment-method makes it your only card. An account with no plan gets 400.

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://api.fronset.ai/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": "ws-7k2m9q4xpa", "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 by card. 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://api.fronset.ai/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://api.fronset.ai/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://api.fronset.ai/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. The card becomes your account’s only card: any other card is removed, and a plan you have bought renews on it. The setup intent accepts cards only; a payment method that is not a card is refused with 400 card_required. 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_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/models gives you catalog identity only. Pricing and per-model quality figures are on the published benchmark.