Schema reference
The envelope, the observation, the status enum, and the instrument_id grammar, all generated from one Zod source.
packages/schema is the single source of truth. TypeScript types and JSON
Schema are both generated from Zod, never hand-written, and CI fails if the
committed artifacts drift from the code.
/schema/v1/observation.json
/schema/v1/envelope.jsonEnvelope
Every data response has the same outer shape.
{
"schema_version": "1.0",
"generated_at": "2026-08-11T13:02:11Z",
"dataset": "gold",
"scope": "in",
"resolution": "daily",
"calendar": "IN-BULLION",
"unit": { "quantity": "gram", "currency": "INR" },
"coverage": { "from": "2026-08-11", "to": "2026-08-11" },
"sources": [ ... ],
"observations": [ ... ]
}| Field | Type | Notes |
|---|---|---|
schema_version | "1.0" | Bumped only on a breaking change. |
generated_at | RFC 3339 UTC | When these numbers were produced, not when a cron last ran. Unchanged data keeps its original stamp. |
dataset | enum | "all" | gold, silver, equity-indices, fx, crude. |
scope | in | world | all | The denomination of the values, not your nationality. |
resolution | daily | monthly | monthly only when every value in the file came from a monthly upstream. |
calendar | calendar id | "MIXED" | MIXED on aggregates. |
unit | object | null | null on aggregates, whose rows genuinely disagree. |
coverage | { from, to } | Inclusive date bounds of the observations present. |
derivation | string | Present only on derived datasets. The formula, verbatim. No dataset is currently derived; the field remains part of the schema for ones that will be. |
sources | array | Every source that contributed, with licence and retrieval time. |
sources
{
"id": "ecb",
"name": "European Central Bank reference rates",
"url": "https://www.ecb.europa.eu/stats/eurofxref/",
"licence": "ECB permissive reuse with attribution",
"retrieved_at": "2026-08-11T13:01:00Z"
}Derived rows resolve to the sources of their inputs. Gold's envelope names the actual upstreams rather than saying "derived" and leaving you to guess.
retrieved_at moves only when the upstream body actually changed. A re-fetch
that returns identical bytes taught us nothing, and the file should not claim
otherwise.
Observation
{
"date": "2026-08-11",
"instrument": "XAU.24K",
"instrument_id": "XAU.24K.INR.G",
"value": 15280.30,
"close": 15280.30,
"prev_close": 15064.10,
"change_pct": 1.4340,
"status": "final",
"is_trading_day": true,
"filled_from": null,
"source": "derived",
"revision": 0
}| Field | Type | Notes |
|---|---|---|
date | YYYY-MM-DD | Market's own local calendar. Never a timestamp. |
instrument | string | Short display name. Unique only within (dataset, scope). |
instrument_id | string | Globally unique. See the grammar below. |
value | number | null | null only when status is missing or not_backfilled. |
close | number | null | Same as value for daily series. |
prev_close | number | null | Previous trading day's close. Skips closed days. |
change_pct | number | null | Percent vs prev_close, 4dp. null when either side is absent. |
status | enum | See below. |
is_trading_day | boolean | From packages/calendar, never from whether a source returned data. |
filled_from | YYYY-MM-DD | null | The date a value was carried forward from. Always earlier than date. |
source | string | A source id, or derived. |
revision | integer | 0 until a correction lands after the settling window. |
Invariants the schema enforces
These are not conventions; they are validated before anything is published, and a violation fails the run:
- A
nullvalue requiresstatusofmissingornot_backfilled. missingandnot_backfilledmust carry anullvalue.- A value on a non-trading day must declare
filled_from. Silent forward-fill is rejected outright. filled_fromis strictly earlier thandate.- Every observation's
datefalls inside the envelope'scoverage.
status
final | provisional | estimated | missing | not_backfilledFull semantics are on the
Dates & trading days page. The short version:
missing means no value exists, not_backfilled means one exists upstream and
we have not imported it, and derived values inherit the weakest status among
their inputs.
instrument_id grammar
SYMBOL[.QUALIFIER].CURRENCY.UNIT| Unit | Meaning |
|---|---|
G | gram |
KG | kilogram |
OZT | troy ounce |
BBL | barrel |
RATE | currency ratio |
IDX | index points |
XAU.24K means INR per gram under scope in and USD per troy ounce under
scope world. Joining across scopes on the short name produces silent
garbage: a number three orders of magnitude off, in the wrong currency,
that will not look obviously wrong on a chart. Always join on instrument_id.
Examples:
XAU.24K.INR.G Gold 24K, INR per gram
XAU.22K.INR.G Gold 22K, INR per gram
XAU.USD.OZT Gold, USD per troy ounce
XAG.INR.KG Silver, INR per kilogram
NIFTY50.INR.IDX Nifty 50
USD.INR.RATE US dollar / Indian rupee
BRENT.USD.BBL Brent crude spot/api/v1/meta.json
Per-source health. Its one job is to never report healthy while serving stale.
{
"schema_version": "1.0",
"generated_at": "2026-08-11T13:02:11Z",
"status": "degraded",
"commit_sha": "c426fc9",
"last_run": { "id": "01J...", "at": "...", "trigger": "India-egress runner", "outcome": "partial" },
"duty_table_version": "2026.08.1",
"sources": [
{
"id": "nse",
"name": "National Stock Exchange of India",
"latest_date": "2026-08-08",
"last_success_at": "2026-08-08T10:04:00Z",
"staleness_hours": 75.0,
"staleness_slo_hours": 96,
"stale": false,
"consecutive_failures": 3,
"last_error_class": "blocked"
}
],
"stale_datasets": ["equity-indices"]
}status is ok only when every source is inside its SLO. A source with no
staleness reading at all is treated as stale, not as fine.
/api/v1/coverage.json
Per-series earliest date, resolution, known gaps, licence, and, the part that matters, why coverage stops where it does. See Coverage & backfill.
/api/v1/revisions.json
Corrections made after a dated file's 48-hour settling window closed.
{
"instrument_id": "XAU.24K.INR.G",
"date": "2026-08-11",
"revision": 1,
"previous_value": 15280.30,
"new_value": 15281.05,
"reason": "upstream USD/INR restatement",
"revised_at": "2026-08-16T09:12:00Z"
}If you cache dated files (and you should, they are immutable) this is how
you find out one changed.