Skip to content

Changelog

This page tracks customer-visible changes to the Podium GraphQL API and developer portal. Internal package changelogs and repository release notes are kept in the Podium knowledge base, not in the public developer docs.


Horse sex follows the racing-age convention

Section titled “Horse sex follows the racing-age convention”

Planned: ahead of the next production rollout.

  • Horse.sex now reads under the racing-age convention rather than as the word the provider supplied. Weatherbys and IHRB send the birth word for life, so a nine-year-old mare arrived as FILLY and was served as FILLY. From this change a FILLY reads as MARE and a COLT as HORSE once the horse has reached the racing jurisdiction’s threshold age on its official birthday: 5 in GB, IE, FR, AE, QA, SA, BH, ZA, US and JP; 4 in AU, NZ, BR, CL, UY, PE, KR and DE; 3 in AR. The birthday is 1 January, or 1 August for horses bred in AU, NZ, ZA, BR, CL, UY, PE or AR. GELDING, RIG, MARE, HORSE and UNKNOWN are unchanged, as is a horse with no plausible yearBorn.

  • The answer is relative to where you read it from. Through Runner.horse and CloseUpComment.horse the age is reckoned at that race’s meeting date under that race’s country, so a horse’s form history shows its sex as at each run and the same horse can read FILLY under one race and MARE under a later one. Reached any other way, including entity(id:), horse(id:) and the search and breeding reads, the age is reckoned today (UTC) under the horse’s countryOfBirth. Expect the two to differ for a horse that crossed the threshold between the race and today; that is the convention, not an inconsistency.

  • updateHorse(sex:) still edits the stored value, and the read may still age it: setting FILLY on a six-year-old stores FILLY and reads MARE. Nothing is written back.

If you reconcile Horse.sex against a copy you hold, expect a one-off change on every stored FILLY or COLT past its threshold, which is a large share of the roughly 216,000 such rows, and treat a FILLY that becomes a MARE on 1 January or 1 August as the expected roll-over rather than an edit.


Planned: ahead of the next production rollout.

  • Horse.careerWinnings counted runs on races that had been removed from the platform. Two consequences, both visible in the figures: a horse’s totals could include prize money for a race that no longer resolves, and — because the race’s own currency was unreadable once removed — that money was added to a bucket for the default currency rather than the race’s. A horse with French or Japanese form could therefore show a GBP total larger than its actual GBP earnings.

    Both are corrected. Expect totals to fall on affected horses, and expect the split across currencies to change even where the overall sum does not. The documented guarantee that careerWinnings equals the sum of the per-run figures holds after this change; it did not hold before it.

  • horseSilkHistory and Horse.silkHistory could return a race that no longer resolves, and because the history reports one entry per distinct silk, such a race could also displace a valid one. A silk worn only in removed races now leaves the history entirely, so the number of entries returned can fall.

  • searchRunnersInRace returned runners for a race that race(id:) answers NOT_FOUND for. It now returns an empty list for those races, consistent with every other read.

  • Runner.winnings carried the same currency fault as careerWinnings above: a per-run figure for a runner whose race has been removed was labelled with the default currency rather than the race’s, because the race’s own currency was unreadable. It now returns null for those runs, so a per-run figure is either correct or absent — never mislabelled. The collection routes to a runner (entriesForParticipant, and race(id:) → runners) already exclude removed races, so expect this one only on a runner you reached from an id you were already holding.

  • Editorial tier only. Several editorial reads failed the entire request when any one of the rows they returned pointed at a removed race or a removed runner. ⚠ An earlier revision of this entry said the list was “emptied” — that understated it. Tip.race, Tip.runner, CloseUpComment.race, CloseUpComment.runner and VerdictSelection.runner are all non-nullable inside non-nullable lists, so the null had no nullable ancestor to stop at and ran off the top of the selection set: the response came back with data: null, taking every other field in the same query with it — not just the affected list. Measured against the server’s own GraphQL version.

    tipsForDate, tipsForRace, verdictsForRace, Verdict.selections and closeUpCommentsForRace now skip rows whose race, meeting or runner has been removed. Expect a query that previously returned data: null to start succeeding, and expect counts to fall by the removed rows. Reads keyed on a single id — tip(id:), verdict(id:), verdictForRace — still return the same records: filtering the record itself would turn a served tip or verdict into NOT_FOUND, which is a contract change rather than a fix. ⚠ One thing under them does change. Verdict.selections is filtered wherever it is reached, verdict(id:) and verdictForRace included, so a verdict read by id can come back with fewer selections than before — a selection pointing at a removed runner is now dropped instead of failing the request. An earlier revision of this entry called those three roots “deliberately unchanged” without that qualification.

