This page as Markdown, byte for byte: /feeds.md
Feeds: the data that is pinned, and how to check it#
Every number Craton produces comes out of a pinned snapshot: a dated copy of a public publisher's response, committed to the build with the SHA-256 of its bytes. A price, a backtest and a model run read those files and nothing else, which is why a result replays byte for byte a year later.
That single fact gives this page its two halves, and most readers arrive needing one of them:
- Writing a structure. A structure names a feed by
idandversion, and both must already be pinned — [what is pinned right now](#what-is-pinned-right-now) is the list to write yourdata_sourcesblock from, and when a feed is not pinned is the refusal you get if you name one that is not on it. - Checking a result. Every technical price and every backtest carries an assumption set, and in it a list of data sources: an id, a version, and the SHA-256 of the exact bytes that version is. [Checking the data a result was computed from](#checking-the-data-a-result-was-computed-from) turns those three strings into the file itself and proves the digest is the digest.
Read the quickstart first for the two values you export (CEDE_BASE_URL, CEDE_API_KEY) and how to get a key from POST /signup.
Why the data is pinned rather than fetched#
A model is deterministic against versioned snapshots and makes no network call while it runs, so a result replays byte for byte. That is only true if the data cannot move underneath it, so Craton holds a snapshot: the publisher's response, committed verbatim, with a manifest recording where it came from, what it covers, and its digest.
A snapshot is immutable once published. A refreshed catalogue is a new version, never an edit of the old one — which matters more than it sounds for a revised catalogue like the USGS one, where re-running the publisher's own query tomorrow does not return today's bytes. The frozen copy is the point. Both versions stay in the table below until the old one is retired, and a result you computed last month keeps naming the exact bytes it read.
The same rule read from the other end is the thing this page exists to say out loud: a vintage that was never committed cannot be fetched on demand. The refusal you get for a feed id nobody pinned is not a permissions problem or a typo the engine will forgive — it is determinism doing its job.
What is pinned right now#
| Feed id | Version | Kind | What it measures | Period of record | Licence |
|---|---|---|---|---|---|
era5-rain-bangkok |
2026-08-11 |
rainfall_daily |
Daily precipitation total, mm, at the Bangkok ERA5 grid point (13.743409N, 100.495865E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
era5-wind-galveston |
2026-08-15 |
wind_daily |
Daily maximum 10 m wind speed, mph, at the Galveston ERA5 grid point (29.279436N, -94.87326E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
era5-wind-miami |
2026-08-14 |
wind_daily |
Daily maximum 10 m wind speed, mph, at the Miami ERA5 grid point (25.764498N, -80.196075E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
era5-wind-new-orleans |
2026-08-15 |
wind_daily |
Daily maximum 10 m wind speed, mph, at the New Orleans ERA5 grid point (29.982424N, -90.10489E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
era5-wind-tampa |
2026-08-15 |
wind_daily |
Daily maximum 10 m wind speed, mph, at the Tampa ERA5 grid point (27.94376N, -82.49155E) | 1986-01-01 to 2025-12-31 (40 years, 14610 complete days) | CC-BY-4.0 |
usgs-eq-kanto |
2026-08-10 |
quake_catalogue |
Earthquake hypocentres and preferred magnitudes, M4.5+, in 34.0–37.0N / 138.0–141.5E (2409 events) | 1986-01-01 to 2026-01-01 (40 years) | Public domain (USGS) |
A feed id is a place and a source, not a peril: usgs-eq-kanto is the USGS catalogue for the Kanto box, and the region it covers is part of what makes it the right feed. There is no all-of-the-world quake feed here to fall back on, by design — a snapshot that covers less than the box you priced would find fewer events than really occurred and hand you a technical price that is too low for a reason nothing records. Coverage is checked on every call and a structure reaching outside it is refused with the edge it crossed.
The wind series, and what a named-storm structure can ask of them#
Four of the snapshots are daily maximum wind, and they are the ones a tropical_cyclone structure attaches on — one on the Atlantic coast of Florida (era5-wind-miami) and three on the Gulf Coast (era5-wind-galveston, era5-wind-new-orleans, era5-wind-tampa). Two things about all four decide whether the ladder you write can be measured at all, and both are in each manifest's notes as well as here.
They measure wind, not storms. A daily wind maximum at a grid point is not a storm track archive: it carries no storm names, no tracks, no radii and no landfall points. A trigger written on one pays on wind speed alone — whether or not a meteorological agency named the system that produced it — and it cannot exclude a wind day that no cyclone caused. If your wording requires a named system, this build measures something adjacent to it, not it.
The levels are levels of the feed. Each series is a cell average of hourly means, so it reads well below an anemometer in the eyewall and further below a gust. Written in the alphabet of the Saffir-Simpson ladder, a 74 mph attachment is above nearly everything forty years of these reanalysis cells hold. What the records actually reach, as the number of years out of 40 whose highest day reaches each level:
| Feed | Highest day in the record | ≥40 mph | ≥50 mph | ≥60 mph | ≥74 mph |
|---|---|---|---|---|---|
era5-wind-galveston |
60.0 mph (2024-07-08) | 8 | 2 | 1 | 0 |
era5-wind-miami |
77.1 mph (2005-10-24) | 4 | 2 | 2 | 1 |
era5-wind-new-orleans |
73.2 mph (2005-08-29) | 4 | 3 | 2 | 0 |
era5-wind-tampa |
55.1 mph (2024-10-10) | 4 | 1 | 0 | 0 |
Read that as the shape of the structure, not as a recommendation: a band no year reaches earns no expected loss from an empirical burn, and nothing here fits a distribution to the tail. Which of two things happens to your ladder depends on whether any of it is inside the record:
- Part of it is. The ladder prices off the years that reach its
attachment. The bands nothing reached carry
occurrences: 0, anullreturn period, and a sentence inassumptions.limitationssaying the price understates them. - None of it is. The structure is refused, not priced at zero. The
price job ends
failedcarrying422 unpriceable_object, and the message names the highest level the record reached and the day it happened. The zero such a ladder would otherwise price at is not a cheap layer; it is the absence of any observation in the structure's trigger range, and the two read identically in every field of a price response, so this build declines to publish it. [A structure the record never paid on is refused, not priced at zero](price.md#a-structure-the-record-never-paid-on-is-refused-not-priced-at-zero) is the whole refusal and the decision behind it.
A Saffir-Simpson ladder is the second case on three of these four feeds. The first rung — 74 mph, as 1-minute sustained wind — is reached in one year of forty at era5-wind-miami and in no year at all at era5-wind-galveston, era5-wind-new-orleans and era5-wind-tampa; that is the last column of the table, read straight across. A category-1-and-above ladder on any of those three is refused every time rather than priced, and the refusal names the number to attach at instead. Galveston tops out at 60.0 mph, so a 75 mph attachment there gets:
no year of the pinned record would have paid: this structure first pays at
75 mph and the highest 1-day rolling_maximum of daily_maximum_wind_speed_mph
in era5-wind-galveston@2026-08-15 over 1986-2025 is 60.0 mph, in 2024 (window
2024-07-08 to 2024-07-08). No year in the period of record reaches 75, 96,
111 mph, so the empirical burn assigns those bands no expected loss at all —
every level of this ladder, not only its attachment, sits above the record.
[…] Attach at or below 60.0 mph to price against the observed record, or run
POST /structures/{id}/backtest to see the same 40 years with no payout in
them, which is a description of history rather than a price.
Which is to say: the table above is the ladder you can measure here, and a category written on top of it is a different measurement — pick the attachment from the record, not from the alphabet.
Two other ways to read the same list, both current for the environment you are pointed at, which a page cannot be: [GET /primitives](#enumerating-them-from-the-api) and [GET /feeds](#the-three-routes).
The measurement variable each feed publishes#
The What it measures column above is English. trigger.index.measurement is machine-readable, and the two are not the same string. This is the table to copy variable and unit from, exactly as written:
| Feed | measurement.variable |
measurement.unit |
The publisher's own name for it |
|---|---|---|---|
era5-rain-bangkok@2026-08-11 |
daily_precipitation_mm |
mm |
precipitation_sum |
era5-wind-galveston@2026-08-15 |
daily_maximum_wind_speed_mph |
mph |
wind_speed_10m_max |
era5-wind-miami@2026-08-14 |
daily_maximum_wind_speed_mph |
mph |
wind_speed_10m_max |
era5-wind-new-orleans@2026-08-15 |
daily_maximum_wind_speed_mph |
mph |
wind_speed_10m_max |
era5-wind-tampa@2026-08-15 |
daily_maximum_wind_speed_mph |
mph |
wind_speed_10m_max |
usgs-eq-kanto@2026-08-10 |
catalogue_preferred_magnitude |
M |
mag |
Every row is a snapshot, not a feed: the variable is a property of what the snapshot holds, so a new vintage gets its own row and is checked on its own bytes. The four wind series repeat one variable because they are the same measurement read at four grid points — the point is chosen by naming the feed in trigger.index.feed, never by varying this string.
That last column is not a second name you may use. It is there to head off the three plausible strings that are not the variable:
- The manifest's name is not the variable.
GET /feeds/{id}/versions/{v}reportscoverage.measurement—wind_speed_10m_maxfor the Miami feed — because that is the field the publisher was asked for inquery_url, and keeping it is how a snapshot traces back to its source. Written intomeasurement.variableit is refused like any other unknown string. - Nor is the prose. A feed described as a daily maximum sustained wind at
10 m does not therefore publish
wind_speed_sustained_10m. The engine matches the variable exactly and never guesses at intent: a name it half-recognised is how a rainfall total gets read as a wind speed. - Nor are the schema's examples.
measurement.variableis any non-empty string as far as the published schema is concerned, so an object namingone_minute_sustained_windvalidates and then prices against nothing. The example objects on the open schema surface illustrate the shape of a risk object; what a given deployment can actually measure is this table andGET /primitives.
Get it wrong and the refusal names what the feed does publish — a job that ends failed carrying 422 unpriceable_object, with:
trigger.index: feed era5-wind-miami@2026-08-14 publishes
daily_maximum_wind_speed_mph; it does not publish 'wind_speed_sustained_10m'
unit is checked separately and just as exactly, against the unit the feed publishes the variable in — a daily series declared in kt over a feed published in mph is refused rather than converted, because a silent conversion would move every level of the payout ladder.
measurement.statistic is the third member, and it says how the daily values become one index level: sum, peak and count for a daily series (the rolling_sum, rolling_maximum and threshold_day_count transforms — compose a model walks them), max for the catalogue's per-event magnitude.
GET /primitives carries the same variable and unit for every feed the model builder reads, in feeds[].measurements[]. That list is empty for the quake catalogue, because a v1 model composes a daily series and the catalogue is not one; the cat-in-a-box price model reads its magnitude all the same, which is why the row above exists and the API list is not a substitute for it.
Naming one in a risk object#
trigger.data_sources takes exactly one entry. Combining feeds is a later model rather than an averaging this build does silently.
"data_sources": [
{ "id": "usgs-eq-kanto",
"version": "2026-08-10",
"kind": "quake_catalogue",
"vintage": "2026-08-10" }
]
id and version are the first two columns of the table above, copied exactly. version is the vintage date — not today's date, and not a semver.
kind is the caller's declaration of what shape of source this is, drawn from the enum the published schema fixes, and it is not the same vocabulary as the manifest's Kind column. What each trigger needs:
| Trigger | data_sources[0].kind |
Feed kinds the model reads | Model |
|---|---|---|---|
parametric_cat_in_a_box |
quake_catalogue |
quake_catalogue |
cede/parametric-burn-eq-box |
parametric_index |
station_network |
rainfall_daily, wind_daily |
cede/parametric-burn-station-index |
For parametric_index the declaration is checked first and the manifest second, before a byte of the series is parsed: a station_network declaration over a quake catalogue is refused as the disagreement it is rather than read as millimetres. A daily series is declared station_network even though its manifest says rainfall_daily or wind_daily, because the object's vocabulary describes the source and the manifest's describes the contents.
Which perils each feed kind can carry — a wind series prices tropical_cyclone, a rainfall series does not — is in a technical price, with the reason.
Enumerating them from the API#
GET /primitives returns the same list, plus the measurement variables and units of each feed and the transforms and payout functions a model may compose with. It reads the pinned tree at request time:
curl -sS -H "Authorization: Bearer $CEDE_API_KEY" "$CEDE_BASE_URL/primitives" \
| python3 -c 'import json,sys
for feed in json.load(sys.stdin)["feeds"]:
print(feed["id"], feed["version"], feed["kind"], sep="\t")'
That endpoint never serves feed content — a feed is enumerated, described and licensed there. Content has its own route, under its own rule; see [what content_servable: false means](#what-content_servable-false-means).
When a feed is not pinned#
Naming a feed or a vintage that is not in the table produces a refusal that lists what is:
no pinned snapshot for feed 'usgs-eq-ca' version '2026-08-10' (nothing at
…/usgs-eq-ca/2026-08-10/feed.json). No feed called 'usgs-eq-ca' is pinned
in this build. The closest pinned feed id is 'usgs-eq-kanto'. Pinned feeds:
era5-rain-bangkok@2026-08-11, era5-wind-miami@2026-08-14,
usgs-eq-kanto@2026-08-10. GET /primitives lists every pinned feed with its
kind, coverage and licence, and docs/feeds.md enumerates the same list. The
engine reads snapshots and never the network, so a feed version a risk object
names must be committed before it can be priced against.
The path in the first parenthesis is abbreviated above: in full it names the place inside Craton's own build where that vintage's manifest would live, which is a fact about Craton rather than a file on your machine. Nothing there is for you to create — the two strings you can act on are the id and the version, and the sentences after it are what tell you which ones to write.
Where the refusal reaches you depends on the verb, and the wrapper differs by verb while the sentence above is the same:
| Verb | How it arrives |
|---|---|
POST /objects/{id}/price |
The job ends failed carrying 422 unpriceable_object. |
POST /structures/{id}/backtest |
The job ends failed carrying 422 unbacktestable_structure. |
POST /models |
422 invalid_composition, immediately — the model is not created. |
Read the message rather than matching on it: messages are written for humans and get rewritten whenever a clearer sentence exists. The code is the stable part. The error reference is the full list of codes.
Checking the data a result was computed from#
The other half of the page: taking the id, version and sha256 out of an assumption set and getting to the bytes themselves.
The three routes#
| Route | What it answers |
|---|---|
GET /feeds |
Every feed and every version this environment holds, with the full digest, the record count, the byte length and the paths below |
GET /feeds/{feed_id}/versions/{version} |
One version as a registry object: the manifest as committed — publisher, retrieval query, coverage, licence, aggregation level, and the notes saying what the snapshot is and is not |
GET /feeds/{feed_id}/versions/{version}/data |
The publisher's response, verbatim, in a JSON envelope carrying the pinned digest beside it |
All three take your key. None of them is metered: reading the lineage of a result is not an object and not a model run, and checking Craton's homework should not carry a price.
What you need#
- the same two exports as the quickstart, and
curl7.76 or newer withpython3; - an environment whose
GET /healthanswers. Everything below is a call against it — nothing here reads a file you do not create.
Walk it#
The block below lists the feeds, reads one version's manifest, downloads its bytes, and — the only step that really matters — recomputes the SHA-256 of what it downloaded and compares it with what the manifest pins. It exits non-zero if they differ.
set -euo pipefail
: "${CEDE_BASE_URL:?export CEDE_BASE_URL first — see 'What you need'}"
: "${CEDE_API_KEY:?export CEDE_API_KEY first — see 'What you need'}"
# 1 — Every feed and version this environment holds.
curl -sS "$CEDE_BASE_URL/feeds" \
-H "Authorization: Bearer $CEDE_API_KEY" > feeds.json
python3 -c 'import json
index = json.load(open("feeds.json"))
for feed in index["feeds"]:
print(feed["id"], "—", feed["name"])
for version in feed["versions"]:
print(" ", version["version"],
version["sha256"][:12] + "…",
str(version["bytes"]) + " bytes",
"servable" if version["content_servable"] else "manifest only")'
# 2 — Pick the first version of the first feed and read its manifest.
FEED_ID="$(python3 -c 'import json; print(json.load(open("feeds.json"))["feeds"][0]["id"])')"
VERSION="$(python3 -c 'import json; print(json.load(open("feeds.json"))["feeds"][0]["versions"][0]["version"])')"
echo "inspecting ${FEED_ID}@${VERSION}"
curl -sS "$CEDE_BASE_URL/feeds/$FEED_ID/versions/$VERSION" \
-H "Authorization: Bearer $CEDE_API_KEY" > manifest.json
python3 -c 'import json
manifest = json.load(open("manifest.json"))
print("publisher :", manifest["publisher"])
print("licence :", json.dumps(manifest.get("licence", "unstated")))
print("coverage :", json.dumps(manifest.get("coverage", {})))
print("aggregation level:", manifest["aggregation_level"]["level"])
print("pinned sha256 :", manifest["sha256"])'
# 3 — The bytes themselves.
curl -sS "$CEDE_BASE_URL/feeds/$FEED_ID/versions/$VERSION/data" \
-H "Authorization: Bearer $CEDE_API_KEY" > data.json
# 4 — The check. Write the content out and take its digest: it must equal the
# sha256 the manifest pins, which is the sha256 every price and backtest
# that read this version names in its assumption set.
python3 -c 'import hashlib, json, sys
document = json.load(open("data.json"))
raw = document["content"].encode(document["charset"])
open("snapshot.csv", "wb").write(raw)
computed = hashlib.sha256(raw).hexdigest()
print("pinned :", document["sha256"])
print("computed :", computed)
print("bytes :", len(raw), "declared:", document["bytes"])
if computed != document["sha256"]:
sys.exit("the bytes served do not match the digest they are pinned to")
print("MATCH — this is the data the results were computed from")'
# 5 — It is an ordinary file. Here are its first three lines.
head -3 snapshot.csv
Reading the answer#
content holds the file's bytes exactly as committed: nothing between the snapshot and the response reorders columns, reformats numbers or re-escapes fields. That is why the digest matches, and it is also why the bytes travel inside a JSON envelope rather than as a raw body — every Craton response validates against a published schema, and a bare file would be the one response with no schema to validate it against. bytes is the length of the file, not of the envelope, so you can see the size before you ask for it.
The published schemas for all three documents are on the open schema surface, no account required: feed-index.v0.schema.json, feed-object.v0.schema.json and feed-data.v0.schema.json.
What content_servable: false means#
Feeds are aggregate only — tables and indices at portfolio level, never individual-level records. Every manifest declares its aggregation level, and content is served only for levels that cannot carry such a record by construction: a public hazard-event catalogue, a gridded reanalysis cell. Any other level is refused with 403 feed_not_servable, and the index says so ahead of time. The manifest, its coverage and its digest stay readable either way: naming a feed is not serving its content.
Tying it back to a result#
A backtest's assumptions.data_sources[0] gives you id, version and sha256. Put the first two into the routes above; the third is what the walk compares against. If they match, the history you are reading was computed from the bytes you now hold — and if you keep them, you can recompute it yourself.
The digest is also why a snapshot cannot quietly rot: it is recomputed before any row is parsed, so a snapshot whose bytes have drifted is an error rather than a silent re-fetch. You cannot get a number out of Craton that was computed from data nobody can produce again.
Troubleshooting#
| What you see | What it means |
|---|---|
404 not_found |
No such feed id or version in this environment. GET /feeds lists exactly what it holds. |
403 feed_not_servable |
The snapshot is there, but its declared aggregation level is not one whose content is served. Read the manifest instead. |
401 unauthenticated |
No key, or a key this environment does not hold. The header is exactly Authorization: Bearer <key>. |
| A digest that does not match | Report it. Craton recomputes the digest before serving a byte and refuses a drifted snapshot, so a mismatch reaching you should be impossible. |
no pinned snapshot on a price, backtest or model run |
The id or the version you named is not one this environment pins. When a feed is not pinned has the refusal and what to write instead. |
Adding a feed means committing a new snapshot to the build, so today it goes through whoever operates the deployment. The routes above read what is there; they are enumeration, not a way to write to it.