Skip to content

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
FieldTypeDescription
provider*"runsignup" | "active" | "raceroster"
provider_event_id*string
provider_sub_event_id*string
endpoint*stringDocumented endpoint the record came from (no credentials).
documentation_url*string
retrieved_at*string
run_idstring | null
eventobject19 fields
FieldTypeDescription
event_uid*string
name*string
urlstring | nullOfficial event / registration page on the provider.
external_urlstring | nullOrganizer's own website, when provided.
logo_urlstring | null
timezonestring | null
location*object
venuestring | null
streetstring | null
citystring | null
regionstring | nullState / province code.
postal_codestring | null
country_codestring | null
latitudenumber | null
longitudenumber | null
next_datestring | null
source_registration_openboolean | nullProvider's own event-level registration-open flag, verbatim.
cancellation_notestring | null
source_modified_atstring | null
sub_eventobject12 fields
FieldTypeDescription
name*string
event_typestring | null
distance*object
raw*string | nullOriginal provider text, e.g. '13.1 Miles'.
valuenumber | null
unit"m" | "km" | "mi" | "yd" | null
metersnumber | null
basis"provider_distance_field" | "provider_name_text" | "none"
start_atstring | null
end_atstring | null
start_localstring | nullStart as shown by the provider, in the event's timezone.
event_datestring | null
registrationobject18 fields
FieldTypeDescription
url*string | null
status*"open" | "not_yet_open" | "closed" | "full" | "expired" | "cancelled" | "unknown"
status_basis*stringHow the status was determined, e.g. 'provider_sales_status' or 'registration_periods_vs_retrieval_time'.
status_reasonstring | null
opens_at*sourced datetime
closes_at*sourced datetime
periodsRegistrationPeriod[]
opens_atstring | null
closes_atstring | null
priceobject | null
amount_cents*integer
currencystring | null
display*stringOriginal provider text, e.g. '$40.00'.
processing_feeobject | null
amount_cents*integer
currencystring | null
display*stringOriginal provider text, e.g. '$40.00'.
is_currentboolean
pricingobject11 fields
FieldTypeDescription
current*sourced Money
current_processing_feeobject | null
amount_cents*integer
currencystring | null
display*stringOriginal provider text, e.g. '$40.00'.
current_period_ends_atstring | null
next_priceobject | null
amount_cents*integer
currencystring | null
display*stringOriginal provider text, e.g. '$40.00'.
next_price_change_atstring | null
capacityobject4 fields
FieldTypeDescription
maximum*sourced intMaximum participant cap (a limit, not a count).
registered*sourced intCurrent registrations, only if the source states it.
remaining*sourced intRemaining slots, only if the source states it.
waitlist*sourced boolWhether a waitlist is available/active, only if stated.
countdownsCountdown[]4 fields
FieldTypeDescription
field*string
raw*stringOriginal relative value, kept verbatim.
resolved_at*string | nullCalendar date-time computed from retrieved_at.
basisstring
data_qualityobject6 fields
FieldTypeDescription
status*"ok" | "partial" | "error"
issuesQualityIssue[]
code*string
severity"info" | "warning" | "error"
fieldstring | 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>
  );
}