Skip to content

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.


The ingestion API is a versioned HTTP/JSON API. Every request:

  1. Is sent over HTTPS to a base URL of the form https://<ingest-host>/v1.
  2. Carries credentials — either a service API key (recommended for provider-to-Podium automation) or a short-lived bearer JWT.
  3. Targets a canonical entity (meeting, race, race result, runner) by its Podium ID.
  4. 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.


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.

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.

Terminal window
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"}}'

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.

  1. Get a tokenPOST /v1/auth is 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>" }
  2. 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"}'

Whichever credential you use resolves to the same authorisation model. A request is allowed only when your scope covers both:

DimensionMeaning
Data rightsThe entity classes you may read and write — e.g. meeting, race.
TerritoriesThe ISO country codes you may write to — e.g. GB, IE. Empty = all.
Scope markerAPI 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.


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}.

Method & pathPurpose
GET /countryList countries (with id and three-letter code).
GET /courseList 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.

Method & pathUpdates
PATCH /meeting/{id}status, going, weather, abandoned.
PATCH /race/{id}status, going, offTime, stewards, winTime.
PATCH /race/{id}/resultresults[] (finishing order) and winTime.
PATCH /race/{id}/runner/{id}A single runner — status, jockey, weight, overweight, claim, …
PATCH /race/{id}/runnerBulk 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.

The write surface follows the RDM wire conventions. Getting these wrong returns 400 invalid_body or 400 invalid_enum before any update is applied:

FieldFormat
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 }.
offTimeISO-8601 timestamp — e.g. 2026-07-01T14:32:00Z.
winTimeISO-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.

A typical race-day push: mark the going, set the race off, advance it to finished, then post the result.

Terminal window
HOST=https://<ingest-host>/v1
KEY=$PODIUM_INGEST_KEY
# 1. Find today's meeting in your territory
curl -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 off
curl -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}}]}'

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 edge 403) with the same X-Correlation-ID and 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.


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.

StatuserrorCause
400invalid_body / invalid_enum / invalid_paramMalformed body, unknown enum value, or bad query parameter.
401token_missingNo x-api-key or Authorization header supplied.
401invalid_api_keyUnknown, disabled, or expired API key.
401token_expiredBearer JWT has expired — re-authenticate via POST /v1/auth.
401token_malformedAuthorization 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.
401api_key_env_mismatchKey issued for a different environment than the host called.
403api_key_not_ingest_scopedValid key but missing the ingest-write scope.
403authorisation messageData right or territory outside your scope.
404meeting_not_found / race_not_found / runner_not_foundEntity ID does not exist or is not accessible to your scope.
409conflict messageThe update conflicts with the entity’s current state.
409correlation_id_conflictAn X-Correlation-ID was reused with a different body — use a fresh ID per operation.
429rate_limit_exceededRequest rate exceeded — back off and retry.
500internal_errorUnhandled server fault — include correlationId in support query.
501not_implemented_in_m13Endpoint reserved but not yet active.
503service_unavailableUpstream timeout — safe to retry with exponential backoff.

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.