# Craton error reference

Every refusal the API makes has one shape, whatever route made it and whatever
went wrong. This page is the specification of that shape: the envelope, every
field in it, every code this build reports, and the rules that say what can
change under you and what cannot.

Write the failure path once. It is the same path for all of them.

---

## The envelope

A response with an HTTP status of 400 or above carries this body — always,
including the ones raised before any handler runs (an unknown path, a method a
route does not serve, a body that would not parse) and including a `500`.

```json
{
  "schema_version": "0.1.0",
  "error": {
    "status": 401,
    "code": "unauthenticated",
    "message": "this endpoint requires an API key: send 'Authorization: Bearer <your key>'"
  }
}
```

The formal specification is `error.v0.schema.json`, published open source
alongside the risk object schema and consumable without a Craton account. The
API contract document `api-contract.v0.json` states, in machine-readable form,
that this schema governs *every* status of 400 or above from *every* route,
and lists the codes below under `errors.named_codes`. Both files are generated
from the running server, not maintained by hand.

Both are served by this documentation site, with no account and no key:
[/schema](/schema) lists everything published,
[/schema/error.v0.schema.json](/schema/error.v0.schema.json) is the envelope
above, and [/schema.json](/schema.json) is the canonical risk object. They are
the same files Craton validates its own responses against — so when a refusal
below names a schema, that is a document you can fetch and read, not a path
inside a repository you do not have.

### Fields

| Field | Type | Always present? | Meaning |
| --- | --- | --- | --- |
| `schema_version` | string, `major.minor.patch` | yes | Version of the error schema this body conforms to. `0.1.0` today. |
| `error` | object | yes | The refusal. Nothing else is at the top level. |
| `error.status` | integer, 400–599 | yes | The HTTP status, restated in the body so a client that has lost the envelope — a log line, a queued webhook payload, a failed job record — still knows what happened. |
| `error.code` | string, `^[a-z][a-z0-9_]*$` | yes | Stable machine-readable identifier for the failure. Switch on this. |
| `error.message` | string, non-empty | yes | What went wrong, in the words a developer needs to fix the call. Human-readable, not machine-readable: do not match on it. Never a secret, never a stack trace. |
| `error.details` | array of non-empty strings | no | Present when one request failed for several reasons at once — a document with three schema violations names all three. Absent when there is nothing to add beyond the message. |

No other member appears at either level: the schema sets
`additionalProperties: false` on both objects, so a body carrying anything else
is a product bug and the contract oracle fails on it.

`details` in use — the same envelope, one member richer:

```json
{
  "schema_version": "0.1.0",
  "error": {
    "status": 422,
    "code": "invalid_request",
    "message": "a signup request reads one optional member, 'label'",
    "details": ["unknown member: nope"]
  }
}
```

Two headers carry information the body does not: `WWW-Authenticate: Bearer` on
a `401`, and `Retry-After` (seconds) on a `429`.

A refusal is a fact about a *request* — malformed, unauthenticated, outside a
verb's scope. It is never a statement about risk appetite and never a
commercial position of any kind.

---

## The codes

Every code this build reports. `status` is the HTTP status (and
`error.status`) the code arrives with.