If you reconcile totals across releases, these are worth re-baselining rather than treating a fall as data loss — the affected races were already unreachable through every collection read. ⚠ An earlier revision of this line said “these three”, and the list has since grown past the three careerWinnings / horseSilkHistory / searchRunnersInRace reads it named.

Race collections exclude reconciliation slots

Section titled “Race collections exclude reconciliation slots”

Planned: ahead of the next production rollout.

  • racesForMeeting, upcomingRaces and Meeting.races / Meeting.raceCount no longer return races whose raceNumber is 1000 or above. Those are not card positions: they are reconciliation slots, where a race is parked when it cannot be placed on the real card. Previously an affected meeting served some races twice — the real race and its parked copy — and its raceCount was inflated to match.

  • Not every excluded race is a duplicate, and this is the part to plan for. On roughly half of the affected rows the parked race has no counterpart at the same scheduled start time (Race.scheduledTime): it is a real race that could not be placed on the card. Roughly 100 of those are declared and carry runners — about 1,800 runner records in total, a figure measured rather than estimated. Those runners leave the card with them. They are not lost — the race is still reachable by race(id:) — but there is no longer a collection that will hand you the id. Two routes to one, in order of reliability:

    • entriesForParticipant → Runner.race. If you track horses, the entry on the excluded race is still in that horse’s history and its race still resolves. This is the durable route.
    • meetingActivity(meetingId:), whose entityId values include the excluded races. Useful, but bounded: it returns at most the newest 100 audit rows for the meeting and takes no cursor, and an excluded race’s audit rows are frozen at its original ingest — so on a busy meeting they can be pushed out of that window with no way to page back.

    A surface built for finding these ships alongside this change, and it is operator-only — Podium’s own staff reconcile these rows through it; it is not in the customer schema and there are no plans to put it there. So the routes above remain the whole of what is available to you: treat this as data that is present but not reliably discoverable through a collection.

    This paragraph previously said the opposite, and the correction matters if you built against it. It told you the cleanup covered only rows that were never published as a card position and carry no runners; that everything else — the declared races with runners, and the published races carrying none — was deliberately retained with no scheduled date for leaving that state. That is no longer true. A later change gives both of those groups a rule that removes them, but only where the card is demonstrably already complete without them: there must be a live race on the same card at exactly the same scheduled start time (Race.scheduledTime, not the separate and usually-null Race.offTime), and for a race carrying runners, every one of its horses must already be accounted for on that race. A row that fails either test is still retained for an individual operator decision, exactly as described above.

    “Accounted for” is a deliberately coarse test, and it is worth knowing which way it is coarse before you rely on it. A horse counts as accounted for when it appears on the live race and that entry is not a withdrawal-or-similar while this row still had the horse taking part — so a horse we hold as running here, whose only record on the live race is withdrawn, a non-runner, non-declared or disqualified, fails the test and keeps the whole row. What it does not do is compare the two statuses exactly: a horse running here against a reserve or doubtful there, or withdrawn here against never-declared there, both count as accounted for. Read it as “nothing taking part is silently dropped”, not as “the two records agree in every detail”.

    What that means for you. The rows removed this way carry nothing you cannot read from the race that replaced them — that is the test for removing them. But they do leave, so the routes above stop resolving for those rows specifically: race(id:) answers NOT_FOUND, they disappear from entriesForParticipant, and Runner.race returns null where it previously resolved. If you cached an id from one, re-resolve it from the card by scheduledTime rather than treating the id as durable. ⚠ Do not use offTime for this. Race.offTime is the recorded actual start, it is nullable, and no file-based provider feed writes it — so it is null on essentially every race and matching on it would find nothing. An earlier revision of this entry said “off-time” here, and it was the wrong field. Nothing is scheduled yet — this has not run in production.

  • recentMeetings narrows with them: the raceCount it reports is now the real card, so it agrees with Meeting.races on the same meeting. If you page recentMeetings and store raceCount, expect it to fall on affected meetings — same correction, same reason.

  • Meeting.status is affected in both directions, and neither is signalled in the response. A meeting whose card is waiting on a reconciliation slot may now hold short of FINISHED / ABANDONED; and a meeting whose only started race is in a reconciliation slot will no longer report ACTIVE. Meeting.status was already not a completion signal on its own — derive completion from the races you were served, not from the meeting status alone.

  • Expect a one-off drop in race counts on affected meetings, and treat it as a correction, not a deletion. A meeting that returned 14 races may now return 7. No race or runner record has been deleted, and no id has changed. If you reconcile by comparing successive snapshots, a race disappearing from racesForMeeting for a meeting whose date has not changed should not be processed as a removal. Note that this holds for the undeclared duplicates AND for the declared races described above: in both cases the record is still there, and only its route in has changed. If you cache races by id, keep them.
  • Order by raceNumber; do not assume it is contiguous. Every raceNumber you receive from a race collection is now a real card position, which it was not before. It is still not gap-free: a race deleted from a card, or moved to another meeting, leaves its number unused. Use it as a stable ordinal — not as an index into the returned list — and take the card size from Meeting.raceCount rather than from max(raceNumber).
  • Entry and tip collections are unchanged, and still reach parked races. The exclusion applies to fields returning races. A horse’s entry history (entriesForParticipant) still includes its entry on a parked row, and that entry’s Runner.race still resolves; the same is true of Tip.race on tipsForDate if you are on the editorial tier. So a raceNumber of 1000 or above can still reach you by either route. Check the value before treating it as a card position.
  • A race parked in a reconciliation slot may still be returned by race(id:) if you already hold its id — no field returning a collection of races reaches it. That is a best-effort route, not a guarantee: a parked row carrying no live runners can later be absorbed into the race it duplicated, at which point its id stops resolving.

Released: April 2026

  • Self-serve organisation setup in the developer portal.
  • API-key generation, reveal-once handling, revocation, and rotation from the organisation admin view.
  • Standard, Editorial, and Internal API key tiers with schema filtering per caller tier.
  • Public developer docs under /docs/, including authentication guidance, copyable examples, and a live query runner.
  • Browser-tab key handoff from the portal reveal dialog to the authenticated app playground. The key is scoped to tab sessionStorage and is not put in the URL.
  • API calls now require either a valid x-api-key or an authenticated Better Auth session. Anonymous GraphQL requests fail with UNAUTHENTICATED.
  • Podium SSO is handled server-side through Better Auth and Cognito OIDC. Browser-facing Cognito configuration is no longer required in the portal UI.
  • The standalone docs playground now redirects to the authenticated app playground so there is one primary live query surface.
  • External customer organisations start on the Standard Card tier by default. Higher tiers require Podium approval.
  • The docs live-query runner stores pasted keys in browser sessionStorage, scoped to the current tab session.
  • If a browser blocks the docs tab during local development, copy the key manually and paste it into the docs key console.

Breaking changes to the public GraphQL schema will be documented here with migration guidance before production rollout.