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 Craton 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.
On this page
The envelope
A response with an HTTP status of 400 or above always carries this body, 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 to 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. It is the same envelope, one member richer:
{
"schema_version": "0.1.0",
"error": {
"status": 422,
"code": "invalid_request",
"message": "a signup request reads two optional members, 'email' and '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 Craton reports. status is the HTTP status (and error.status) the code arrives with.
| Code | Status | What it means |
|---|---|---|
account_cancelled |
402 | The account canceled 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 canceling; 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 Craton 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. |
exchange_access_refused |
403 | The model exchange listing is listed and the account may not run it: its provider lists it by grant and has not granted this account. Access is arranged with the provider, outside Craton, at the listing's access_url, which the message names and details carries as one line, access_url: <address>. Reported on the model lab's route. |
exchange_credentials_unavailable |
422, 503 | 422: a model exchange listing sent platform credentials to an environment that holds no key to encrypt them with, so it stores none rather than keep them in clear; send the listing without connector.credentials. 503: credentials are stored and the environment cannot decrypt them (its key is missing or changed); the provider sends them again once it is fixed. |
exchange_not_ready |
422 | The model exchange listing cannot take this step in its current state: a review asked for without stored credentials or for a model already listed, a grant on a free listing, or a run asked for before credentials are stored. The message names what is missing. |
feed_not_servable |
403 | The pinned snapshot exists and its manifest, coverage and digest are readable, but Craton 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. |
exposure_coding_unavailable |
503 | POST /exposure-codes could not code anything, or POST /multi-building-flags could not check anything: exposure coding (one switch for both) is switched off on this environment, the environment has used its daily row budget, or the AI provider gave no usable answer for any row. No row is reported as coded or flagged. A row the provider did not answer inside an otherwise answered request is a 200 with that row unanswered, never this. When the wait is known the response carries Retry-After. See Exposure coding and Multi-building rows. |
forbidden |
403 | A refusal at 403 that no more specific code describes. Read the message. |
geocoder_unavailable |
503 | GET /geocode could not look anything up: geocoding is switched off on this environment, or every public geocoder that should have been asked was unreachable, too slow or refused, or none is configured for a query of that shape. Nothing is reported as found; an empty results list is a 200, never this. When the wait is known the response carries Retry-After, and the message names each geocoder and what it did. See Geocoding. |
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. |
internal_only |
403 | The route answers only Craton's own services: a request from the same machine, not forwarded by any proxy, carrying the environment's internal credential. Anything else is refused, and the message says which was missing. |
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_exchange_model |
422 | A model exchange listing sent to POST /exchange/models or PATCH /exchange/models/{id} does not validate: a member Craton sets, a member a listing does not have, a missing one, or a value the published listing schema refuses. details names each fault. |
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. That pass is 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 catalog of what was asked for: a peril nothing is pinned for, a feed id or a vintage Craton does not hold, or a peril whose only record is a gridded daily series rather than a set of events. Craton 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. |
no_event_footprint |
404 | The peril's events are held as report extracts (storm reports, mapped wildfires), and a report is a point: no hail swath, tornado track or burn perimeter is pinned, so no event of that peril has a footprint to serve. GET /events/{peril} lists each record with its point and value. See reading a report extract. |
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 Craton answers hazard lookups for, but no pinned snapshot of it covers the point you asked about: a catalog 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 request is over a size limit: an uploaded file over the per-request size Ingest accepts (32 MB), or rows sent to POST /exposure-codes or POST /multi-building-flags over their limits (50 rows a request, 1,000 characters a row, 192 KiB a body). The message names the limit. |
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 Craton 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, POST /signup and the two public model exchange reads (GET /exchange/models, and GET /exchange/models/{id} for a listed model) needs one; those two refuse a key the environment does not hold. The model lab's internal route answers it to a request carrying no internal credential. 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 Craton 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 Craton does not pin (the message then lists the pinned ones, as the pinned data feeds does). 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. |
unsafe_platform_address |
422 | A model exchange listing's connector.base_url is not one Craton connects to: not https, carrying a user name, query or fragment, a local name, or a host that resolves to a private, loopback, link-local or otherwise non-public address. The message says which. |
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 Craton 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.codefor the codes above that you handle specifically. - Fall back to
error.statusfor 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 POSTHTTP/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" ;;
esacSee also: the quickstart, whose When it does not work section lists the refusals you are most likely to meet on your first run.