| Code | Status | What it means |
| --- | --- | --- |
| `account_cancelled` | 402 | The account cancelled its plan, so its usage is no longer metered and the billable verbs no longer run for it. Usage recorded before the cancellation is still settled, on a final invoice raised at the moment of cancelling; nothing after it is metered or billed. Cancellation is not reversible: sign up again for a new account and a new key. |
| `client_error` | any other 4xx | A refusal at a 4xx status this build names no more specific code for. Read `status`. |
| `currency_conflict` | 422 | The ingest request declared a `currency` the file itself contradicts — its cells or column headers state a different code, or several. Ingest neither overwrites what a file says nor ignores what you said, so it stores no object and the message names both sides. Re-submit without the field to keep the file's own currency, or correct the code. Reported on the ingest job, not on the request that created it: what a file states is a fact about its bytes. See [the quickstart](quickstart.md) for when to declare a currency at all. |
| `feed_not_servable` | 403 | The pinned snapshot exists and its manifest, coverage and digest are readable, but this build does not serve its content: feeds are aggregate only — never individual-level records — and content is served only for declared aggregation levels that cannot carry one. 403 rather than 404 on purpose: the snapshot is there, and saying so is the honest answer. `GET /feeds` marks each version `content_servable` ahead of time. |
| `internal_error` | 500 | Craton failed at something that is not the request's fault. Recorded for repair; the same request may work on a retry. |
| `invalid_composition` | 422 | A model submitted to the builder is not in the v1 composition vocabulary: an unknown feed, measurement, transform or payout function, or one of the four missing. v1 logic is declarative only, so a composition the vocabulary does not cover is refused rather than executed. The message names the part that could not be read; for a feed it also lists the ones that are pinned, as [the pinned data feeds](feeds.md) does. |
| `invalid_model` | 422 | A composed model does not validate against the published model object schema — step 1 of the automatic validation pass. `details` names each violation. |
| `invalid_request` | 400, 422 | The request itself could not be read or accepted: an unparseable body, a member the route does not take, a malformed value. 400 when the bytes are wrong, 422 when they parse but the content is not acceptable. On `PATCH /objects/{id}` this is also how the route answers a block it does not write — `exposure`, `id`, `schema_version`, `status` and `provenance` are read-only there, and `details` names the one place each of them does come from. *What `PATCH /objects/{id}` writes, and what it does not* on [the price page](price.md) is the same table, readable before you send the call. |
| `invalid_risk_object` | 422 | A risk object submitted to the API does not validate against the published risk object schema, which this site serves at [/schema.json](/schema.json) — the file the message names. `details` names each violation, and where the schema can say what to write instead it does: the block that keeps a member you put elsewhere, the real name when yours was close to one, and the kind a member takes when it arrived as another. A violation inside `trigger` ends at **[the trigger object](trigger.md)**, which shows both branches and [the three near misses](trigger.md#three-near-misses-and-what-the-refusal-says-about-them). |
| `invalid_structure` | 422 | A structure could not be cut from the object: the request varies a block a structure does not vary (it varies `trigger`, `financial_structure` and `period` only), or the resulting structure is missing the peril, trigger or financial structure a verb needs to evaluate it. Raised on `POST /objects/{id}/structures` itself, not on a job. |
| `method_not_allowed` | 405 | The path exists but does not serve that HTTP method. |
| `model_validation_failed` | 422 | The composition is in the vocabulary but the automatic validation pass refused it, so the model was not created. SPEC §2A makes that pass mandatory on creation, so a model that fails it does not exist — there is no id to retry against. The message names the part that refused. |
| `no_event_catalogue` | 404 | No pinned snapshot holds an event catalogue of what was asked for: a peril nothing is pinned for, a feed id or a vintage this build does not hold, or a peril whose only record is a gridded daily series rather than a set of events — this build will not manufacture events out of one. The message names the perils, feeds and vintages that do answer, and `GET /feeds` lists every snapshot behind them. See [the hazard record behind a result](events.md). |
| `not_found` | 404 | No such route, or no such object, job or account *for the presented key*. Objects and jobs belong to the key that created them, so another key gets 404 rather than 403: the existence of your work is yours. |
| `outside_feed_coverage` | 422 | The peril is one this build answers hazard lookups for, but no pinned snapshot of it covers the point you asked about: a catalogue extract whose bounding box does not hold the whole cell around the point, or a gridded feed that measures somewhere else. Coverage is checked rather than approximated — an extract stopping inside the area asked about would report less hazard than the record holds — and the message names each snapshot and what it does cover. [The pinned data feeds](feeds.md) is the same list, with the coverage of every vintage. |
| `payload_too_large` | 413 | The uploaded file is over the per-request size limit Ingest accepts (32 MB). |
| `rate_limited` | 429 | The presented key is over its request limit. The response carries `Retry-After`; the message names the limit, the window and the wait. Limits are per key, so this says nothing about any other key's traffic. |
| `server_error` | any other 5xx | A refusal at a 5xx status this build names no more specific code for. Read `status`. |
| `unauthenticated` | 401 | No API key, a malformed `Authorization` header, or a key this environment does not hold. Every route except `GET /health` and `POST /signup` needs one. Never an expired key: keys carry no expiry (`api_key.expires_at` is `null` at signup) and none is aged out, so the message names the four things that are actually wrong instead — no header, a scheme this API does not read, the scheme with an empty key after it (an unset shell variable), or a key that changed on the way here. |
| `unmonitorable_structure` | 422 | The structure is well-formed but its index cannot be measured against the pinned public record — no trigger, no term, more than one data source, or a feed nothing is pinned for. The message names what is missing, and for a feed it names what is pinned. |
| `unbacktestable_structure` | 422 | The structure is well-formed but Backtest v0 cannot replay it — a trigger the v0 parametric model does not read, or a window reaching outside the period of record the pinned archive holds. Reported on the backtest job, not on the request that created it. |
| `unpackageable_object` | 422 | The object exists and is well-formed, but it does not carry what a submission pack is assembled from: no price run in its provenance, or none of the blocks the draft slip is populated from — the parties, the period, the jurisdiction, the financial structure, the trigger, and the technical premium a price run writes. Package assembles records and computes nothing, so it will neither price the object nor draft around a gap; the message names every missing piece at once, and the call that produces each (`PATCH /objects/{id}` for the blocks a person writes, `POST /objects/{id}/price` for the premium). Reported on the request, before a job exists — see [the submission pack](package.md). |
| `unanalysable_object` | 422 | The object exists and a named analysis cannot read it: no `peril.code` on the object, no locations on its exposure, no coordinates on any of them, more located locations than one run reads (25), or no pinned snapshot covering any of them for that peril. The message names the block to write and the route that writes it. Reported on the analyse job, not on the request that created it. See [Analyse an object](analyse.md). |
| `unknown_analysis` | 422 | The request named an analysis this build does not run, or named none at all. The message lists every analysis that can be asked for, so the call is fixed in one round trip. Reported on the request itself: an analysis nothing can run never becomes a job. |
| `unpriceable_object` | 422 | The object is well-formed but Price cannot compute a technical price for it — typically no financial structure or no trigger (**[the trigger object](trigger.md)** lists the messages this refusal arrives with and what to change for each), or a `trigger.data_sources` entry naming a feed vintage this build does not pin (the message then lists the pinned ones — see [the pinned data feeds](feeds.md)). It also covers the structure no year of the pinned record would have paid: the message names the highest level that record reached and when, because a premium of 0.00 is not a cheap structure but an untested one — see [Price an object](price.md). Reported on the price job, not on the request that created it. |
| `unrunnable_object` | 422 | The model and the object both exist, but the model cannot be run against that object — typically the object carries nothing the model's declared inputs read, or falls outside the feed's record. Reported on the model run job, not on the request that created it. |
| `unsupported_media_type` | 415 | The request's content type is not one the route reads. On `POST /ingest` that is either of two mistakes, and the message says which: the request itself was not `multipart/form-data` with a part named `file`, or the file part declared a type Ingest v0 does not read. It reads any `text/*`, `application/json` and `application/x-ndjson` (with their aliases), and `application/octet-stream` for "type unknown" — see the table in [the quickstart](quickstart.md). Raised on the request, before a job exists. |
| `unsupported_peril` | 422 | A hazard lookup named a peril this build pins no data for. A peril is answerable when a snapshot of it is committed — the engine reads snapshots and never the network — so the message names the perils that are, and [the pinned data feeds](feeds.md) lists the feeds behind them. |
| `unsupported_source` | 415 | Ingest could not read the uploaded file at all — encoding, container format or structure. The message states what it tried. Reported on the ingest job, not on the request that created it: `POST /ingest` answers `202` and the reader finds this out afterwards. See *Which faults answer on the request, and which on the job* below. |

One ordering fact worth knowing before you debug a `404`: **the key is checked
before the path is**. An unknown path presented without a credential answers
`401 unauthenticated`, not `404 not_found` — the API does not disclose which of
its paths exist to a caller it cannot identify. Send the key and the same
request answers `404`.

---

## What may change, and what may not

`error.code` is specified as a *pattern*, not a closed enum, and that is
deliberate. A failure mode nobody predicted must be reportable honestly the
moment it exists, without waiting for a schema version bump — the alternative
is a platform that reports new failures as something they are not.

So write your client this way:

* **Switch on `error.code`** for the codes above that you handle specifically.
* **Fall back to `error.status`** for anything else. A code you have never
  seen still arrives inside the envelope above, at a status whose meaning is
  ordinary HTTP.
* **Never match on `error.message`.** Messages are written for humans and are
  revised whenever a clearer sentence exists.

What will not change without a major version of the error schema: the envelope,
the three mandatory fields, `additionalProperties: false`, and the rule that
every status of 400 or above uses this shape. The table above may *grow*.

---

## Failures on jobs

Every Craton verb answers with a job. A job that fails carries the same refusal,
minus the envelope, as the `error` member of the job resource:

```json
{
  "schema_version": "0.1.0",
  "id": "0f6b6f4e-8a5b-4c1d-9d2a-3c7e5b1f0a44",
  "verb": "price",
  "status": "failed",
  "created_at": "2026-08-11T04:11:00.000000Z",
  "updated_at": "2026-08-11T04:11:00.412000Z",
  "links": { "self": "/jobs/0f6b6f4e-8a5b-4c1d-9d2a-3c7e5b1f0a44" },
  "error": {
    "status": 422,
    "code": "unpriceable_object",
    "message": "Price v0 cannot price this object: no trigger"
  }
}
```

The three fields mean exactly what they mean in a synchronous refusal, and the
codes are the same codes: a failure is a job state, not a dropped connection,
and `GET /jobs/{id}` answering `200` with a failed job is the correct outcome —
the job resource was served, and it says the work did not succeed. Check
`status == "failed"`, then read `error`.

### Which faults answer on the request, and which on the job

One rule, and it holds for every verb: **a fault the platform can know without
doing the work is answered on the request; a fault it can only discover by
doing the work is answered on the job.** A `POST` that starts a job does not do
the work — that is what the job is — so it can only refuse what is already
knowable when it is called.

Knowable at the door, and therefore a `4xx` from the `POST` itself: the
credential (`unauthenticated`, `rate_limited`), the account's standing
(`account_cancelled`), the shape of the request (`unsupported_media_type`,
`invalid_request`, `payload_too_large`), the existence of what it names
(`not_found`), and the few refusals that read a document already in the store
(`unknown_analysis`, `unpackageable_object`, `invalid_structure`).

Discovered by doing the work, and therefore on the job: `unsupported_source`,
`currency_conflict`, `unpriceable_object`, `unbacktestable_structure`,
`unanalysable_object`, `unrunnable_object`. (The split is about where a fault is *discovered*, not
about which codes exist: a subject that goes away between the `POST` and the
work fails the job with the same `not_found` the request would have answered.)

`unsupported_source` is the one that catches people out, so it is worth being
explicit. **`POST /ingest` answers `202` even for a file that is obviously not a
schedule** — a text file with no delimiter anywhere in it, say, declared as
`text/csv`. The declared type is checked at the door, because a declaration can
be read without opening the file; whether the *bytes* are delimited text cannot
be, and finding out is the first thing the ingest job does. So the job is
created, runs, and finishes `failed` with `415 unsupported_source` in `error`.
There is no synchronous form of that refusal, and no request flag that asks for
one: poll the job.

`currency_conflict` splits the same way, and shows the rule twice on one
field. `POST /ingest` takes an optional `currency` part — the ISO 4217 code
*you* state for a schedule that names no currency of its own. Whether that
code is three letters is a fact about the request, so a malformed one answers
`422 invalid_request` from the `POST`. Whether the file agrees with it is a
fact about the file's cells and headers, so a code the file contradicts
answers `422 currency_conflict` on the job.

The status codes are the same either way. A client that switches on
`error.code` and `error.status` handles both paths with one branch — the only
difference is whether it read them off the `POST` response or off the job it
polled.

---

## Validating against the published schema

The schema is a file, not an API call. Fetch `error.v0.schema.json` from the
public schema repository and validate refusals against it in your own tests —
it is JSON Schema draft 2020-12, so any standard validator reads it. If a Craton
response ever fails that validation, that is a Craton bug: report it rather than
loosening your validator.

The same holds in the other direction. Craton's own gate validates every response
the API produces against these published files on every change, so this page
and the product cannot quietly drift apart.

---

## A worked example

```sh
curl -sS -i "$CEDE_BASE_URL/ingest" -X POST
```

```
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
content-type: application/json

{"error":{"code":"unauthenticated","message":"this endpoint requires an API key: send 'Authorization: Bearer <your key>'","status":401},"schema_version":"0.1.0"}
```

(Craton serves canonically-ordered JSON, so members arrive sorted by name. Member
order is not meaning — read the body with a JSON parser, never by position.)

Handling it, with nothing installed:

```sh
response=$(curl -sS -w '\n%{http_code}' "$CEDE_BASE_URL/ingest" -X POST)
body=${response%$'\n'*}
code=$(printf '%s' "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["error"]["code"])')

case "$code" in
  unauthenticated)  echo "check CEDE_API_KEY" ;;
  rate_limited)     echo "wait and retry" ;;
  *)                echo "unhandled: $body" ;;
esac
```

---

See also: the [quickstart](quickstart.md), whose *When it does not work*
section lists the refusals you are most likely to meet on your first run.
