This page as Markdown, byte for byte: /errors.md
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.
{
"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 lists everything published, /schema/error.v0.schema.json is the envelope above, and /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:
{
"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 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 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 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 — 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, which shows both branches and the three near misses. |
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. |
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 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. |
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. |
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 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). 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. 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. 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 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:
{
"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#
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:
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, whose When it does not work section lists the refusals you are most likely to meet on your first run.