Skip to main content
This page is for developers whose call to the OpenType API returned something they did not expect. Each entry starts from what you see, an HTTP status and an error code, then gives the cause and the fix, and links to the full page for that code under Problem codes. It covers the failures people hit first: rejected keys, credit and quota refusals, runs that cannot be routed, and retries that do not behave the way you assumed.

Log the request id first

Every response carries an x-request-id header, successful ones included. On a JSON error the same value is in error.request_id. It is the one value that identifies a single request, so log it with the status and the code on every failure, before you change anything.
  • You can send your own x-request-id. The server keeps it when it matches ^[A-Za-z0-9._-]{1,128}$, and otherwise mints req_ followed by 32 hex characters.
  • A few routes can answer with a plain-text body instead of JSON (see Request format). Those bodies carry no request_id, so read the header.
  • Branch on error.code. The message is written for people, and its wording is not something to match on.
This request sets its own id and prints the four values worth logging when it fails:
When you send your own id, the error body echoes it:

Keys and authentication

What you see
Why. The Authorization header carried something that is not an active API key. Any of these gives the same code:
  • The value is not otsk_ followed by exactly 64 lowercase hex characters, 69 characters in all. A truncated copy fails here, and so does the 13-character secret_prefix that key lists show.
  • The key does not exist.
  • The key was revoked. A revoked key gets invalid_credential, not key_revoked: that code only comes back when you try to rotate a revoked key.
  • The key was rotated. The old secret stops working immediately, with no grace period.
  • You sent the key id. A key_... id names a key in URLs; it is not a credential.
