Data model · schema v1.0.0
One record per race distance. Nothing guessed.
Every field the spec asks for, with its exact source field and an explicit reason whenever a provider doesn't supply it. Your frontend never has to wonder whether "empty" means "free", "unknown" or "not offered".
The "sourced value" pattern
Dates, prices and capacity are wrapped so a missing value always carries its reason. This is how the pipeline keeps the promise of never inferring data.
{
"value": 4500 | "2027-03-01T23:59:00-05:00" | null,
"availability": "available" | "not_provided" | "not_retrieved"
| "unavailable_at_source" | "not_applicable",
"source_field": "registration_periods[].race_fee", // exact documented field
"note": "Why a value is missing, in plain English"
}- available the provider returned it
- not_provided the field exists but this race left it empty
- not_retrieved available via a per-race call — add the race to the target list
- unavailable_at_source the provider's public API doesn't offer it
Live sample record
{
"schema_version": "1.0.0",
"record_id": "runsignup:152568:1192542",
"source": {
"provider": "runsignup",
"provider_event_id": "152568",
"provider_sub_event_id": "1192542",
"endpoint": "https://api.runsignup.com/rest/race/152568?format=json&future_events_only=T&include_participant_caps=T&include_extra_date_info=T",
"documentation_url": "https://runsignup.com/API/race/:race_id/GET",
"retrieved_at": "2026-11-11T02:26:22.375029Z",
"run_id": "30ba425e-d1a5-4ebb-899a-4a3bf3a00375"
},
"event": {
"event_uid": "runsignup:152568",
"name": "#RUNMARANA Tortolita Traverse Trail Half-Marathon and 7 Miler (~7.7)",
"url": "https://runsignup.com/Race/AZ/Marana/TTHMR",
"external_url": null,
"logo_url": "https://d368g9lw5ileu7.cloudfront.net/races/race152568-logo-0.bLrqM8.png",
"timezone": "America/Phoenix",
"location": {
"venue": null,
"street": "Tortolita Preserve",
"city": "Marana",
"region": "AZ",
"postal_code": "85658",
"country_code": "US",
"latitude": null,
"longitude": null
},
"next_date": "2027-03-13",
"source_registration_open": true,
"cancellation_note": null,
"source_modified_at": "2026-08-09T08:03:00-04:00"
},
"sub_event": {
"name": "Half Marathon Trail Run",
"event_type": "trail_race",
"distance": {
"raw": "13.15 Miles",
"value": 13.15,
"unit": "mi",
"meters": 21162.87,
"basis": "provider_distance_field"
},
"start_at": "2027-03-13T06:15:00-07:00",
"end_at": null,
"start_local": "3/13/2027 06:15",
"event_date": "2027-03-13"
},
"registration": {
"url": "https://runsignup.com/Race/AZ/Marana/TTHMR",
"status": "open",
"status_basis": "registration_periods_vs_retrieval_time",
"status_reason": null,
"opens_at": {
"value": "2026-08-02T03:00:00-04:00",
"availability": "available",
"source_field": "registration_periods[].registration_opens",
"note": null
},
"closes_at": {
"value": "2027-03-13T08:00:00-05:00",
"availability": "available",
"source_field": "registration_periods[].registration_closes",
"note": null
},
"periods": [
{
"opens_at": "2026-08-02T03:00:00-04:00",
"closes_at": "2027-03-13T01:59:00-05:00",
"price": {
"amount_cents": 3500,
"currency": "USD",
"display": "$35.00"
},
"processing_fee": {
"amount_cents": 310,
"currency": "USD",
"display": "$3.10"
},
"is_current": true
},
{
"opens_at": "2027-03-13T02:00:00-05:00",
"closes_at": "2027-03-13T08:00:00-05:00",
"price": {
"amount_cents": 4500,
"currency": "USD",
"display": "$45.00"
},
"processing_fee": {
"amount_cents": 370,
"currency": "USD",
"display": "$3.70"
},
"is_current": false
}
]
},
"pricing": {
"current": {
"value": {
"amount_cents": 3500,
"currency": "USD",
"display": "$35.00"
},
"availability": "available",
"source_field": "events[].registration_periods[].race_fee",
"note": null
},
"current_processing_fee": {
"amount_cents": 310,
"currency": "USD",
"display": "$3.10"
},
"current_period_ends_at": "2027-03-13T01:59:00-05:00",
"next_price": {
"amount_cents": 4500,
"currency": "USD",
"display": "$45.00"
},
"next_price_change_at": "2027-03-13T01:59:00-05:00"
},
"capacity": {
"maximum": {
"value": 90,
"availability": "available",
"source_field": "events[].participant_cap",
"note": null
},
"registered": {
"value": null,
"availability": "unavailable_at_source",
"source_field": null,
"note": "RunSignup exposes registration counts only via participant endpoints that require race-director authorization for each race; not available for directory use."
},
"remaining": {
"value": null,
"availability": "unavailable_at_source",
"source_field": null,
"note": "RunSignup exposes registration counts only via participant endpoints that require race-director authorization for each race; not available for directory use."
},
"waitlist": {
"value": null,
"availability": "unavailable_at_source",
"source_field": null,
"note": "RunSignup's public race API does not include waitlist state."
}
},
"countdowns": [],
"data_quality": {
"status": "ok",
"issues": []
},
"content_hash": "bc9a4762201a38c57f6349b4c878d89ba4524fb5e758eb03a86366a5d78015b2"
}Field reference
Every field, documented
schema_version"1.0.0"show
record_idstringshow
sourceobject7 fields
| Field | Type | Description |
|---|---|---|
provider* | "runsignup" | "active" | "raceroster" | |
provider_event_id* | string | |
provider_sub_event_id* | string | |
endpoint* | string | Documented endpoint the record came from (no credentials). |
documentation_url* | string | |
retrieved_at* | string | |
run_id | string | null |
eventobject19 fields
| Field | Type | Description |
|---|---|---|
event_uid* | string | |
name* | string | |
url | string | null | Official event / registration page on the provider. |
external_url | string | null | Organizer's own website, when provided. |
logo_url | string | null | |
timezone | string | null | |
location* | object | |
venue | string | null | |
street | string | null | |
city | string | null | |
region | string | null | State / province code. |
postal_code | string | null | |
country_code | string | null | |
latitude | number | null | |
longitude | number | null | |
next_date | string | null | |
source_registration_open | boolean | null | Provider's own event-level registration-open flag, verbatim. |
cancellation_note | string | null | |
source_modified_at | string | null |
sub_eventobject12 fields
| Field | Type | Description |
|---|---|---|
name* | string | |
event_type | string | null | |
distance* | object | |
raw* | string | null | Original provider text, e.g. '13.1 Miles'. |
value | number | null | |
unit | "m" | "km" | "mi" | "yd" | null | |
meters | number | null | |
basis | "provider_distance_field" | "provider_name_text" | "none" | |
start_at | string | null | |
end_at | string | null | |
start_local | string | null | Start as shown by the provider, in the event's timezone. |
event_date | string | null |
registrationobject18 fields
| Field | Type | Description |
|---|---|---|
url* | string | null | |
status* | "open" | "not_yet_open" | "closed" | "full" | "expired" | "cancelled" | "unknown" | |
status_basis* | string | How the status was determined, e.g. 'provider_sales_status' or 'registration_periods_vs_retrieval_time'. |
status_reason | string | null | |
opens_at* | sourced datetime | |
closes_at* | sourced datetime | |
periods | RegistrationPeriod[] | |
opens_at | string | null | |
closes_at | string | null | |
price | object | null | |
amount_cents* | integer | |
currency | string | null | |
display* | string | Original provider text, e.g. '$40.00'. |
processing_fee | object | null | |
amount_cents* | integer | |
currency | string | null | |
display* | string | Original provider text, e.g. '$40.00'. |
is_current | boolean |
pricingobject11 fields
| Field | Type | Description |
|---|---|---|
current* | sourced Money | |
current_processing_fee | object | null | |
amount_cents* | integer | |
currency | string | null | |
display* | string | Original provider text, e.g. '$40.00'. |
current_period_ends_at | string | null | |
next_price | object | null | |
amount_cents* | integer | |
currency | string | null | |
display* | string | Original provider text, e.g. '$40.00'. |
next_price_change_at | string | null |
capacityobject4 fields
| Field | Type | Description |
|---|---|---|
maximum* | sourced int | Maximum participant cap (a limit, not a count). |
registered* | sourced int | Current registrations, only if the source states it. |
remaining* | sourced int | Remaining slots, only if the source states it. |
waitlist* | sourced bool | Whether a waitlist is available/active, only if stated. |
countdownsCountdown[]4 fields
| Field | Type | Description |
|---|---|---|
field* | string | |
raw* | string | Original relative value, kept verbatim. |
resolved_at* | string | null | Calendar date-time computed from retrieved_at. |
basis | string |
data_qualityobject6 fields
| Field | Type | Description |
|---|---|---|
status* | "ok" | "partial" | "error" | |
issues | QualityIssue[] | |
code* | string | |
severity | "info" | "warning" | "error" | |
field | string | null | |
message* | string |
content_hashstringshow
SHA-256 of the record's material fields.
Integration
Drop it into your Next.js app
Typed end to end. Download the generated types, point your page at tonight's file, and render.
lib/races.ts
import type { EventRecord } from "./event-record"; // /schema/event-record.d.ts
export function isRegistrable(r: EventRecord): boolean {
return r.registration.status === "open";
}
export function priceLabel(r: EventRecord): string {
const p = r.pricing.current;
if (p.availability !== "available" || !p.value) return "See race page";
return new Intl.NumberFormat("en-US", { style: "currency", currency: p.value.currency })
.format(p.value.amount_cents / 100);
}app/races/page.tsx
// app/races/page.tsx — Next.js App Router (server component, revalidates hourly)
import type { EventRecord } from "@/types/event-record";
async function getRaces(): Promise<EventRecord[]> {
// Option A: read tonight's file from your private bucket with a signed URL
// Option B: query the Postgres directory tables directly
const res = await fetch(process.env.EVENTS_JSON_URL!, { next: { revalidate: 3600 } });
const bundle = await res.json();
return bundle.records as EventRecord[];
}
export default async function RacesPage() {
const races = (await getRaces()).filter((r) => r.registration.status === "open");
return (
<ul>
{races.map((r) => (
<li key={r.record_id}>
{r.event.name} — {r.sub_event.distance.raw} · {r.sub_event.event_date}
</li>
))}
</ul>
);
}