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.
Changed
Section titled “Changed”-
Horse.sexnow 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 asFILLYand was served asFILLY. From this change aFILLYreads asMAREand aCOLTasHORSEonce 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,HORSEandUNKNOWNare unchanged, as is a horse with no plausibleyearBorn. -
The answer is relative to where you read it from. Through
Runner.horseandCloseUpComment.horsethe 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 readFILLYunder one race andMAREunder a later one. Reached any other way, includingentity(id:),horse(id:)and the search and breeding reads, the age is reckoned today (UTC) under the horse’scountryOfBirth. 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: settingFILLYon a six-year-old storesFILLYand readsMARE. 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.
Reads that still counted removed races
Section titled “Reads that still counted removed races”Planned: ahead of the next production rollout.
-
Horse.careerWinningscounted 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
careerWinningsequals the sum of the per-run figures holds after this change; it did not hold before it. -
horseSilkHistoryandHorse.silkHistorycould 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. -
searchRunnersInRacereturned runners for a race thatrace(id:)answersNOT_FOUNDfor. It now returns an empty list for those races, consistent with every other read. -
Runner.winningscarried the same currency fault ascareerWinningsabove: 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 returnsnullfor those runs, so a per-run figure is either correct or absent — never mislabelled. The collection routes to a runner (entriesForParticipant, andrace(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.runnerandVerdictSelection.runnerare 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 withdata: 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.selectionsandcloseUpCommentsForRacenow skip rows whose race, meeting or runner has been removed. Expect a query that previously returneddata: nullto 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 intoNOT_FOUND, which is a contract change rather than a fix. ⚠ One thing under them does change.Verdict.selectionsis filtered wherever it is reached,verdict(id:)andverdictForRaceincluded, 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.
Changed
Section titled “Changed”-
racesForMeeting,upcomingRacesandMeeting.races/Meeting.raceCountno longer return races whoseraceNumberis 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 itsraceCountwas 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 byrace(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 itsracestill resolves. This is the durable route.meetingActivity(meetingId:), whoseentityIdvalues 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-nullRace.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:)answersNOT_FOUND, they disappear fromentriesForParticipant, andRunner.racereturns null where it previously resolved. If you cached an id from one, re-resolve it from the card byscheduledTimerather than treating the id as durable. ⚠ Do not useoffTimefor this.Race.offTimeis 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. -
recentMeetingsnarrows with them: theraceCountit reports is now the real card, so it agrees withMeeting.raceson the same meeting. If you pagerecentMeetingsand storeraceCount, expect it to fall on affected meetings — same correction, same reason. -
Meeting.statusis 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 ofFINISHED/ABANDONED; and a meeting whose only started race is in a reconciliation slot will no longer reportACTIVE.Meeting.statuswas already not a completion signal on its own — derive completion from the races you were served, not from the meeting status alone.
Migration guidance
Section titled “Migration guidance”- 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
racesForMeetingfor 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. EveryraceNumberyou 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 fromMeeting.raceCountrather than frommax(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’sRunner.racestill resolves; the same is true ofTip.raceontipsForDateif you are on the editorial tier. So araceNumberof 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.
M5 Developer API Preview
Section titled “M5 Developer API Preview”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
sessionStorageand is not put in the URL.
Changed
Section titled “Changed”- API calls now require either a valid
x-api-keyor an authenticated Better Auth session. Anonymous GraphQL requests fail withUNAUTHENTICATED. - 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.
Known Limits
Section titled “Known Limits”- 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.