Fix. Check the length first: printf %s "$OPENTYPE_API_KEY" | wc -c should print 69. Then compare its first 13 characters with the prefixes and states on the API keys page of the console, or in GET /v1/keys called with a key that holds keys_read. A revoked key cannot be re-enabled and a lost secret cannot be recovered, so create a new key. Retrying with the same value never succeeds.
API keys page listing four keys with their prefixes, scopes, last-used times and active or revoked state
Log the status, the code and the x-request-id, never the secret. See invalid_credential.
What you see: {"error":{"code":"missing_credentials","message":"a bearer credential is required",...}}Why. The request had no Authorization header, used a scheme other than Bearer, or sent an empty token. A common cause is an environment variable that is not set in the process that makes the call, such as a CI job or a container, so the header goes out as Bearer with nothing after it.Fix. Send Authorization: Bearer <key>. The scheme name is case-insensitive. There is no other auth header and no cookie authentication. Print ${#OPENTYPE_API_KEY} in the failing environment to confirm the variable is set.Log the status, the code and the x-request-id. See missing_credentials.
What you see
Why. You called GET /v1/keys/{key_id}, DELETE /v1/keys/{key_id} or POST /v1/keys/{key_id}/rotate with a value starting otsk_ in the path. The server refused it, but the secret has already travelled in a URL, and URLs end up in shell history, proxy logs and access logs.Fix. Treat the secret as leaked and rotate it now. Find its key_ id by matching the first 13 characters of the secret against secret_prefix in GET /v1/keys, then rotate with a key that holds keys_write. Rotation is only available through the API:
The 200 response keeps the same id, name and scopes and carries the new secret, once. The old secret stops working at that moment, so update every place that uses it. Rotating a revoked key answers 409 key_revoked; create a new key instead.Log the x-request-id of the refused call and of the rotation, not the secret. See secret_in_path and Key rotation.
What you see: {"error":{"code":"scope_denied","message":"the session lacks the runs_write scope",...}}. The message names the missing scope.Why. Every route requires one scope, and the credential does not hold it. POST /v1/runs needs runs_write; reading or streaming a run needs runs_read; usage and quota need usage_read; GET /v1/billing needs billing_read.Fix. A key’s scopes are fixed when it is created and cannot be edited, so create a new key that holds the scope, then revoke the old one if you no longer need it. You can only give a key scopes that you hold yourself. In the console, the Send requests set gives runs_write and runs_read.Log the status, the code, the scope named in the message and the x-request-id. See scope_denied and Scopes.

Credit and quota

What you see
Why. Before a run starts, it holds its whole per-request spend ceiling, 20,000 micro-USD ($0.02), even though a typical decision run costs a few dozen micro-USD. If your available credit minus that hold would drop below zero, the run is refused. Available credit is balance_micros: credited, minus spent, minus the holds of runs still in flight, so many runs in parallel need more headroom than one.Nothing is stored: the run and its hold are rolled back. If auto-recharge is on, an attempt is also queued in the background.Fix.
  1. Check the balance on the Billing page of the console, or with GET /v1/billing and a key that holds billing_read:
  2. Add funds from the console or with POST /v1/billing/checkout, between 5and5 and 1,000 in whole cents. You finish the payment on a Stripe-hosted checkout page, and the credit arrives once the payment succeeds, not in the checkout response.
  3. Retry with the same Idempotency-Key. The refused request never used it.
  4. Turn on auto-recharge so a low balance tops itself up. It charges at most once per organization per clock hour.
Log the status, the code and the x-request-id, and alert on this code rather than retrying in a loop. See insufficient_credits and Handling insufficient credits.
What you see
Why. This is a quota refusal, not a rate limit: OpenType has no rate limiter, and POST /v1/runs is the only route that answers 429. Your organization has a spend limit, a token limit, or both, for the current period, which is the current UTC calendar month.
  • Spend: the check compares the run’s per-request ceiling, at most 20,000 micro-USD, with what is left of the period’s spend limit. It does not use the run’s actual cost, so refusals begin once less than 20,000 micro-USD remain, even though a typical run costs far less.
  • Tokens: the estimated input tokens plus max_output_tokens exceed the tokens left in the period.
Fix. Read the limits and what remains with GET /v1/quota (scope usage_read):
A null limit means there is none. For the token code, lower max_output_tokens or trim the input. For the spend code, the remaining spend also counts the holds of runs still in flight, so it recovers a little as those runs settle; otherwise it resets when the next period begins at 00:00:00Z on the first of the next month. Back off instead of retrying in a loop. The refused request never used its Idempotency-Key, so you can reuse it.Log the status, the code, the numbers in the message and the x-request-id. See organization_spend_quota_exhausted, organization_token_quota_exhausted and Spend limits and quotas.

Runs that are accepted, then fail

These errors come back after the run was admitted, so its Idempotency-Key is now tied to a run that stays pending. Retry them with a new key; see a same-key retry stuck at 202.
What you see: {"error":{"code":"no_route_available","message":"no eligible route is available for this request",...}} on a request without "kind": "decision".Why. Neon 1.1 serves decision runs only. A verdict run, which is also what you get when kind is absent, has no model to route to, so it fails every time. Retrying does not help.Fix. Ask the same thing as a decision run. A verdict schema with an enum usually maps to one choice question:
Send it with a new Idempotency-Key. The answer’s choice is the most likely intent, and probabilities gives every option.Log the status, the code and the x-request-id. See no_route_available and Verdict runs.
What you see: {"error":{"code":"decision_unavailable","message":"no decision-capable route is available for this request",...}} on a decision run that passed validation.Why. The model answers each question by putting probability on its labels, and every label must be a single token. Admission checks the count and uniqueness of alternatives but not their length, so a long label is caught only when the run is routed. The same code is returned when the question set does not fit the answer template, or when the request is rejected upstream.Fix. Make every option name and level name a short, common single word, and move the explanation into the description or the instructions:
Multi-word names such as "billing and payments" or "extremely furious" fail this way. Send the corrected run with a new Idempotency-Key. If short labels still fail, keep the x-request-id.Log the status, the code, the question ids and labels you sent, and the x-request-id. See decision_unavailable and Decision questions.
What you see: 413 with code input_too_large.Why. Every run gets an input estimate: ceil(prompt bytes / 4) + ceil(contract bytes / 4). For a decision run the prompt is the state, a string as-is or anything else as its JSON text, and the contract is instructions, questions, draws and think_tokens. There are two ceilings:The message reads "the input estimate of N tokens exceeds the ceiling".Fix. Send only the fields of state that your questions need, shorten instructions and descriptions, and split a large question set across several runs. Send the smaller request with a new Idempotency-Key, since the body has changed. The request body itself is capped separately at 4 MiB, which gives 413 body_too_large.Log the status, the code, the size of the state you sent and the x-request-id. See input_too_large and Limits.
What you see: 504 with code deadline_exceeded. The message may say “before a route was chosen” even when the model call was the slow part, so rely on the code.Why. Each run has a deadline, deadline_ms. For a decision run it defaults to 30,000 plus 120,000 per 262,144 input tokens and is clamped to 1 to 150,000; for a verdict run it defaults to 30,000 and is clamped to 1 to 120,000. The deadline passed during routing or during the model call.Fix. Raise deadline_ms, up to 150,000 for a decision run, or shrink the input, then retry with backoff and a new Idempotency-Key. Set your HTTP client’s timeout to at least 160 seconds so you receive the 504 instead of cutting the connection yourself.Log the status, the code, the deadline_ms you sent and the x-request-id. See deadline_exceeded.

Retries and idempotency

What you see: you resend POST /v1/runs with the same Idempotency-Key and body, and every attempt answers 202:
Why. POST /v1/runs is synchronous: a fresh run returns 200 once it has settled. A 202 is a replay of a stored run that is not finished. When a run fails before any model served it (no_route_available, decision_unavailable, a context input_too_large, deadline_exceeded, budget_exhausted, or a first-attempt provider failure), its hold is released but its state is never moved: it stays pending for good, and so does every replay of that key.Fix. Send the request again with a new Idempotency-Key. Do not poll the pending run: it will not complete. A replay is never charged, so the 202s cost nothing.If you retried while the first request might still have been in flight, wait until its deadline has passed (at most 150 s) and replay once more. A 200 then carries the answer with "replayed": true and is not charged again; a run still pending after its deadline is stuck.Log the Idempotency-Key, the run_id from the 202 body and the x-request-id of every attempt. See Polling and Idempotency.
What you see
Why. Your organization already used this key with a different request. A key’s identity is the normalized input (the decision state, or system and messages), the kind, and the contract (instructions, questions, draws and think_tokens, or the verdict schema). Keys never expire, so a key reused months later still conflicts.max_output_tokens, deadline_ms and capability_hint are not part of the identity. Changing only those and reusing the key does not conflict: it returns the stored run, not a new one.Fix. If this is a retry, resend the original body unchanged. If it is a new request, use a new key. Keys derived from your own business ids work well, with a version when the question changes: ticket-4822-triage-v2. The refused request created nothing and was not charged.Log the Idempotency-Key, the code and the x-request-id. See idempotency_conflict and Idempotency.

Request format

What you see: a text/plain body instead of the JSON envelope, for example 422 with Failed to deserialize the JSON body into the target type: ....Why. The keys, usage, quota and billing routes read JSON, query and path values with the web framework’s own extractors, which answer in plain text. Every request body refuses fields it does not define:For example, keys never expire, so an expiry field is refused:
Fix. Send Content-Type: application/json, valid JSON, and only the documented fields with the documented types. Branch on the HTTP status when the body is not JSON. On POST /v1/runs the same mistakes come back as JSON 400 invalid_body instead.Log the status, the first line of the body and the x-request-id header: these bodies carry no request_id. See Errors.
What you see: {"error":{"code":"invalid_body","message":"the request body is not valid: state is only valid on a decision run",...}}. Every message starts with the request body is not valid: .Why. The body does not fit the contract. The most common causes:
  • "kind": "decision" is missing. kind defaults to verdict, so the body is checked as a verdict run and a decision-only field such as state is refused.
  • A field the run does not define, or a value of the wrong type: the body does not match the schema.
  • max_output_tokens is 0: max_output_tokens must be greater than zero.
  • No Content-Type: application/json header, or malformed JSON.
Fix. Correct the body and send it again. Nothing was stored, so the same Idempotency-Key is fine.Log the status, the full message and the x-request-id. See invalid_body.
Why. The optional model field on a decision run accepts neon-1.1 or neon-latest. Any other value is refused.Fix. Send one of those two ids, or leave model out. Live decision responses report decision.model as neon-1.1.Log the status, the code and the x-request-id. See unknown_model and Models and pricing.

When to retry

Responses carry no retry hint, in the body or in the headers. Decide from the status and the code:
  • Problem codes - every code, its status and the fix, one page each.
  • Errors - the error envelope, plain-text rejections and how to branch on code.
  • Runs - run states, replays, and what a stored run returns.
  • Error handling - a retry helper that picks the right idempotency key for you.
  • Frequently asked questions - short answers on cost, limits, keys and timeouts.