Exposure coding: OED codes for messy rows
POST /exposure-codes takes the rows of a schedule of values as plain text, one building a row, and answers with OED 5.0.0 OccupancyCode and ConstructionCode values for each. Every field comes back with its confidence and whether it cleared the gate. A field below the gate is not coded: its row is marked for a person to look at, with the proposal beside it.
Read the quickstart first for the two values you export (CEDE_BASE_URL, CEDE_API_KEY) and how to get a key. The same thing is on the website as a tool you can paste a schedule into: the SOV coder.
On this page
What it is for, and what it is not
A schedule says "2 sty wd frm apts" or "tilt-up whse" or "RC office tower". A catastrophe model reads 1052 and 5050. This route does that translation for the two fields every model needs first, and tells you how sure it is.
- Not a silent step. Nothing is written onto any object, and Ingest does not call it. You get codes and confidences; what you do with them is yours.
- Not a chat. You send rows. The questions each row is asked are fixed by Craton and published by digest in every answer; a request that carries anything but
rowsis refused. - Not a guess. A code appears only when the model's answer maps to a real OED code and its confidence clears the gate. OED's own Unknown codes (1000 and 5000) are never written as codes: "the row does not say" is left for you.
Who does the coding
Each row is read by Jev, a decision model made by TypeSafe, reached through Vercel's AI Gateway (typesafe-ai/jev). Jev does not write text. It picks one answer from a closed list of classes Craton gives it, each mapped to one OED code, and says how confident it is. Craton's own code then decides what becomes a code. Every answer names the model, the provider that served it, and the digest of the question set, so you can tell which questions produced which codes.
The request
{
"rows": [
"Apartments, 3 sty wood frame, built 1986, Houston TX",
"Distribution whse, concrete tilt-up, Miami FL",
"Oficinas, concreto reforzado, 12 pisos, Ciudad de Mexico"
]
}| Member | What it is |
|---|---|
rows |
1 to 50 strings, one building each, at most 1,000 characters after runs of spaces and line breaks are collapsed. The only member the route reads. |
More rows than that go in more requests. A request over a limit is refused with 413 payload_too_large, and a request that is not an object with a rows array of strings, or that carries any other member, with 422 invalid_request. Repeated rows in one request are sent once.
What comes back
{
"schema_version": "0.1.0",
"run_id": "exposure-codes-v0:9c0f1a2b3c4d5e6f:0a1b2c3d4e5f6a7b",
"coded_at": "2026-09-25T15:02:11Z",
"model": {"name": "typesafe-ai/jev", "served_by": ["typesafe-ai"], "via": "vercel-ai-gateway"},
"question_set": {"id": "craton-jev-questions/1:v4_final", "version": "2026-09-25T19:16:25Z", "digest": "sha256:..."},
"oed": {"version": "5.0.0", "fields": ["OccupancyCode", "ConstructionCode"]},
"gate": {"OccupancyCode": 0.95, "ConstructionCode": 0.85},
"counts": {"rows": 1, "coded": 0, "needs_review": 1, "unanswered": 0},
"rows": [
{
"row": 1,
"status": "needs_review",
"generation_id": "gen_01M3CFPRNSZSWW3J7E255S80TV",
"fields": {
"OccupancyCode": {
"code": 1052, "proposed": 1052,
"name": "Residential, Permanent dwelling: multi-family",
"confidence": 0.99, "cleared": true, "why": "cleared", "alternatives": []
},
"ConstructionCode": {
"code": null, "proposed": 5100, "name": "Masonry, Masonry",
"confidence": 0.62, "cleared": false, "why": "below_gate",
"alternatives": [{"code": 5105, "name": "Masonry, Reinforced masonry", "probability": 0.21}]
}
}
}
]
}That shows the shape, not a recorded answer: the numbers are there to read the fields by. The published schema is the whole contract, and it lists every OED code a field can carry.
| Field | What it is |
|---|---|
run_id |
Changes whenever any row is coded differently, or by another provider or question set. |
coded_at |
When, in UTC. The gateway reports no dated model version, so the date is part of the attribution. |
model |
The model as the gateway reported it, the providers that served this request, and the route. |
question_set |
Which fixed questions every row was asked, by digest. |
gate |
The confidence each field needs to be coded without a person looking. |
counts |
How many rows came back coded, needs_review and unanswered. |
rows[].generation_id |
The AI gateway's id for the call that answered the row (gen_...), or null. Give it to the provider if you ever need a call looked up. |
rows[].status |
coded: every field cleared. needs_review: at least one did not. unanswered: no usable reply came back for the row; send it again. |
fields.*.code |
The OED code, or null when the field did not clear. Never a code below the gate. |
fields.*.proposed |
The code the model's answer maps to, cleared or not. |
fields.*.name |
The OED name of proposed, from the OED 5.0.0 lists. |
fields.*.confidence |
The model's confidence, 0 to 1. |
fields.*.why |
cleared, below_gate, unknown (the row does not say; OED Unknown), unrecognized_answer or no_answer. |
fields.*.alternatives |
Up to two other codes the model gave at least a 5% chance, most likely first. |
The row text is not echoed back; rows are numbered from 1 in the order sent.
Limits, and when it is off
- Per key: 20 requests a minute by default, on top of the API's own per-key limit. Per client address: 120 requests an hour. Over either,
429 rate_limitedwithRetry-After. - Pace: the provider sets it. Craton spaces its calls at about 25 rows a minute across everyone using the route, so a request of 50 rows can come back with some rows
unanswered; ten rows a request is quickest. - Per day, across all of Craton: a budget of rows sent to the provider. Once it is used,
503 exposure_coding_unavailablewithRetry-Afteruntil midnight UTC. - Switched off: exposure coding is off unless the environment switches it on. Off, every well-formed request answers
503 exposure_coding_unavailableand nothing leaves Craton. The same 503 comes back if the provider gave no usable answer for any row in a request, withRetry-Afterwhen the provider named a wait. When it answers some rows and not others, the answer is a200and those rows areunanswered: send just them again.
Not metered: nothing is stored and no catastrophe model runs.
Where your rows go
Coding a row sends its text to Vercel's AI Gateway and on to the provider that serves Jev (TypeSafe, or a fallback the gateway chooses; the answer names which). The gateway's listing for this model says the providers do not train on what they are sent; zero data retention is not available for it, so a provider may keep the text for a time under its own terms. Those services see the row text and the Craton server that asked, never your key, your account or your own address. Craton does not store the rows, keep them in a cache or write them to its logs.
Send what describes a building: its use, how it is built, its size and age, its city. Never send a person's name or anything else about a person.
Where the codes come from
The OED 5.0.0 code lists are the Open Exposure Data standard's, as published by Oasis LMF (CC0). Craton checks every proposed code against them before it can appear in an answer.
Walk it
The first block codes three rows and prints what came back. If the environment you point it at has exposure coding switched off, or the provider did not answer, it prints the refusal instead, which is the other answer this route is documented to give.
set -euo pipefail
: "${CEDE_BASE_URL:?export CEDE_BASE_URL first, see the quickstart}"
: "${CEDE_API_KEY:?export CEDE_API_KEY first, see the quickstart}"
# 1: three rows of a schedule, US first.
cat > rows.json <<'JSON'
{"rows": [
"Apartments, 3 sty wood frame, built 1986, Houston TX",
"Distribution whse, concrete tilt-up, Miami FL",
"Oficinas, concreto reforzado, 12 pisos, Ciudad de Mexico"
]}
JSON
status=$(curl -sS -o coded.json -w '%{http_code}' \
"$CEDE_BASE_URL/exposure-codes" \
-H "Authorization: Bearer $CEDE_API_KEY" \
-H 'Content-Type: application/json' \
--data @rows.json)
if test "$status" = 503; then
# Coding is off here, or the provider did not answer: the documented refusal.
python3 -c 'import json
error = json.load(open("coded.json"))["error"]
print(error["status"], error["code"])
print(error["message"])'
else
python3 -c 'import json
answer = json.load(open("coded.json"))
print("run :", answer["run_id"])
print("model :", answer["model"]["name"], "served by", ", ".join(answer["model"]["served_by"]))
print("gate :", answer["gate"])
for row in answer["rows"]:
print("row", row["row"], row["status"])
for field, entry in sorted(row["fields"].items()):
print(" ", field, entry["code"], "proposed", entry["proposed"], entry["name"], entry["confidence"], entry["why"])'
fiThe second block turns an answer into OED-style CSV, leaving a field blank wherever it did not clear the gate, with a column that says why. It codes the same rows again, so it stands on its own.
set -euo pipefail
: "${CEDE_BASE_URL:?export CEDE_BASE_URL first, see the quickstart}"
: "${CEDE_API_KEY:?export CEDE_API_KEY first, see the quickstart}"
# 2: the same rows, written out as OED-style CSV.
cat > rows.json <<'JSON'
{"rows": [
"Apartments, 3 sty wood frame, built 1986, Houston TX",
"Distribution whse, concrete tilt-up, Miami FL",
"Oficinas, concreto reforzado, 12 pisos, Ciudad de Mexico"
]}
JSON
status=$(curl -sS -o coded.json -w '%{http_code}' \
"$CEDE_BASE_URL/exposure-codes" \
-H "Authorization: Bearer $CEDE_API_KEY" \
-H 'Content-Type: application/json' \
--data @rows.json)
if test "$status" = 503; then
python3 -c 'import json
error = json.load(open("coded.json"))["error"]
print(error["status"], error["code"])'
else
python3 -c 'import csv, json, sys
rows = json.load(open("rows.json"))["rows"]
answer = json.load(open("coded.json"))
out = csv.writer(sys.stdout)
out.writerow(["LocNumber", "Text", "OccupancyCode", "ConstructionCode", "Status"])
for coded, text in zip(answer["rows"], rows):
fields = coded["fields"]
out.writerow([
coded["row"],
text,
fields["OccupancyCode"]["code"] or "",
fields["ConstructionCode"]["code"] or "",
coded["status"],
])'
fi