Provider ingestion guide
This guide is for data providers integrating with the Podium ingestion API — the write surface that accepts race-day updates (meeting going, race off-times, results, runner changes). It is distinct from the customer-facing GraphQL read API.
The integration flow is the same for every provider. Your provider profile — the territory, data rights, transport, and provider slug you are entitled to — is issued to you at onboarding and differs from one provider to the next; this guide documents the shared flow, not any individual provider’s scope.
How it works
Section titled “How it works”The ingestion API is a versioned HTTP/JSON API. Every request:
- Is sent over HTTPS to a base URL of the form
https://<ingest-host>/v1. - Carries credentials — either a service API key (recommended for provider-to-Podium automation) or a short-lived bearer JWT.
- Targets a canonical entity (
meeting,race,race result,runner) by its Podium ID. - Is authorised against your provider scope — the data rights and territories your credentials are entitled to write — before any update is applied.
All request and response bodies are application/json. The maximum request body size is
256 KB.
Authentication
Section titled “Authentication”You can authenticate either with an API key or a bearer JWT. If both are supplied on the same request, the bearer token takes precedence.
Option A — API key (recommended for providers)
Section titled “Option A — API key (recommended for providers)”A service API key is the simplest path for unattended, server-to-server ingestion. Podium
issues you a key whose metadata encodes your provider slug, granted data rights, territories,
and the environment it is valid for. Pass it in the x-api-key header on every request.
curl -X PATCH https://<ingest-host>/v1/meeting/<meetingId> \ -H "Content-Type: application/json" \ -H "x-api-key: $PODIUM_INGEST_KEY" \ -d '{"going": {"ground": "good-to-soft", "description": "Good to Soft"}}'const res = await fetch(`https://<ingest-host>/v1/meeting/${meetingId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.PODIUM_INGEST_KEY, }, body: JSON.stringify({ going: { ground: 'good-to-soft', description: 'Good to Soft' } }),});if (!res.ok) throw new Error(`ingest failed: ${res.status}`);import httpx, os
client = httpx.Client( base_url="https://<ingest-host>/v1", headers={"x-api-key": os.environ["PODIUM_INGEST_KEY"]},)resp = client.patch(f"/meeting/{meeting_id}", json={"going": {"ground": "good-to-soft", "description": "Good to Soft"}})resp.raise_for_status()Option B — bearer JWT
Section titled “Option B — bearer JWT”If you authenticate with credentials, exchange them for a short-lived token first, then send that token as a bearer on subsequent requests. This path is RDM-compatible and is used where a provider already integrates against an RDM-style auth flow.
-
Get a token —
POST /v1/authis the only unauthenticated endpoint.Terminal window curl -X POST https://<ingest-host>/v1/auth \-H "Content-Type: application/json" \-d '{"email": "you@provider.example", "password": "••••••••"}'# → { "token": "<JWT>" } -
Call the API with the token. Tokens are valid for 8 hours by default; re-authenticate when one expires.
Terminal window curl -X PATCH https://<ingest-host>/v1/race/<raceId> \-H "Content-Type: application/json" \-H "Authorization: Bearer <JWT>" \-d '{"status": "off", "offTime": "2026-07-01T14:32:00Z"}'
Scope and authorisation
Section titled “Scope and authorisation”Whichever credential you use resolves to the same authorisation model. A request is allowed only when your scope covers both:
| Dimension | Meaning |
|---|---|
| Data rights | The entity classes you may read and write — e.g. meeting, race. |
| Territories | The ISO country codes you may write to — e.g. GB, IE. Empty = all. |
| Scope marker | API keys must carry the ingest-write scope to write at all. |
If your credential lacks the required data right or targets a territory outside your
entitlement, the request is rejected with 403 — it is not silently dropped.
Endpoints
Section titled “Endpoints”The API exposes read endpoints (to look up canonical IDs) and write endpoints (to
update race-day state). The paths below are shown relative to the /v1 base — join them to
your base URL (https://<ingest-host>/v1), e.g. https://<ingest-host>/v1/meeting/{id}.
Reads — find the entity to update
Section titled “Reads — find the entity to update”| Method & path | Purpose |
|---|---|
GET /country | List countries (with id and three-letter code). |
GET /course | List courses; GET /course/{id} for one. |
GET /horse?term=<name> | Search horses by name (for runner horse lookups). |
GET /jockey?term=<name> | Search jockeys by name (for runner jockey lookups). |
GET /meeting?date=YYYY-MM-DD&countryId=<id> | Find meetings for a date/territory. |
GET /meeting/{id} | A meeting and its races. |
GET /race/{id} | A race and its runners. |
The country, course, horse, and jockey lookups need only a valid credential. The
meeting and race reads are gated by the matching data right (meeting / race), so a
provider without that right gets 403 on the read as well as the write. The horse and
jockey searches require a term query parameter.
Writes — push updates
Section titled “Writes — push updates”| Method & path | Updates |
|---|---|
PATCH /meeting/{id} | status, going, weather, abandoned. |
PATCH /race/{id} | status, going, offTime, stewards, winTime. |
PATCH /race/{id}/result | results[] (finishing order) and winTime. |
PATCH /race/{id}/runner/{id} | A single runner — status, jockey, weight, overweight, claim, … |
PATCH /race/{id}/runner | Bulk runner update — { "runner": [ … ] }. |
Send only the fields you are changing — write bodies are partial (PATCH) updates. Unknown
fields are ignored (and logged as a warning) rather than rejected, so additive schema changes
won’t break your integration. The one exception is PATCH /race/{id}/result: it always
requires a non-empty results array with at least one finishing position, so it cannot be
used for a winTime-only correction — send that to PATCH /race/{id} instead.
Field formats
Section titled “Field formats”The write surface follows the RDM wire conventions. Getting these wrong returns
400 invalid_body or 400 invalid_enum before any update is applied:
| Field | Format |
|---|---|
going (meeting) | An object — { "ground"?: string, "description"?: string }, not a bare string. Both are stored, and a present going object is a full replacement — an omitted nested field is cleared, so send ground alongside description to avoid wiping the official going. |
going (race) | An object, but only ground is stored on a race — description is ignored. Send { "ground": "..." }; omitting ground clears the race’s official going. |
status (race) | Lowercase, hyphenated wire values — off, going-down, weighed-in, under-orders, … (not OFF). |
status (runner) | Lowercase, hyphenated wire values — runner, non-runner, pending-non-runner, withdrawn, reserve, doubtful. |
stewards (race) | An object — { "inquiry"?: string, "objection"?: string, "resolution"?: string }. |
offTime | ISO-8601 timestamp — e.g. 2026-07-01T14:32:00Z. |
winTime | ISO-8601 duration — e.g. PT1M38.42S (1 min 38.42 s). |
results[] (result) | Each entry is { "runner": { "id": string }, "result": { "finishingPosition": number } }. |
jockey (runner) | { "id": "<uuid>" } — resolve the UUID via GET /jockey?term=<name> first. |
weight / overweight / claim | { "unit": "lb" | "kg", "value": number } — kg is converted to pounds server-side. |
Worked example
Section titled “Worked example”A typical race-day push: mark the going, set the race off, advance it to finished, then post the result.
HOST=https://<ingest-host>/v1KEY=$PODIUM_INGEST_KEY
# 1. Find today's meeting in your territorycurl -s "$HOST/meeting?date=2026-07-01&countryId=$GB_COUNTRY_ID" \ -H "x-api-key: $KEY"
# 2. Update the going on the meeting (send `ground` too — a present `going`# object is a full replacement, so omitting `ground` would clear it)curl -s -X PATCH "$HOST/meeting/$MEETING_ID" \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{"going": {"ground": "good-to-soft", "description": "Good to Soft"}}'
# 3. Set the race offcurl -s -X PATCH "$HOST/race/$RACE_ID" \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{"status": "off", "offTime": "2026-07-01T14:32:00Z"}'
# 4. Advance the race to finished (the result endpoint requires the race to be# finished, photograph, or result first — see note below)curl -s -X PATCH "$HOST/race/$RACE_ID" \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{"status": "finished"}'
# 5. Post the result — tag mutating writes with a stable X-Correlation-ID and# reuse the SAME value if you retry this call, so a timeout retry replays the# original outcome instead of writing twice (see Idempotency and retries)RESULT_REQUEST_ID=$(uuidgen)curl -s -X PATCH "$HOST/race/$RACE_ID/result" \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -H "X-Correlation-ID: $RESULT_REQUEST_ID" \ -d '{"winTime": "PT1M38.42S", "results": [{"runner": {"id": "..."}, "result": {"finishingPosition": 1}}, {"runner": {"id": "..."}, "result": {"finishingPosition": 2}}]}'Idempotency and retries
Section titled “Idempotency and retries”Every request carries a correlation ID. Send it yourself as an X-Correlation-ID header — it
must be a UUID (e.g. uuidgen output). A value that isn’t a valid UUID is silently replaced
with a server-generated one, so your idempotency key is lost and a retry won’t be recognised as a
replay. If you omit the header entirely, the server generates a UUID and echoes it back in the
X-Correlation-ID response header. The server uses this ID to make writes idempotent:
- Retrying a mutating request (after a timeout, a
5xx, or an edge403) with the sameX-Correlation-IDand the same body replays the original outcome — the write is not applied twice, and you get back the original response. - Reusing a correlation ID with a different body returns
409 correlation_id_conflict— a given ID identifies one specific write, so generate a fresh UUID for each new operation.
The practical rule: generate a stable UUID per logical write, send it as X-Correlation-ID,
and reuse that exact value on every retry of that write. Without this, a retried request gets a
new correlation ID and is treated as a brand-new write — turning a timeout retry into a duplicate
result, version bump, or change-log entry.
Responses and errors
Section titled “Responses and errors”Successful writes return 200 with the updated entity. Application errors — those raised
once your request reaches the API — return a JSON body of the form
{ "error": "<code>", "correlationId": "<uuid>" }. Quote the correlationId when raising a
support query. The codes in the table below are all application errors.
Additional context fields (for example issues, reason, received, or allowed) may appear
alongside error and correlationId on 4xx responses when the server has detail to return —
treat them as diagnostic hints, not a stable contract surface.
| Status | error | Cause |
|---|---|---|
400 | invalid_body / invalid_enum / invalid_param | Malformed body, unknown enum value, or bad query parameter. |
401 | token_missing | No x-api-key or Authorization header supplied. |
401 | invalid_api_key | Unknown, disabled, or expired API key. |
401 | token_expired | Bearer JWT has expired — re-authenticate via POST /v1/auth. |
401 | token_malformed | Authorization header is present but not a valid bearer JWT (e.g. Bearer not-a-jwt). Note the bearer takes precedence, so this fires even if a valid x-api-key is also sent. |
401 | api_key_env_mismatch | Key issued for a different environment than the host called. |
403 | api_key_not_ingest_scoped | Valid key but missing the ingest-write scope. |
403 | authorisation message | Data right or territory outside your scope. |
404 | meeting_not_found / race_not_found / runner_not_found | Entity ID does not exist or is not accessible to your scope. |
409 | conflict message | The update conflicts with the entity’s current state. |
409 | correlation_id_conflict | An X-Correlation-ID was reused with a different body — use a fresh ID per operation. |
429 | rate_limit_exceeded | Request rate exceeded — back off and retry. |
500 | internal_error | Unhandled server fault — include correlationId in support query. |
501 | not_implemented_in_m13 | Endpoint reserved but not yet active. |
503 | service_unavailable | Upstream timeout — safe to retry with exponential backoff. |
Your provider profile
Section titled “Your provider profile”The flow above is identical for every provider. Your specific scope — the territory and data
rights you may read and write, the transport agreed for your feed, and your provider slug — is
issued and managed by Podium at onboarding rather than self-served. Some providers push over
HTTP/JSON to /v1 directly; others integrate through a pull or bridge path. Confirm your
profile, transport, and environment base URLs with your Podium onboarding contact.