New features, fixes, and infrastructure changes to the PredictorSDK API and SDKs.
New
Hyperliquid's NFL games are matched
GET /v1/matching-markets/sports now matches Hyperliquid's NFL game winners, so an NFL event's full-game moneyline lists a hyperliquid source market beside the other venues. Hyperliquid lists each NFL game as one market with two sides, one per team, and two market creators list most games, so a game can carry two Hyperliquid markets. They name the teams in opposite orders and neither says which team is home, so a Hyperliquid market joins the event the other venues already list for the same two teams on the same day, and each side is matched to its team by name. A game no other venue lists, or one the venues disagree on, is left out rather than guessed.
A Hyperliquid source market's market_id and event_id are its outcome ID (11458), and its outcome IDs are its spot coins (#114580, #114581), the same IDs GET /v1/markets/{market_id} returns. source_id takes hyperliquid:<outcome id> and hyperliquid:<spot coin> (send the # as %23). Hyperliquid's game winners count overtime and pay 0.5 to each side on a tie, so the rule matrix reads them as equivalent to Kalshi, Polymarket, Predict, Pred and Limitless, and different from ProphetX and SX Bet, which void a tie. Hyperliquid's other markets are unchanged. The SDKs gain hyperliquid as a source-market provider value with their next release.
Fix
Pred stays matched while it lists new markets
On 2026-10-12, from about 11:54 UTC until roughly 14:20 UTC, Pred's markets were missing from upcoming games in GET /v1/matching-markets/sports (games already played kept them). Pred was listing a new market every few seconds, which shifts the pages of its market list while they are read, and our reader took the shift for a broken list and gave up on every attempt. It now reads a list that grows while it is read in full, so Pred stays matched through its next burst of new markets, and a reading that could have missed a market is now detected and retried rather than used.
No field, parameter or route changed, and the SDKs need no update.
Fix
Rule verdicts now draw on how venues settled past games
Some venues write no rule about overtime or a tied game, so include_rules=true showed unknown for them and their submarkets read unverified. We checked how those venues settled every game of the last month that went past regulation, against ESPN's final and regulation scores, and the matrix now uses what they did:
Predict, MLB moneylines: its rules never mention extra innings, but it paid the extra-innings winner of all 13 games that went past nine innings. overtime is now included.
AlphaArcade, moneylines: its rules name only the two teams, but it paid the full-game winner of all 23 games that went to overtime, a shootout or extra innings. overtime is now included.
SX Bet, NFL ties: its help center says nothing about a tied NFL game, but it voided the moneyline of the Giants at Commanders tie on October 11. tie is now void.
Each such value says in its description that it rests on settlements, and its evidence_url is the venue's record of one of those games. As a result, MLB moneylines and AlphaArcade's hockey and basketball moneylines now read equivalent. NFL moneylines that include SX Bet now read different, because SX Bet voids a tie while the other venues pay 50-50. Pred's NFL overtime rule stays unknown until it settles a game that goes to overtime.
No field or parameter changed. The SDKs need no update.
Fix
NHL puck lines read as equivalent, and unclassified markets never pair
NHL puck lines. With include_rules=true, a game line's overtime row says whether each venue counts overtime. On an NHL puck line of more than one goal, such as -1.5, that row is now not_applicable for every venue: a tied NHL game is decided by exactly one goal (sudden-death overtime, or a shootout that adds one goal to the winner's score), so the favourite fails to cover and the other side covers whether a venue counts overtime or not. AlphaArcade's puck-line rules do not mention overtime, so its puck lines used to leave their submarket unverified; they now read equivalent when the venues agree on everything else. not_applicable is a new value of overtime; the field was always documented as open to new values.
Unclassified markets. When matching cannot tell what a venue's market is (a spread whose side it cannot read, an unfamiliar market type), it used to file the market under a catch-all key that another venue's unclassified market on the same game could share, so two unrelated markets could have been published together. Such a market is now never matched. None was published when this changed, so no submarket you can see today moves. SX Bet hockey markets are also limited to the full-game moneyline, puck line and total, the only types SX Bet lists on the NHL, because its period types are named for quarters.
No field was removed and no parameter changed. The SDKs need no update.
Fix
Matching reads each new snapshot in as soon as it is published
Matching publishes a new snapshot about every 12 seconds, and the API has to read each one into memory before it can answer from it. Until now it did that when a request asked. Earlier today a request that arrived just after a new snapshot stopped waiting for it, because it was answered from the snapshot the API already held. A caller who asks only about once a minute still waited: by then two new snapshots had been published, the API held neither, and it had to read both before it could answer. On the production machine that took about 0.6 to 0.7 seconds, when a request answered from memory takes about 30 milliseconds.
Now the API reads each new snapshot in the background as soon as it is published (it looks every 2 seconds), so by the time a request arrives the snapshots are already in memory. In our measurements on a laptop with production data, a request that came after two new snapshots dropped from about 170 milliseconds to about 3, which is the time of any request answered from memory. On the production machine it should drop from about 0.6 seconds to about 30 milliseconds. This applies to GET /v1/matching-markets/sports, and to a lookup for settled events (include_settled=true), which reads the snapshots the same way. A lookup by event id or source_id already found a game in the newest snapshot straight away.
The same goes for games that have just finished. The API also keeps a copy of today's finished games in memory, and it refreshed that copy only when a request found it 30 seconds old, so a caller who asks about once a minute could be answered from a copy that was a whole request behind, and miss a game that had only just left the live data. Now the API refreshes that copy every 30 seconds whether or not anyone is asking, so it is at most about 30 seconds old for every caller, not only for callers who ask often.
One thing is slightly different. For up to about two seconds after a new snapshot is published, until the API has read it, a response is built from the snapshot before it, so a game that matching only just added can be missing from that response. The next request has it. snapshot.observed_at still dates exactly the data the response was built from.
No field, parameter or status code changed, so nothing in the SDKs needs updating.
SDK
SDKs 0.25.0 read a response that leaves a field out
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.25.0. Nothing in a request or response changed shape.
Python reads a response that leaves a field out. A field the API documents as always present, but that a response leaves out (for example during an API rollback), now reads None instead of raising ParsingError. The TypeScript and Go SDKs already behaved this way. Dates and datetimes still come back as date and datetime, nested objects as SDK models, and unknown fields are kept.
Two small Python differences. A whole number in a decimal field stays a whole number (price: 1 reads 1, not 1.0). An error body that does not match the documented error shape raises the usual typed error for its status, with code set to None, instead of ParsingError.
pydantic 1 still works, and the stated minimum is real. The package now requires pydantic>=1.10.8. It said >=1.9.2, but on Python 3.9, pydantic 1.10.0 to 1.10.7 could not import the SDK at all, so pip could install a combination that failed on import. CI now tests the SDK on pydantic 1 as well as 2. See the Python SDK page.
TypeScript and Go. Only the description of the catalog's snapshot.observed_at changed. It now says the time marks when the oldest crawl started, as the API does since today.
We checked each published package against the production API before announcing it:
Python 0.25.0 ran on Python 3.9 with pydantic 1.10.8 and on Python 3.12 with pydantic 2.14, and pip refuses pydantic 1.10.7 next to it.
The TypeScript package loaded with require() and with import.
The Go module built.
Each listed a matched event, and Python read the event's start and the snapshot time as datetimes on both pydantic versions.
Fix
Matching answers at once when new data has just arrived
Matching publishes a new snapshot about every 12 seconds. The first request after each one used to wait while the API read it in. On the production machine that wait was about 0.6 seconds, when a request answered from memory takes about 30 milliseconds. A caller who asked less often than every 12 seconds or so met that wait on most requests.
Now GET /v1/matching-markets/sports answers at once from the snapshot the API already has, and reads the new one in the background. In our measurements the first request after a new snapshot dropped from about 90 milliseconds to about 3, and ten requests arriving together right after one dropped from about 120 to about 30 (on a laptop with the same data), which should bring the first request after a snapshot in line with any other.
One thing is slightly different. For the fraction of a second the new snapshot takes to read in, a response is built from the snapshot before it, so a game that matching only just added can be missing from that one response. The next request has it. snapshot.observed_at still dates exactly the data the response was built from, and a game first read after that time can be absent from a response, as it always could.
A request that arrives after a longer pause, about half a minute or more with no request, can still wait for the read as before, because by then every snapshot the API held has been replaced. A lookup by event id or source_id is not affected: it finds a game in the newest snapshot straight away. Only a lookup for settled events (include_settled=true) follows the same rule as the list.
No field, parameter or status code changed, so nothing in the SDKs needs updating.
Fix
The catalog's freshness stamp now marks when the crawl started
snapshot.observed_at on GET /v1/markets is now the moment the oldest provider's crawl began. Until now it was the moment that crawl's snapshot was published, which comes after the crawl has read every row. A crawl takes time: Kalshi's, the longest, takes about 45 seconds, and Polymarket's about 23. A row read at the start of a crawl was therefore older than the stamp said, by up to that long, while the field's description called it a floor for the whole page.
Now the description is true: no row on the page is older than observed_at. The matching endpoint's snapshot.observed_at already worked this way, so the two now follow the same rule.
For you, the value is earlier than before by up to about 45 seconds. It was usually minutes old anyway, because the catalog refreshes about every 30 minutes. Nothing else about it changed. It is still null when a snapshot carries no time, still the same on every page of one cursor traversal, still the oldest across providers, and still that provider's own value under ?provider=.
Each provider's snapshot gets its start the next time that provider's crawl finishes after this deploys, so the full catalog is dated by crawl starts within about one crawl cycle. Until a provider has crawled again, its snapshot is dated by when it was published, which is the value it had before. The stamp therefore never becomes null because of this change and never moves later; it only moves earlier as providers crawl again.
No parameter, field, type or status code changed, so there is nothing to update in the SDKs. Their generated comments for the field were reworded to say "began reading".
New
Predict's NBA games now match
Predict's NBA games now appear in GET /v1/matching-markets/sports. Predict lists them separately from its other sports, and matching did not read that listing, so NBA events carried Kalshi, Polymarket, SX Bet, ProphetX and Pred but never Predict (the Sources page said so). On October 10, 39 of the 43 NBA events carried Predict's moneyline once it was read.
A Predict market is matched only when it is one game's full-game moneyline, spread or total whose teams, outcomes, line and rules text all agree. A spread must name the team giving the points at a negative half-point line, a total must list its Over and Under at the same line, and anything that reads as a half, a quarter, overtime or a prop stays out. Each Predict market also names the Polymarket market it mirrors, and the run now drops a Predict market whose Polymarket twin reads as another submarket (another line, another team or another period) instead of matching it. That check applies to every league Predict is matched in. It dropped nothing on the data we replayed: all 176 Predict markets agreed with their twin.
Predict lists spreads and totals only for the games nearest their start (six of the 39 games on October 10, with three to seven lines each), so most NBA events carry its moneyline alone until the day of the game. Where a spread or total is listed, it joins the Kalshi and Polymarket market of the same line as one sub-market. Predict's own spreads and totals say nothing about overtime, but the game's description does ("the final score including any overtime periods"), so each sub-market's rule matrix reads equivalent on overtime across the three venues.
Nothing in a request or a response changed shape, so there is no SDK change. The first matching run after this deploys adds Predict to the NBA events.
Fix
Matching list and lookup no longer time out under load
On October 10, between about 21:38 and 22:20 UTC, GET /v1/matching-markets/sports and GET /v1/matching-markets/sports/lookup answered 503 or an empty 502 for a minute or two at a time, every few minutes, whenever several requests arrived together. Every request was reading and decoding tens of megabytes of matching data from scratch, and a handful at once took longer than the nine seconds the API allows a request. The other routes were not affected.
That is fixed. The API now keeps the matching data it has already decoded and reads each new snapshot once, instead of once per request. In our measurements a default page of events costs about 3 milliseconds of work instead of about 170, a lookup about 3 instead of 17, and ten requests arriving together about 30 instead of 1,660. Under load the API now answers in milliseconds instead of timing out.
One thing is slightly different. The archive of settled games is now read in the background about every 30 seconds, no longer on every request. A game that has just finished can reach the default list, and a cancelled game can leave it, up to about 30 seconds later than before. No field, parameter or status code changed, so nothing in the SDKs needs updating.
Fix
A cancelled game another venue still lists leaves the default matching list
GET /v1/matching-markets/sports listed mlb-nyy-tb-2026-10-10 as one of today's games, in the list and in lookups by event_id or source_id, although the game was never played: Polymarket settled its moneyline 50-50 two days before its date. The earlier fix for cancelled games covered a game after every venue had delisted it. This one stayed listed because AlphaArcade still listed the game and Polymarket's event stayed open (another of its markets did), so Polymarket's settled moneyline stayed in the matching with AlphaArcade's, and a game that has not started is never read from the settled archive. A game whose Polymarket moneyline was settled 50-50 is now left out of the default population whichever other venues still list it, and include_settled=true still returns it with the markets that were matched to it. The game leaves the default list within about two minutes of the fix being deployed (within ten if its start has already passed), and within a minute or two of Polymarket settling a future one. NFL games are never left out this way, because a tied NFL game settles 50-50 too, and a cancelled game that never had a Polymarket market is not detected.
SDKBreaking
SDKs 0.24.0 retry the same way, load with require, and expose request ids
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.24.0. One change can break code that compiled against 0.23: the stream options are gone (last item). Nothing else in a request or response changed shape.
One retry policy. Every SDK makes the request plus two retries by default; the Go SDK made one retry before. All three also retry a connection that failed before any response arrived (refused, reset, or closed), which only the Python SDK did. A timeout or a cancelled request is never retried, and cancelling ends a wait between retries at once.
require() works. The TypeScript package ships a CommonJS build beside the ES module one, so require("@predictorsdk/client") works on every Node version the package supports. It threw ERR_REQUIRE_ESM on Node 18 and on Node 20 before 20.19.
Errors carry the request id. Every API response now has an X-Request-Id header, and the SDKs expose it on errors: error.requestId in TypeScript, error.headers["x-request-id"] in Python, and apiErr.Header.Get("X-Request-Id") on a Go *core.APIError. Quote it when you report a problem; see error handling.
New value and tighter types.tradingFees.chargeBasis can be close_or_settlement: Hyperliquid outcome markets charge only when you close or settle a position. A scheduled fee change's taker and maker are never null, and the types now say so; the current taker and maker can still be null, and trading fees says when.
Packaging. The npm package and the Python wheel and sdist include the MIT license. The Python wheel no longer installs the generator's tests, and pip install "predictorsdk[aiohttp]", which the client suggests for its async default, now installs the aiohttp transport.
Removed: stream options. The API has no streaming endpoints, so the generator's stream reconnection settings configured nothing: Python's stream_reconnection_enabled and max_stream_reconnection_attempts (client and request options), TypeScript's stream client option, and Go's option.WithMaxStreamReconnectAttempts and option.WithoutStreamReconnection. Code that passed them must drop them.
We checked each published package against the production API before announcing it: each one paged through matched events, read the default verdict and the includeRules rows on NFL games, looked up an event, read a market and an event, read the request id off an error, and read Hyperliquid's new charge basis. The TypeScript check also loaded the package with require(), and the Python check confirmed the license, the absent tests, the aiohttp extra and the removed options.
Fix
Predict's exact Polymarket join runs live, and two coverage limits are stated
Predict declares which Polymarket market each of its sports markets mirrors, by condition ID. Matching now uses that to put a Predict market in the same event as its Polymarket twin, instead of relying on two parsed slugs agreeing; the join had been wired into a code path production never runs. Every Predict market already agreed with its twin when this shipped, so no event ID, submarket or source market changed.
Two limits are now written down where you read them. An SX Bet event_id resolves on GET /v1/events/{event_id} only while SX Bet lists the fixture's markets, which stops when the game ends. A matching row keeps that event_id through the day after, so for a game that has been played it answers 404 while the row's market_id still resolves on market detail (see Which identifier goes where). And Predict's NBA games, which the docs said it did not list, are listed under a market variant that matching does not read yet, so NBA events carry no Predict market (see Sources).
Fix
Kalshi results read as settled, Hyperliquid fees say when they are charged, and other lookup fixes
Kalshi settled now means what it says.GET /v1/markets/{market_id} reported a Kalshi market as closed from the moment Kalshi set its result until it finalized it, which takes anywhere from 30 seconds to 30 minutes. A market whose status is determined (or amended) with a result of yes, no or scalar is now settled, as is every finalized one. A market that is initialized, inactive, closed, or disputed (a challenged result is not settled) is closed, and an unrecognized status is closed. The status description lists every venue's mapping.
Hyperliquid outcome fees have their own charge_basis. Hyperliquid's fee page says outcome trading "only charges fees when closing or settling, not when opening outcome positions", but trading_fees.charge_basis said fill. It is now close_or_settlement, a new value of the enum: the fee is charged when a position is closed or settles and never when one is opened. The rate is still your account's own (account_specific, rate: null), and a market's deployer can add a fee on top through its deployerFeeScale; the trading fees guide says how. A client that switches on charge_basis needs a case for the new value.
AlphaArcade lookups no longer hit the venue's rate limit. Every market detail and events lookup of an AlphaArcade id paged AlphaArcade's whole catalog, so about seven lookups back to back drew 429 upstream_rate_limited. The catalog is now read once every 30 seconds for all lookups, and a failed read still returns an error rather than a missing market. The order book is still read for each market lookup.
A Kalshi ticker with a slash in it is a 400. Kalshi tolerates one trailing / after a ticker, so kalshi:KXNBA-27-SAS%2F used to answer 200 with the real market. A slash cannot be in a Kalshi ticker, so market detail and events now answer 400 invalid_identifier. Tickers with a . in a strike, single-segment tickers, lower case and surrounding spaces still resolve.
Smaller corrections.
The Los Angeles Clippers are LA Clippers in participants[].name, the league's own name. Keys did not change, and every spelling a venue sends still resolves to the team.
The Hyperliquid Ballon d'Or legs are in the sports category, not entertainment. An award now takes its category from its own field of play.
Kalshi's NHL, NBA and NFL tickers now carry the line the same way as MLB and WNBA, so a spread or total built from a ticker alone, with no published strike, no longer reads half a point or a goal too high. Lines Kalshi publishes were never affected.
Fix
Trading fee legs say when they are null, and a scheduled change never has a null leg
trading_fees.taker and trading_fees.maker on GET /v1/markets/{market_id} are JSON null, never omitted, when no fee model can be stated for that side, and their descriptions now say exactly when, by availability. published never has a null leg, so taker.rate is safe to read once availability is published. partial has one where the venue does not let us state it: Limitless's taker, a Polymarket maker whose market record does not say only takers pay, and both legs when a record omits its fee parameters. SX Bet is account_specific with both legs null, and unavailable always has both null. Nothing about the responses changed; the spec was only vague.
What did change is the generated types for scheduled_change. Its legs are never null, because a change is reported only when both legs can be stated, and the object itself is omitted rather than null when there is none. TypeScript, Python and Go now type them that way, so tradingFees.scheduledChange.taker.rate compiles under strict without a null check. The legs of trading_fees itself stay nullable, since they are null in the cases above.
New
Every response carries an X-Request-Id
Every response from the API now carries an X-Request-Id header: a short opaque string that names that one request. It is on successes and on every error, including the 404 for a path that matches no route, the 405 for a wrong method, a rejected query parameter, a bad key (401), a 402 and a 429. When a call fails or surprises you, quote the id and we can find the request in our logs.
The id is opaque, so do not parse it or rely on its length or prefix. The API makes a new one for every request (a retry gets its own) and ignores any X-Request-Id you send, so it is not a way to tag your own requests. The generated SDKs already read the header on their errors: error.requestId in TypeScript, error.headers in Python and core.APIError.Header in Go. TypeScript's requestId was always empty before, because the API sent no id. See error handling.
SDK
SDKs 0.23.1 add include_rules, team totals and the new matching values
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.23.1 with the matching changes shipped since 0.23.0. Nothing is removed, and 0.23.0 keeps working: its clients tolerate the new values. Upgrade to get the new parameter and the complete types.
includeRules (include_rules, IncludeRules) on getSportsMatchingMarkets and lookupSportsMatchingMarkets. Every moneyline, spread, total and team total carries settlementEquivalence, whether its venues settle a tie, overtime and a line that lands exactly the same way; send includeRules: true to also get the ruleComparisons rows behind it. See game-line rules.
Team totals. A submarket can have marketType: "team_total", with the team as its subject and over/under outcomes. marketType stays a string, so new kinds of market never break parsing.
New values in existing fields.limitless is a matching provider in sourceMarkets[].provider and a sourceId prefix; tie and push are rule names in ruleComparisons; a second-half or fourth-quarter line that counts overtime has a key ending in :incl_ot and a display name ending " (incl. OT)".
We checked each published package against the production API before announcing it: each one paged through matched events, read the default verdict and the includeRules rows on NFL games, and found team totals there.
Fix
Game-line rule rows are now opt-in with include_rules
Every moneyline, spread and total recently started carrying rule_comparisons, the three rows (tie, overtime, push) that say how each venue settles a tie, overtime and a line that lands exactly. They turned out to be about 60% of every football event's size, and the football period lines added the same day multiplied the markets they ride on. A page of 100 events with every submarket reached about 11 MB, its first byte took anywhere from 1.3 to 9.4 seconds, and some requests ran into the API's nine-second limit and got a 503 or an empty 502 instead of data. GET /v1/matching-markets/sports and GET /v1/matching-markets/sports/lookup now take include_rules (boolean, default false). Without it a game line still carries settlement_equivalence, the verdict, and leaves out the rows behind it; with include_rules=true the response is exactly what it was before this change. In a test shaped like production, default responses are about 60% smaller. If you read rule_comparisons on a moneyline, spread or total, add include_rules=true, ideally on a lookup or a small page. Player props are not affected: they always carry their nine rows. A cursor works with or without the flag, because it changes what each event lists and never which events come back. The regenerated SDKs add includeRules (TypeScript), include_rules (Python) and IncludeRules (Go). Read game-line rules.
New
NFL team totals match across Kalshi and Polymarket
A team total is one team's own points in a game, "the Eagles over 24.5", and it is not the game's total. GET /v1/matching-markets/sports?include_submarkets=true now lists it as its own sub-market type, market_type: team_total, for NFL games on Kalshi and Polymarket.
A team total carries its team as the subject (a game total has none): {"type": "participant", "key": "phi", "name": "Philadelphia Eagles"}. Its metric is points, its line is a positive threshold, its display_name names the team (Philadelphia Eagles Total 24.5, 1H Philadelphia Eagles Total 13.5), and its outcomes are over and under for that team. The key names the team as well, team_total|full|team_total:phi:incl_ot|24.5, so the Eagles' line and the Jaguars' at the same number are two sub-markets and never one. A full-game team total counts overtime, as a full-game total does, and says so in its key and in rules.settlement; a first half ends before overtime can begin. Every team total carries the same rule matrix as any game line, and a Kalshi and Polymarket pair read equivalent on all three rules.
On one recording of 2026-10-09 the two venues listed the same team, period and line for 236 sub-markets, 66 full-game and 170 first-half. Kalshi listed 379 full-game and 331 first-half NFL team totals and Polymarket 946, 688 first-half and 688 second-half. Polymarket's second-half team totals count overtime (carrying incl_ot in their key) and stay one venue's line, because Kalshi lists no second-half team total, so they do not appear. Each venue's team is read from its own structure, Kalshi's ticker suffix and Polymarket's market slug, and a team total whose title or line contradicts its team is left out rather than filed under a guess.
Nothing that was already matched changed: the sports sources were replayed through the previous build and this one on one recording, and every sub-market key, source market and published field outside team totals was identical. The one thing that moved is a latent mislabel: Kalshi's WNBA team-total series (36 markets that day) were filed as game totals with the team in the detail (total|full|team_total:atl:incl_ot|72.5). No other venue lists them, so none was ever published, and they are team_total rows now. Other leagues' team totals are not matched yet.
market_type is a plain string, not an enum: a value you do not know yet is a new kind of market, and a client reads it as text. The descriptions of market_type, metric, line, subject, key, display_name and rules.settlement now say what a team total is, and the SDKs were regenerated for them; no field was added. See Sources for the per-venue detail.
New
Second halves and fourth quarters match under their own overtime rule
Second halves and fourth quarters end where overtime begins, and venues treat that differently, so these lines used to match only when a venue's rules said overtime does not count. Each one now matches under its own rule, read from the venue's market text, and only venues with the same rule share a submarket.
A line that counts overtime gets the key it would have had with :incl_ot added to its detail, for example total|half:2|game_total:incl_ot|21.5 or spread|half:2|spread:jax:incl_ot|2.5. It publishes rules.settlement of incl_ot, and its display_name ends with "(incl. OT)", as in 2H Total 21.5 (incl. OT), because a bare "2H" reads as regulation play. A line that excludes overtime keeps exactly the key and name it had, so nothing you stored changes.
The NFL second half is the result. Kalshi and Polymarket both say their second halves count overtime, so GET /v1/matching-markets/sports?include_submarkets=true now lists 232 more NFL submarkets with two venues on one week of live data: 213 spreads and 19 totals, each on Kalshi and Polymarket. NFL fourth quarters, which both venues exclude from overtime, are unchanged. In the WNBA, Polymarket's second half counts overtime and Kalshi's does not, so the two stay in separate submarkets and do not join; the NBA had no second-half or fourth-quarter lines that week. A rule that cannot be read, or that says both, still leaves the line unmatched.
No field was added: the descriptions of key, segment, display_name and rules.settlement now say what the token means. The sports sources were replayed through the previous build and this one on two recordings, and every key that existed before was identical. See Sources for the per-venue detail.
New
NFL half and quarter spreads and totals match across venues
NFL games now carry their half and quarter lines. GET /v1/matching-markets/sports?include_submarkets=true (and a lookup, which returns every submarket) lists first-half and quarter 1 to 4 spreads and totals beside the full game, with segment set to half:1 or quarter:1 to quarter:4, a display_name such as 1H Jacksonville Jaguars -2.5 or Q4 Total 10.5, and each venue's market and sides under it. On one week of live data that is about 1,160 submarkets with two or more venues: 835 spreads and 327 totals, led by the first half (226 spreads, 45 totals) and the four quarters (140 to 158 spreads and 70 to 72 totals each).
The venues match as follows. Kalshi and Polymarket list the first half and all four quarters, and SX.Bet the first half and quarters 1 to 3 (it lists no NFL fourth quarter). Kalshi's winner markets for a half or quarter ("Will Buffalo win the 1st Half?") are matched as that team's −0.5 spread for the period, which joins other venues' −0.5 lines; the tie leg and every half or quarter moneyline are not matched, because the venues settle a tied period differently. An SX.Bet spread's team is read from its own label, and a Kalshi or Polymarket market's period from its series or its structured market type.
Second halves are not matched. A second half or fourth quarter ends where overtime begins, and a line there joins only when the venue's own rules exclude overtime. Kalshi's and Polymarket's NFL fourth quarters do, so those lines match; both venues' NFL second halves count overtime, so those lines are left out rather than joined with a venue that does not. The rule is read from each market's own text, so a change on either venue changes the result without a release. NFL team totals and ProphetX's half and quarter lines are not matched yet.
Nothing about the response shape or the full-game lines changed: the same sports sources were replayed through the previous build and this one, and every existing line was identical. Each new submarket carries the tie, overtime, and push rule comparison that full-game lines do. See Sources for the market types each venue matches.
New
Game lines publish how each venue settles a tie, overtime and a push
GET /v1/matching-markets/sports and its lookup route now publish settlement_equivalence and rule_comparisons on every moneyline, spread and total, the fields player props already carried. The matrix has three rows: tie (what a venue does when the game or period ends level), overtime (whether overtime, extra innings or a shootout counts) and push (a line landing exactly). Each source market gets a value, a plain-words description and, for every known value, an evidence_url pointing at the venue's rules. A rule a venue has not written down is unknown, and unknown never counts as agreement. Today the only league where a tie can differ is the NFL: Kalshi, Polymarket, Predict, Pred, Limitless and AlphaArcade pay a tied game 50-50, ProphetX voids it, and SX Bet's help center does not say. Matching is unchanged: venues are paired exactly as before and no key moved; the matrix is the evidence for deciding whether a pairing is a hedge. It is additive, reuses the player-prop schemas (the rule enum gains tie and push), and the regenerated SDKs carry the new descriptions. Read game-line rules.
New
AlphaArcade puck lines match across venues
GET /v1/matching-markets/sports now matches AlphaArcade's spreads that are one market with one line, such as "Will Capitals -1.5 win?". Each sits in the same spread row as the game's Polymarket, Kalshi, and SX.Bet spreads for that team and line, with AlphaArcade's yes side filed as the team covering the line and its no side as the team not covering it, under the same token ids Polymarket's market uses. When this shipped, that was three of the night's NHL games. AlphaArcade also lists spreads as one market holding several lines, each its own option with its own id, and it lists totals the same way; those, and any spread whose game line is not listed beside it, stay in the catalog only. Only a negative half-point line is matched. AlphaArcade's rules never say how overtime counts, and a hockey game that reaches overtime is decided by one goal, so a line of 1.5 goals settles the same either way; a half-goal NHL line, and rules that limit the result to regulation, are left out. Nothing in the response's shape changed.
New
Limitless NFL moneylines match across venues
Limitless lists a moneyline for each NFL game that mirrors Polymarket's market for the same game, and GET /v1/matching-markets/sports now matches them. A Limitless market appears in its game's full-game moneyline with provider: limitless. Its market_id is its slug, which is also its event_id because a single market is its own event, and its outcomes are yes and no, the ids and names GET /v1/markets/{market_id} publishes: yes is the team the market names first, which is Polymarket's first outcome and the away team, and no is the other. source_id=limitless:{slug} finds a game, and limitless:yes and limitless:no are a 400 pointing to market_id, as Kalshi's and Pred's sides are. A Limitless game is matched only when its title, its rules text and its Polymarket slug name the same two teams in the same order and agree on the date, and its rules say that overtime counts and a tie resolves 50-50, the rule Polymarket, Kalshi, Pred, Predict and AlphaArcade use too; any other game is left out, not guessed. Limitless's start time is a copy taken when it lists a game and does not follow a schedule change (it read 4:25 PM ET for a game ESPN and Polymarket have at 1:00 PM), so it dates the game but is never published as scheduled_start. Nothing changes for the other venues' markets: matching the same data with and without Limitless left every other venue's markets in every event identical. The provider enum gains limitless, which ships in the next SDK release; older SDKs keep working and read it as an unrecognized value.
SDKBreaking
SDKs 0.23.0 page through results and follow the 0.23 API
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.23.0 for the 0.23 API release shipped the same day. Upgrade now if you use Python: 0.22.0 and earlier fail to parse three responses whose required fields moved, events (platform is now provider), crypto prices (prices is now data) and wallet positions (pagination.total can be null).
Paginated calls return a page you can iterate.getSportsMatchingMarkets, getMarkets, getBinanceCryptoPrices and listPolymarketWalletPositions (snake_case in Python) now return a page instead of the response body. Iterating it visits every item on every page, sending each next cursor and repeating your filters: for await (const item of page) in TypeScript, for item in pager in Python, and page.Iterator() in Go. The current page's items are page.data in TypeScript, pager.items in Python and page.Results in Go, and the whole response, with snapshot and pagination, is response (Response in Go). See Pagination.
Lookups have their own method.lookupSportsMatchingMarkets (lookup_sports_matching_markets, LookupSportsMatchingMarkets) takes eventId and sourceId and returns data, lookups and snapshot. getSportsMatchingMarkets no longer takes identifiers; it takes the new league, scheduledDate and participant filters.
Matched events carry their schedule.scheduledDate is a date (a string in TypeScript, datetime.date in Python, time.Time in Go) and scheduledStart is a timestamp or null (Date | null, Optional[datetime], *time.Time).
provider replaces platform on getMarket and getEvent and on the event response. In Go the request types are GetMarketRequestProvider and GetEventRequestProvider; a matching row's Provider converts to either with a plain type conversion.
Crypto prices and total.getBinanceCryptoPrices takes cursor and returns data and pagination, and pagination.total is nullable (number | null, Optional[int], *int) on every paginated response.
Two generator bugs are fixed before they shipped. A Go paginated method called with a nil request no longer panics, and the TypeScript pager no longer writes its cursor into the request object you passed, so reusing that object starts from the first page.
We checked every published package against the production API before announcing it: each one paged through matched events, filtered by league, found an event through the lookup method, read market detail and the event with provider, and paged through a minute of crypto prices.
Breaking
Matched events carry their schedule, lookups have their own route, platform is provider, and SDK calls return pages
This release changes five things callers touch, and each is a break for code written against the old shape. SDK 0.23.0 ships right after the API deploy; the table at the end maps every old call to its new one.
Every matched event says when it is scheduled.data[] on the list and on the lookup now carries scheduled_date, the game's calendar day in America/New_York as YYYY-MM-DD, always present, and scheduled_start, its start in UTC with milliseconds (2026-10-16T00:15:00.000Z). scheduled_start is null when no venue published an exact start: Kalshi's tickers and Polymarket's slugs carry a date and no time, so a game only they list has a date and a null start, and a "time to be announced" is never published as a start. It says when the game is scheduled, not whether it has started or ended. scheduled_date is the day the default list judges the game by. Until the new matching build has written two runs after the deploy, scheduled_start reads null on every event; the date is there from the first response.
The list filters.GET /v1/matching-markets/sports takes league (nba, wnba, nfl, mlb or nhl), scheduled_date (YYYY-MM-DD, America/New_York) and participant (a participants[].key such as okc, case-insensitive). They compose, pagination.total and has_more count the filtered set, and a cursor works only with the filters that issued it. Participant keys are unique only within a league (la is the Rams in the NFL and the Sparks in the WNBA), so send league with it. An empty value, an unknown league or a malformed date is a 400 quoting it. scheduled_date reads the same population as the rest of the list, so a past day matches nothing unless include_settled=true.
Lookups moved to GET /v1/matching-markets/sports/lookup. A page of the schedule has a cursor; a lookup is one unpaginated answer that reports what each identifier found. They are now two routes with two response shapes: the list answers data, pagination and snapshot, and the lookup answers data, lookups and snapshot, all always present. event_id and source_id on the list route are a 400 that names the lookup route, and the lookup needs at least one identifier. Everything else about a lookup is unchanged: up to 100 unique identifiers, case-insensitive, every submarket by default, and include_settled choosing the population.
platform is provider.GET /v1/markets/{market_id} and GET /v1/events/{event_id} take ?provider= where they took ?platform=, and an event answers provider where it answered platform, so the value a matching row publishes as provider goes straight into both. The composite {provider}:{id} form is unchanged. ?platform= is a 400 that names provider, and the 409 for an ambiguous identifier now advises ?provider=.
Crypto prices use the shared envelope.GET /v1/crypto-prices/binance answered prices, pagination_key and total and took pagination_key. It now answers data and pagination and takes cursor, like the other paginated routes. A cursor from the old response still decodes. A limit above 100 is a 400; it used to be clamped to 100 with a 200. With no time range the route still answers the one latest price.
pagination.total is null where a route cannot count. Wallet positions and crypto prices read upstreams that give no count. Positions answered 0, and crypto prices the number of prices on the page, so neither was a count of the result; both now answer null, and 0 means empty only on GET /v1/markets and the matching list. Follow has_more to paginate. The field is still always present.
The SDKs generate auto-pagination from the API's cursor and pagination.next_cursor, so each of the four paginated calls (getSportsMatchingMarkets, getMarkets, getBinanceCryptoPrices, listPolymarketWalletPositions) now returns a page in place of the response body. Iterating the page walks every page and repeats the filters; its first page is data in TypeScript, items in Python and Results in Go, and the whole response, with snapshot and pagination, is response in TypeScript and Python and Response in Go. The Pagination guide and the SDK pages show each language.
Before
After
GET /v1/matching-markets/sports?source_id=… or ?event_id=…
GET /v1/matching-markets/sports/lookup?source_id=… or ?event_id=…
GET /v1/markets/{id}?platform=kalshi and GET /v1/events/{id}?platform=kalshi
?provider=kalshi on both
event.platform on an event response
event.provider
GET /v1/crypto-prices/binance?pagination_key=… and response.prices
?cursor=… and response.data, with response.pagination
getMarket({ marketId, platform }) and getEvent({ eventId, platform })
the same with provider
response.pagination.nextCursor loops
iterate the page: for await (const item of page) in TypeScript, for item in pager in Python, page.Iterator() in Go
response.data on a paginated call (Python and Go)
pager.items in Python, page.Results in Go; TypeScript keeps page.data
SDK 0.23.0 is released right after the API deploy, so upgrade it with the API. Older SDKs keep working for the calls this release did not touch. An older Python SDK (0.22 and earlier) fails to parse three responses, because its models require fields that moved, and raises a ParsingError that carries the status code and body: GET /v1/events/{event_id} (it requires platform; the response says provider), GET /v1/crypto-prices/binance (it requires prices; the response has data and pagination) and GET /v1/polymarket/wallet/positions (it requires an integer pagination.total; the response carries null). Older TypeScript and Go clients do not validate responses, so those fields read as undefined or empty rather than raising. Any older SDK that sends platform, pagination_key, or event_id or source_id to the list gets a 400 naming the replacement.
Fix
Cancelled games leave the default matching list
GET /v1/matching-markets/sports listed a game that was never played as one of today's games. When a game is cancelled with no make-up date, Polymarket settles its moneyline 50-50. The venues close its markets, the game left the live matching and was kept like a finished game, and finished games stay in the default list for the day, so mlb-tb-nyy-2026-10-08, a postseason game that was never played, was the first event on the default page. A game whose Polymarket moneyline was settled 50-50 is now left out of the default population, in the list and in lookups by event_id or source_id. include_settled=true still returns it, with the Polymarket and Predict markets that were matched to it. The game leaves the default list within about ten minutes of Polymarket settling it. NFL games are never left out this way, because a tied NFL game settles 50-50 too, and a cancelled game that never had a Polymarket market is not detected.
Fix
Malformed query strings and overlong keys answer 400 and 401
Three request mistakes now get the answer that says what went wrong. A query string separated with ; (?limit=5;provider=kalshi) or carrying a % that is not followed by two hex digits used to be read as something else: the ; pair was dropped, so a filter was silently ignored and the response looked filtered when it was not. Every route except the two webhooks now answers 400 with code invalid_parameter, quoting the pair at fault. An API key longer than 256 characters answered 502, which reads as an outage and invites a retry; it is now 401 with code invalid_api_key, like any other key that is not valid. And player_prop_match= sent empty on GET /v1/matching-markets/sports now means its default, as an empty limit or cursor already did. See error handling.
Fix
Kalshi game events include every game line
GET /v1/events/{event_id} on a Kalshi game gathers the game's other Kalshi series into one event, and that list had gaps. An MLB game returned its first-five-innings and first-inning markets but never its run line, total or team totals, so the lines a matching row is most often about were missing from the event it points to; it now returns all ten of its game-line series. NHL games add their period winners, spreads and totals and their team totals, and follow Kalshi's rename of NHL overtime to KXNHLOT, which left the old series answering 404 on every request. NFL games add the market on the game ending tied. Two WNBA series that have never listed a game are no longer requested, and the NBA's new first-half winner series is included. Player props are still not gathered into the event. Nothing in the response's shape changed; fanout.siblings_attempted lists the new series.
Fix
Kalshi markets use Kalshi's own category
Kalshi markets in GET /v1/markets now take the category Kalshi itself gives their series, instead of a guess from their ticker and title. Before, about 40% of Kalshi markets were unknown, so ?category=sports missed roughly 20,000 of Kalshi's sports markets, and some were plainly wrong: the Summer Olympics lacrosse qualifiers were crypto because their ticker starts with "SOL", and a Texas Tech championship market was technology. Now 2 of about 135,000 open Kalshi markets are unknown. Sports is sports; Elections and Politics are politics; Financials, Companies and Commodities are finance; and Kalshi's Mentions (what someone will say on a call or broadcast), Health and Transportation series are other. A market's category changes the next time the Kalshi catalog refreshes after this release, and no request or response changed. See filtering for the category values.
SDKBreakingFix
SDKs 0.22.0 type the new error body and fix retries
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.22.0, typing the new error shape shipped the same day. Upgrade now if you use Python: 0.21.0 and earlier require the removed error member, so every error response raises a ParsingError instead of its typed error.
Errors are read by code.ErrorResponse is code, message, statusCode (status_code in Python, StatusCode in Go), and the optional param and errors, a list of ErrorParamProblem (param, value, message). The 409 and 402 bodies carry code and message too. The error field is gone, so replace error.body.error with error.body.code, or error.body.message for display.
A server error no longer waits for the rate-limit window. The SDKs waited until X-RateLimit-Reset before retrying a 502 or 503, up to a minute per retry, because the API sends that header on every response. Only a 429 waits for it now. Other retried statuses honour Retry-After and otherwise back off from one second.
Python no longer retries a 409. An ambiguous identifier answers the same 409 every time, so a bare ID took over a minute and three requests to fail. Catch ConflictError and retry with a platform from e.body.candidates.
An abort or a deadline ends a wait. A TypeScript abortSignal and a Go context now cancel a backoff in progress, where the SDK used to finish the wait first. Go also returns after its last attempt instead of waiting once more.
Python 3.8 and 3.9 work with pydantic 2. Every call raised TypeError: unsupported operand type(s) for | on the versions the package declares, including the python3 macOS ships. CI now tests 3.9 as well as 3.12.
The docstrings match the API: market detail lists one outcome per option on a grouped market, pricing.source names every venue, and each status maps all nine venues.
Fix
Settled markets, titles and lookups fixed after a customer review
A second review of the API, docs and SDKs on 2026-10-04, read as a customer would and checked against each venue's own data, found these defects. All are fixed.
Market detail
A settled market reads settled. SX Bet reports a result in reportedDate and can leave a settled market ACTIVE for months, so 4 of 10 settled markets read open. Polymarket's resolved markets read closed. A market that is not open is never pricing.availability: live, whatever quotes it still shows: the resolved 2024 election read live.
A closed Polymarket market resolves by slug and by condition ID. Both answered 404 once a game finished, so a wallet position could not be priced or titled after its game. A numeric ID past Polymarket's range answers 404, not 502.
Hyperliquid markets are titled in words, such as "2026/2027 English Premier League winner — Arsenal", and filed under a category. They read as template: IDs, and draw and rate-decision legs were missing.
Limitless bid_size and ask_size are shares. They were raw units, a million times the share count.
An AlphaArcade multi-choice market keys every option, by the option's own market ID, which market detail resolves. Only options mirroring a Polymarket market had an outcome_id: 3 of the 21 in the 2028 presidential market.
A Kalshi ticker works in any case on market detail and /v1/events, as it does in matching lookups.
The Limitless group and Pred parent views report pricing.source: market_record, since they read no order book.
Catalog
Every ProphetX row is category: sports. 1,350 of 1,547 were unknown, so ?category=sports dropped most of ProphetX.
SX Bet and ProphetX rows carry market detail's title. An SX Bet game's moneyline, spreads and totals all read as the team pair; they now read by their outcomes ("Seattle Seahawks -2 vs Los Angeles Chargers +2"). A ProphetX market is "event: market" on every surface.
Matching
Today's finished games stay in the default list with every venue. Once venues settled and delisted a game, it dropped out or kept only the venues still listing it, so a Kalshi ticker for a game played that evening found nothing. Games archived before 2026-10-04 21:26 UTC keep only the venues that still listed them, usually two; they age out by 2026-11-04.
A ProphetX prop's <event>:<market> ID, the form the catalog and /v1/events publish, finds its game.
Player-prop outcome labels spell the stat in words: "over 8.5 longest reception", not "longest_reception".
Crypto prices
A start_time–end_time range is walked newest first and returns every price. Page one held the range's first seconds and the cursor pointed before them, so a 60-second range read 10 at a time returned 11 prices.
Responses and docs
Every response carries Vary: Accept-Encoding, since responses are gzipped for a client that accepts gzip.
/llms.txt opens with the base URL, authentication and the usual workflow, and its links are absolute. /llms-full.txt is plain text and includes this changelog. An API reference page leads with one sentence. Old /docs/... links redirect to their pages.
BreakingNew
Every error has one shape with a stable code
An error used to be {"error": "...", "status_code": N}, and error held a short phrase on some routes (market not found), a Title-Case phrase on others (Invalid platform), and a full sentence on matching. A client could tell one cause from another only by matching prose. Every route now answers with one shape:
{ "code": "invalid_parameter", "message": "invalid limit \"500\": limit must be <= 100", "status_code": 400, "param": "limit"}
code is the stable, machine-readable reason, such as invalid_identifier, market_not_found, ambiguous_identifier or rate_limited. Branch on it. message is for people and may change. The Error Handling guide lists every code.
param names the query or path parameter at fault. errors lists every problem when a request has several: a lookup batch with three bad identifiers now names all three, where it named only the first.
The error member is gone. Read code, or message for display. A 409 keeps candidates, and a 402 keeps action, required_tier and current_tier.
A repeated or empty parameter is a 400 on every route.GET /v1/markets?provider= returned the whole catalogue, and a repeated filter kept only its first value. The matching lookups event_id and source_id may still be repeated.
Identifier mistakes say what is wrong. A provider prefix that names no provider (polymarkt:3871438) is a 400 listing the providers, where it was probed as a slug and answered 404. A composite ID that contradicts ?platform= says so, and a key sent without the Bearer scheme is told how to send it.
SDK 0.22.0 types the new body. Older SDKs still raise the typed error for each status, but the Python client fails to parse the new body, because it requires error, and raises a ParsingError that carries the status code and body instead.
SDKBreaking
SDKs 0.21.0 read matching events from data and lookups
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.21.0, typing the matching response changes shipped the same day. Upgrade now: 0.20.0 and earlier cannot read the new response. Python's client requires the canonical_events field the API no longer sends and raises on every matching call, and TypeScript and Go find no events.
Events are data, a list in start order. TypeScript response.data (CanonicalSportsEvent[]), Python response.data, Go response.Data ([]*CanonicalSportsEvent) replace the canonicalEvents / canonical_events / CanonicalEvents map. Read each event's ID from its own eventId / event_id / EventID.
Lookups are lookups. A map from each identifier you sent to a SportsMatchingLookup with eventIds / event_ids / EventIDs, empty when the identifier found nothing. It is absent (undefined, None, nil) on a list page.
Fields that are always present are required. A source market's marketName / market_name / MarketName and its outcomes' label and outcomeId / outcome_id / OutcomeID are plain strings now, as are an event's sport, league and participants, a participant's name and role, a submarket's display name and metric, and a canonical outcome's type and side. In Go each changes from *string to string, so drop the dereference: *m.MarketName becomes m.MarketName. That is the source-breaking part for Go; TypeScript and Python only lose an undefined / None case.
The docstrings match the API: include_submarkets defaults by mode, source_id accepts Polymarket condition IDs and rejects side names, and Kalshi and Pred outcomes are labelled Yes/No.
BreakingNewFix
Matching responses list events in data and report every lookup
Using GET /v1/matching-markets/sports the way a client does turned up no
wrong mappings, but several things that made it harder to use than it should
be. This release fixes them. The response shape changes, so update to SDK
0.21.0, released right after this deploy.
Events are in data, in start order. The canonical_events map is now
the data array, soonest scheduled start first: the same field, and the same
order, as every other paginated endpoint. A map could not keep that order, so
finding the next games meant sorting by a time the response does not include.
Each event appears once.
A lookup reports what each identifier found. A lookup used to key events by
the identifier you sent, so an event found by two identifiers came back twice,
and learning which identifiers missed meant diffing your own inputs against the
keys. Now lookups lists every event_id and source_id you sent, as you
sent it, with the events it found:
A lookup returns its events in full.include_submarkets now defaults to
true for a lookup, so looking up a spread ticker returns the spread rather
than only the game's moneyline. A list page still defaults to the moneyline,
which keeps it small. Send the flag to choose either way.
Labels mean what market detail says they mean. A Kalshi or Pred outcome is
labelled with its side, Yes or No, as GET /v1/markets/{market_id} names
it. It used to repeat the market's title on both sides, which read as if the
No side backed the team in the title. The title is in market_name. An SX
Bet market is now named by its two outcome labels
(Dallas Cowboys -6.5 vs Tampa Bay Buccaneers +6.5), as market detail titles
it: SX Bet's own name for a market is the game's team pair, shared by the
moneyline and every spread and total.
Also in this release:
Submarkets are listed full game first, then each half and quarter, with the
moneyline, spreads, totals and props in that order. Outcomes list the away
team, the cover side and the over first.
Fields that are always present are marked required: an event's sport, league
and participants, a source market's market_name, and every outcome's
label and outcome_id.
A Polymarket condition ID, the condition_id that wallet positions carry,
works as source_id=polymarket:<condition_id> and on
GET /v1/markets/polymarket:<condition_id>.
A player prop's own market_id finds its game under the default strict
policy too. It used to find nothing unless you sent
player_prop_match=same_prop.
New 400s, each quoting the value: an outcome ID that names a side in every
market of its venue (kalshi:yes, sxbet:outcomeOne), which points you to
the market's market_id; a control parameter sent twice
(limit=1&limit=50 used to apply the first); and an event_id that matches
no event but is a venue's identifier, most often a Polymarket slug that
carries a night game's UTC date, which names the provider to send it under.
GET /v1/events/{event_id} returns every nested market_id platform-native,
as documented. AlphaArcade, ProphetX, Pred, Hyperliquid and Limitless
returned it with the provider prefix, so on three venues a matched market's
market_id was not among its own event's markets. Send the event's
platform with a nested ID to market detail.
The docs site's /llms.txt lists every page again (it was serving
[object Promise]), and /llms-full.txt includes each API reference page's
description.
<id>, sent to market detail with the event's platform
Fix
Matching keeps late, postponed and settled games whole
A review of GET /v1/matching-markets/sports against every venue's own data found no wrong mappings in current games, but four ways games were dated or archived wrongly. All four are fixed.
Late games no longer drop out at midnight Eastern. An event whose venues published only a date, or whose start was 8 PM EDT (00:00 UTC), was read as starting the previous evening. Its one-day grace then ran out at midnight while the game was still being played, so a 10 PM ET tip-off left the default list and lookups mid-game. Every venue's exact start is now used, and an event is never dropped before the date its event_id ends in plus one day.
Postponed games stay one event. Polymarket and Predict keep a game's original date in its slug, so a rained-out game split into two events, one per date. Both venues are now dated by their structured start time in every league, as NFL already was.
Settled games keep every venue. Venues settle and delist a game one at a time, so with include_settled=true an archived game kept only the last two venues that still listed it, and a game whose lines closed while its player props stayed listed could vanish. For a game that has started, include_settled=true now returns the fullest mapping it had, from before its first venue settled. Archive entries from before the September 30 period-line fix, which filed Polymarket's quarter moneylines as the full game, are removed.
ProphetX player props name their line. A ProphetX market ID resolves to whichever line ProphetX currently favours, and that moves before kickoff, so a matched prop's market_id could price a different line on market detail. Matched props now use <event_id>:<market_id>:<line>, which GET /v1/markets/{market_id} resolves to that line; a line ProphetX no longer lists answers 404.
SDKBreaking
SDKs 0.20.0 type event_id as always present
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.20.0, following the matching lookup changes shipped the same day.
event_id on every source market is a required string. It was always present in responses; the types now say so, so you can pass it to getEvent without a guard. TypeScript's eventId changes from string | undefined to string, Python's event_id from str | None to str, and Go's EventID from *string to string: drop the dereference (*m.EventID becomes m.EventID). That is the one source-breaking change, and only in Go.
The docstrings match the API.source_id and event_id describe outcome-token lookups (Polymarket, Predict, AlphaArcade), case-insensitive matching, and the 400s for an empty value, a comma-joined list, or a venue ID sent as event_id. include_submarkets says the default covers events whose full-game moneyline is matched. market_slug now says market detail takes market_id: it accepts a Polymarket slug but not an AlphaArcade one.
0.19.0 keeps working against the API; upgrade for the corrected types and docs.
NewFixBreaking
Matching lookups: outcome tokens, any case, and errors instead of silent misses
A review of the new matching response, as a customer would use it, found
lookups that failed quietly. This release fixes them.
New.
source_id accepts an outcome token on Polymarket, Predict and AlphaArcade:
source_id=polymarket:<clob token id> finds the game the token belongs to.
These tokens identify one market on their own. Other venues' outcome IDs
(yes, outcomeOne, 4) name a side within one market and are not accepted.
Lookups match case-insensitively: source_id=kalshi:kxnbagame-26oct20okcsas
and event_id=NBA-OKC-SAS-2026-10-20 find the same games as the venue's own
spelling. The response is still keyed by the identifier as you sent it.
Errors instead of silent misses. Each of these used to answer 200 with an
empty or unrelated result, and now answers 400 quoting the rejected value:
an empty lookup value (source_id=), which used to list 25 unrelated events;
a comma-joined list (source_id=kalshi:A,kalshi:B), which used to match
nothing; repeat the parameter instead;
a venue's own ID sent as event_id (a Kalshi ticker, a numeric ID, an SX Bet
L… fixture, an 0x hash, a ULID), which used to match nothing; send it as
source_id={provider}:{id}.
A retired lookup parameter such as kalshi_event_ticker still answers 400,
and its message now names the replacement (source_id=kalshi:{id}).
Default population. Without include_submarkets=true the response publishes
only each event's full-game moneyline, so it now lists only events whose
full-game moneyline is matched, and submarkets is never empty. An event
matched only on spreads, totals or props used to appear with no submarkets; set
include_submarkets=true to see it.
Also:
source_markets[].event_id is now a required field. It was always present
in practice; the next SDK release types it as a plain string (Go EventID
changes from *string to string).
Event titles always use the participants' full names
("Arizona Cardinals vs. New York Giants"). Some used a venue's abbreviated
title, and one event matched only on a Predict spread was titled
"Spread -3.5".
SDKBreaking
SDKs 0.19.0 adopt the one-shape matching response
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.19.0. They type the new GET /v1/matching-markets/sports response, which uses one shape for every venue.
The response is canonical_events (canonicalEvents in TypeScript, CanonicalEvents in Go). The markets field and the PlatformMarket type are gone. By default each event carries its full-game moneyline; set include_submarkets for every matched submarket.
Every source market has the same fields:provider, event_id (the venue's parent, for GET /v1/events/{event_id}), market_id (for GET /v1/markets/{market_id}), market_name, market_slug, and outcomes, each mapping a native outcome_id to a canonical_outcome_key.
Lookups take source_id (sourceId, SourceID) as {provider}:{id}, alongside event_id. The eight per-venue request fields, such as kalshi_event_ticker and pred_market_id, are removed.
The API already serves this shape, and older SDKs cannot read it: 0.18.0's Python client raises a parsing error because it requires markets, and its TypeScript and Go clients find no markets. Upgrade to 0.19.0. Matching markets describes the shape, and Filtering the lookup rules.
BreakingSDK
Matching responses now use one shape for every venue
GET /v1/matching-markets/sports used to answer with two shapes. The default
markets map gave each venue its own field names: Kalshi's event_ticker and
market_tickers, Polymarket's market_slug and token_ids, and everyone
else's market_id and outcome_ids. include_submarkets=true then added a
canonical_events map with uniform fields. Code reading the default map had to
branch per venue, and the obvious shortcut broke on exactly one: a Kalshi row
had no market_id, and its event_id, an event ticker, looks just like a
market ticker, so sending it to GET /v1/markets/{market_id} answered 404.
There is now one shape. Every response is a canonical_events map. Each
event lists its participants and submarkets, and every venue market inside a
submarket carries the same references:
event_id is the venue's parent event, for GET /v1/events/{event_id}.
market_id is the market itself, for GET /v1/markets/{market_id}. Each
outcome ID is mapped to the canonical outcome it pays on, so you can tell which
team a venue's yes backs without parsing tickers. By default each event
carries only its full-game moneyline, the same content the old map described;
include_submarkets=true adds every other matched submarket.
Lookups take one parameter. The eight per-venue parameters
(kalshi_event_ticker, polymarket_market_slug, predict_market_id,
sxbet_market_id, alpha_arcade_market_id, prophetx_event_id,
prophetx_market_id, pred_market_id) are replaced by
source_id={provider}:{id}. It takes any market_id, market_slug or
event_id a source market publishes, including SX Bet L… fixture IDs, which
no old parameter accepted. event_id still takes
canonical keys, and the two now combine, across venues, up to 100 identifiers
per request.
Read canonical_events instead of markets. Each old row maps onto the
source markets in an event's moneyline submarket:
Kalshi's market_tickers are the market_ids of its two entries, one per
team.
Polymarket's market_slug and token_ids are its entry's market_slug
and outcomes[].outcome_id.
Every other venue's market_id and outcome_ids are its entry's
market_id and outcomes[].outcome_id.
A row's event_id is now on each entry.
Replace per-venue lookups with source_id.kalshi_event_ticker=KX…
becomes source_id=kalshi:KX…, predict_market_id=1784504 becomes
source_id=predict:1784504, and so on. Response keys are the identifiers as
you sent them, so they now carry the prefix.
Lowercase provider replaces uppercase platform (ALPHA-ARCADE became
alpha-arcade). It is the value GET /v1/markets/{market_id} and
GET /v1/events/{event_id} take as platform, so you can pass it straight
through, or build the composite {provider}:{id}.
include_submarkets=false no longer omits canonical_events. It returns
each event with only its moneyline.
The old lookup parameters now answer 400 with the list of accepted ones.
Clients still reading markets get nothing: the Python SDK raises a parsing
error, and the TypeScript and Go SDKs see a missing or empty map. Upgrade to
the SDK release that follows this change for the new types.
Fix
SX Bet and ProphetX `pricing.as_of` docs now match the API
Since SX Bet moved its order book to V3 at its 2026-08-25 cutover,
GET /v1/markets/{market_id} has returned pricing.as_of: null with
pricing.as_of_kind: unknown on every SX Bet market, because the V3 book
publishes a version counter and no timestamp. The 2026-08-24 entry announced
that, but the API reference and the SDK docstrings still described SX Bet's
retired V2 timestamp as a live quote stamp, and the published SX Bet example
still showed one. All three now describe what the API returns: SX Bet as_of
is always null, so bound observed_at instead. The example is now a real
reading of a live NFL moneyline.
The as_of reference also gains the ProphetX entry it never had. ProphetX
reports as_of_kind: quote, and its stamp is the best resting offer's own
updated_at, so on a thin book it can be days old while availability reads
live. Measured across 51 quoted markets sampled from the whole ProphetX
catalog on 2026-10-03, it ran from under a minute to 2.6 days old, with a
median around two hours. Size any bound on it to the venue, not in seconds.
The trading fees guide, which said as_of_kind tells
you whether as_of can be bounded in seconds, now says this too.
Two server-side changes came with the cleanup, and neither changes a response
SX Bet produces today. A failed SX Bet order-book read used to fall back to the
retired V2 route, which has answered 400 to every request since the cutover;
that fallback is gone, so a failed read reaches
pricing.availability: unavailable without a guaranteed-failing second
request. And a book that SX Bet answers but that cannot be read as that
market's book (an explicit failure status, a missing side, levels that do not
parse) now also reports unavailable, instead of passing for an empty or
one-sided book. None of those shapes occurred across 500 live SX Bet markets
checked on 2026-10-03.
NewSDK
Sports matching now tells you how old its answer is
GET /v1/matching-markets/sports reads a matching snapshot built in the
background, and published no timestamp. A lookup that matched nothing answered
{"markets": {}} whether the game is genuinely unmatched across venues or the
snapshot had stopped updating hours earlier — and a game listed after a freeze
can never be in a frozen snapshot. That second case has happened in production,
and nothing in the response could tell the two apart.
Every response, in list and lookup mode alike, now carries a snapshot block:
What the value means.observed_at is when the OLDEST venue read behind the
matching snapshot began — the same "when PredictorSDK read it" meaning it has on
the catalog's snapshot.observed_at and on pricing.observed_at. It is the
start of the read, so it never claims data is newer than it is, and it is
deliberately not the time the snapshot was written: a snapshot can keep a venue's
last good read for up to 10 minutes, and its write time would hide that.
What to expect. Each venue is re-read about every 30 seconds plus the time
the read takes, so while matching is healthy observed_at is normally under two
minutes old and never more than about 12. Anything older means matching has
stopped updating: treat an event the response does not contain as unknown, not
unmatched. The filtering guide
has a worked check.
It describes each response. Unlike the catalog, matching has no immutable
snapshot to bind a cursor to, so pages of one traversal can come from successive
snapshots and the value can advance between pages. Settled events added by
include_settled=true do not affect it.
observed_at is null when the age is unknown — briefly, while a snapshot
written before this release is still being served. Treat null as unknown age,
not as fresh.
Matching snapshots are also fresher and more complete. A snapshot used to be
rebuilt once for every venue refresh, in arrival order; under load each rebuild
trailed the newest venue reads by 20 to 100 seconds (measured in production).
Pending refreshes are now folded into one rebuild over the newest read of every
venue. And a restart of the matching worker no longer replaces the complete
snapshot with one holding only the first two venues to answer: it now waits for
every venue's first read (at most 90 seconds) while the previous snapshot keeps
serving.
A recent observed_at says the snapshot is updating, not that every venue is in
it: a venue whose reads keep failing is dropped once its last good read is 10
minutes old, and its events can then be missing from a current snapshot.
The TypeScript, Python and Go SDKs gain a SportsMatchingSnapshot type on the
response; the field is a parsed date-time. Nothing was removed and no existing
field changed, so a client that ignores the new block keeps working.
SDK
SDKs 0.18.0 add matching snapshot freshness
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.18.0. It is the first version that types the snapshot block every GET /v1/matching-markets/sports response now carries, in list and lookup mode alike.
snapshot.observed_at is when the oldest venue read behind the matching snapshot began, and null when its age is unknown. It parses to Date | null in TypeScript, datetime | None in Python, and *time.Time in Go. Check it before treating an event a response does not contain as unmatched; Check how old the answer is has the numbers and a worked example.
The Polymarket wallet positions reference also documents its cursor page-size rules. The API already serves the snapshot block, and 0.17.0 predates it; older clients ignore the field.
Fix
Calgary and Vegas NHL games now match on Polymarket and Predict
Fix. Polymarket's NHL game slugs, and the Predict category slugs that mirror them, spell Calgary cal and Vegas las, while Kalshi writes CGY and VGK. Those two spellings were missing from the NHL team table, so every Calgary and Vegas game was left out of GET /v1/matching-markets/sports on Polymarket and Predict: 22 of the 182 open Polymarket NHL games on September 30. They now match under the canonical cgy and vgk codes, as in nhl-cgy-van-2026-10-03.
Fix
Half and quarter lines corrected on sports matching
Fix. On GET /v1/matching-markets/sports, Polymarket's 1Q–4Q moneylines on NBA and WNBA games were filed as the full-game moneyline, so the default response's Polymarket token_ids carried ten tokens instead of the game's two; its quarter spreads and totals were filed as full-game lines, and NBA "team to score first" markets as the moneyline. Each Polymarket market is now filed under the period Polymarket assigns it, and types outside matching scope are no longer matched. Half and quarter moneylines are no longer matched, because the venues settle a tied period three ways (Kalshi lists the tie as its own contract, Polymarket splits it 50-50, SX.Bet voids it); that removes Kalshi half-winner rows that carried no outcomes and Kalshi "No" selections labelled as the other team winning. A Kalshi team leg loses on a tie, so "Golden State wins the 1st quarter" is Golden State −0.5 for the quarter, and it is now matched as that spread alongside Polymarket's −0.5 quarter spreads. Second-half and fourth-quarter lines now match only when the venue's rules exclude overtime: Kalshi's second half excludes it and Polymarket's counts it, so the second-half spreads previously matched across the two venues are no longer joined.
Fix
Polymarket positions validate upstream pages and cursor bounds
Polymarket wallet positions now reject incomplete or inconsistent upstream pages with 502 instead of returning a successful empty or truncated result. Positions cursors cannot bypass the public 100-item page-size maximum: a cursor carrying a larger size returns 400. The pagination guide and API reference now explain the first-page default of 50, cursor-preserved page sizes, the unknown total (0), restarting cursors from the previous implementation, and Polymarket's rejection of protocol contract addresses.
SDK
SDKs 0.17.0 add Pred
The TypeScript (@predictorsdk/client), Python (predictorsdk), and Go (github.com/PredictorSDK/sdk-go) SDKs are released at 0.17.0, the first version that includes Pred, the ninth provider. pred is a typed provider and platform value on catalog, market-detail, event, and matching responses, and in the provider and platform request parameters. GET /v1/matching-markets/sports exposes the pred_market_id lookup filter, which takes a <parent_market_id>:<child_market_id> pair or a bare parent market ID, and market detail's trading_fees rounding types the down direction that Pred's fee schedule uses. The API has served all three since Pred shipped; 0.16.0 predates them, so upgrade to use them from an SDK.
BreakingInfra
Polymarket wallet positions now use Data API v2
The wallet positions endpoint now reads Polymarket Data API v2 ahead of the upstream v1 retirement on October 24, 2026. The public /v1/polymarket/wallet/positions response keeps its shape, and the upstream request pins the v1 defaults (open positions, 1-share floor, size-descending order), so the served population and page order are unchanged — verified live against both APIs side by side, byte-identical first page. Pagination is the breaking part: cursors minted by the old offset-backed implementation are rejected, so restart from the first page; has_more is now the upstream's exact answer rather than a full-page heuristic; and on cursor pages pagination.limit reports the page size the cursor carries. One edge: v2 rejects Polymarket protocol contract addresses (such as the zero address) with a 400 where v1 served an empty page.
New
Pred joins as the ninth provider
Pred, a peer-to-peer sports prediction exchange on Base, joins as the ninth provider behind the pred source key. GET /v1/markets, GET /v1/markets/{market_id}, and GET /v1/events/{event_id} serve Pred markets as provider-native passthrough: the tradeable unit is the child market, with parents grouping the selections of one market (e.g. the three 1x2 outcomes of a soccer moneyline), each addressed as a pred:<parent_market_id>:<child_market_id> pair.
Identity notes: the 0x + 64-hex id halves share their shape with SX Bet market hashes, so v1 routing is composite-only or ?platform=pred, and bare ids are never inferred. Quotes come from the child's LONG-side order book (prices in cents) with the SHORT side derived by complement. Reads are public and keyless — login-with-signature mints a JWT for trading routes only, so there is no read credential to configure. trading_fees on market detail reports the venue's published schedule — a taker-only quadratic at fee_rate_bps/10000 with makers free, rounded down to 1e-6 — read from the parent record's fee_rate_bps. Pred participates in GET /v1/matching-markets/sports for NFL full-game moneylines, spreads, and totals (NBA mapped on the same terms but unverified until the season opens), and all other families and leagues stay passthrough-only. A Pred spread ("New York Jets Wins By Over 7.5 Points") is published as its subject giving the points — Jets −7.5 — the same contract as the per-team spread markets Kalshi lists. Look up a match by Pred id with the new pred_market_id filter, which accepts the <parent_market_id>:<child_market_id> pair a PRED row publishes as market_id or the bare parent id it publishes as event_id. A settled Pred market reports the venue's declared result as its price (1 for the winning side, 0 for the losing one).
New
Limitless joins as the eighth provider (passthrough)
Limitless Exchange, a prediction market on Base, joins as the eighth provider behind the limitless source key. GET /v1/markets, GET /v1/markets/{market_id}, and GET /v1/events/{event_id} serve Limitless markets as provider-native passthrough: binary Yes/No markets keyed by kebab-case slugs, with group (NegRisk) markets exposing one submarket per outcome.
Identity notes: slugs share their shape with Polymarket/Predict slugs, so v1 routing is composite-only (limitless:<slug>) or ?platform=limitless. Quotes come from the YES-side order book with the NO side derived by the venue's own complement; recurring up-or-down price markets label the same YES/NO indices Up/Down. Reads are public and keyless (LIMITLESS_API_KEY is forwarded when set, for future authenticated routes). Limitless is not matched into GET /v1/matching-markets/sports in v1 — its game-line sports coverage (soccer, cricket, tennis) does not overlap any matcher-supported league, and the league-branded rows it does list (NHL conference-champion futures, F1 race winners) are futures/outrights, which the game-scoped matcher cannot match.
Fix
Reliable ProphetX lookups and unavailable-market errors
ProphetX event lookups now fall back to the complete matching snapshot when an older lookup index lacks parent-event entries, preventing known games from returning an empty result during an API/jobs rollout. Market detail maps ProphetX's explicit event_not_available response to 404 instead of an upstream-outage 502. The documentation clarifies explicit provider routing, preserves NBA/WNBA half-line coverage, explains how to obtain a current AlphaArcade market ID, and refreshes expired ProphetX examples. Landing-page provider counts consistently include all seven platforms.
NewFix
AlphaArcade and ProphetX matching lookups plus example refresh
GET /v1/matching-markets/sports gains three lookup filters — alpha_arcade_market_id, prophetx_event_id and prophetx_market_id — so holders of those provider-native IDs no longer need to paginate list mode to rediscover an event. All three accept up to 100 unique values, allow only one filter type per request, skip pagination in lookup mode, and respect include_settled and player_prop_match like the existing five filters. Stale matching examples (mlb-tor-cle-2026-09-02, nfl-no-det-2026-09-13) are refreshed to the live nfl-pit-ne-2026-09-20 three-source home-team spread, and the spec, guides, SDK tables and web coverage copy are corrected to seven platforms with league-specific segment coverage.
NewFix
NFL player props with verified identities and venue rule differences
NFL player-prop comparisons now cover verified Kalshi, Polymarket, and ProphetX contracts through include_submarkets=true&player_prop_match=same_prop, including additional passing/rushing attempts, interceptions, combined yards, and longest-play families when matching lines are available. Each comparison retains the nine-rule matrix and exact native selection references. The all-provider review also tightened fixture wording, player aliases, statistic labels, and strike validation; weekly contracts, whole-number push lines, and ProphetX's contradictory passing-touchdown family remain excluded. See the player-prop guide for provider coverage, known differences, and evidence gaps.
Fix
Correct ProphetX bid and ask direction
ProphetX market detail now treats each selection's best available back offer as its ask and derives its bid from the complementary selection. The previous mapping treated these offers as bids and produced crossed quotes on otherwise valid books. Levels without positive available quantity do not establish quotes. Native event, market and outcome references, primary-strike selection, player-prop matching policies and settlement-rule distinctions are unchanged. Matching remains an identity-only surface; use market detail for quotes and its observation timestamps for freshness.
NewBreakingSDK
Player-prop identity and settlement comparisons
Sports matching now separates verified player-prop identity from settlement
equivalence. With include_submarkets=true, the default player_prop_match=strict
admits only completely reviewed equivalent contracts; the explicit same_prop
mode includes different or unverified rules with a nine-dimension, source-linked
rule matrix. NFL Kalshi/Polymarket game props gain validated threshold/side
parsing and ESPN roster verification. Previously grouped NBA/WNBA props no
longer qualify merely by sharing a name and line, and canonical player keys now
use league-scoped ESPN athlete IDs. Game-line behavior and the compact moneyline
bridge are unchanged. Go, TypeScript, and Python SDKs expose the policy and
comparison types. See coverage, options, and limitations.
New
ProphetX joins as the seventh provider (passthrough + moneyline matching)
ProphetX, a sports betting exchange, joins as the seventh provider behind the prophetx source key. GET /v1/markets, GET /v1/markets/{market_id}, and GET /v1/events/{event_id} serve ProphetX markets as provider-native passthrough, and full-game moneylines for NFL, MLB, NBA, and NHL match into GET /v1/matching-markets/sports alongside the other venues.
Identity notes: ProphetX events are bare integers that collide with Polymarket/Predict numerics, so v1 routing is composite-only (prophetx:<event_id>, prophetx:<event_id>:<market_id>) or ?platform=prophetx. Market ids are per-event type ids — the event half is the identity — and detail resolves the favourite (primary-line) strike of a spread/total. Outcome ids are small market-scoped integers that join to the same market's detail row. Reads are authenticated with a shared per-process session (10-minute tokens, proactive refresh) and paced to the keypair limit.
Spreads, totals, halves, and player props stay passthrough list-only in v1; spread/total matching follows once the spread-subject derivation is proven against live labels.
Fix
Market catalog cursors now stay valid for 8 hours instead of 24
GET /v1/markets cursors stay bound to the immutable catalog snapshot that
issued them. Replaced snapshots now remain available for up to 8 hours instead
of 24. Nothing else about the endpoint changes: same rows, same order, same
filters, same snapshot.observed_at behavior.
Why. Each provider refresh publishes a complete new snapshot and the old
ones are kept so in-progress paginations can finish. At the 30-minute catalog
interval a 24-hour window keeps ~48 copies of every provider, which set the
size of the Valkey instance. Nobody pages a catalog for 8 hours — a full walk
of the whole catalog at the slowest rate limit takes under an hour — so the
older half of the window was shelf space, not a feature. The shorter window is
what lets the instance be right-sized (tracked separately).
What may need changing. If you hold a next_cursor longer than 8 hours
before continuing, it now answers 400 pagination cursor is stale — restart
from the first page. Cursors were already documented as short-lived session
state with no guaranteed grace under storage pressure; treat them that way.
Fix
NFL — AlphaArcade moneylines join matching, first-half rows leave the moneyline, and AlphaArcade detail/events recover from the catalog outage
AlphaArcade began listing NFL game lines for the 2026 regular season (14 of the 16 Week 1 games, everything but the Sunday- and Monday-night games), so NFL events on /v1/matching-markets/sports now carry an alpha-arcade platform row alongside Kalshi, Polymarket, Predict, and SX.Bet. As with MLB and WNBA, the AlphaArcade contribution is the full-game moneyline only.
Fix. Together with the game line, AlphaArcade publishes first-half moneylines, first-half spreads and totals, and team totals for each game, tagged with category names it had not used before (FirstHalfMoneyline, FirstHalfSpread, FirstHalfTotal, TeamTotal). These share the parent's sport_game tag, team names and kickoff, and they were being classified as the full-game moneyline: the moneyline|full|winner submarket of 14 live NFL events listed them as source_markets, and the alpha-arcade platform row carried four outcome_ids (the game-line pair plus the first-half pair) instead of two. AlphaArcade marks every sub-market with a gameMarketId naming its parent game line, and the classifier now refuses any market that carries one before reading a single tag, so a tag we have not seen cannot leak a sub-market into the moneyline again. The outcome_ids on an AlphaArcade NFL row are once more exactly the two CLOB token ids of the game line; nothing else about the response shape changes.
Fix.GET /v1/events/{event_id}?platform=kalshi for a KXNFLGAME-… ticker fans out to the game's sibling series. Kalshi keeps NFL half and quarter winner markets in the bare KXNFL1H, KXNFL2H and KXNFL1Q–KXNFL4Q series (away, home and tie per game); the fanout had been asking for KXNFL1HWINNER, KXNFL2HWINNER and KXNFL1QWINNER–KXNFL4QWINNER, which hold no markets, so those six always landed in fanout.siblings_missing and the winner markets were never returned. The fanout now uses the series that exist and also includes KXNFL1HFT (1st half / full time result), KXNFL1HTEAMTOTAL and KXNFLWINMARGIN, all open for every current game. Player-prop series remain outside the fanout and are available through GET /v1/markets.
Fix. From about 13:57 UTC on 2026-09-09, every GET /v1/markets/alpha-arcade:{id} and GET /v1/events/{id}?platform=alpha-arcade answered 502 upstream service unavailable. Both routes resolved identity by downloading AlphaArcade's whole live catalog in one request, and once that body passed roughly 6 MB (the catalog grew to 2,900+ markets with the NFL season) AlphaArcade's origin began answering that request with a Cloudflare 502 origin_bad_gateway for every API key and every client network; only Cloudflare colos that still held a stale copy answered 200, which made the failure look intermittent and key-dependent. The same catalog read in pages (?limit=1000&offset=N, following nextOffset) is served fresh from the origin throughout, and it is how AlphaArcade's own site loads. The jobs catalog crawl and both API routes now read the catalog in pages, a page that fails fails the whole read rather than producing a partial catalog, and nothing about the response shapes changes. Until this deploys, the alpha-arcade rows on /v1/matching-markets/sports and /v1/markets (served from the store) are unaffected; only the two per-market routes were down.
BreakingFix
Kalshi parlay (multivariate-event) markets are no longer in the catalog
GET /v1/markets no longer lists Kalshi's multivariate-event markets, the
tickers that start with KXMVECROSSCATEGORY. Kalshi's total drops from
about 4.28 million rows to about 110,000, and the unfiltered catalog shrinks by
the same amount. Nothing else about the endpoint changes: same order, same
cursors, same filters.
What these markets are. A multivariate-event market is a parlay a Kalshi
user built. Pick legs from existing markets and Kalshi mints a new yes/no
market that pays only if every leg hits, with a ticker whose tail is a hash of
the selected legs. Every distinct combination anyone has ever assembled is its
own market. On 2026-09-08 that was 4,172,580 of Kalshi's 4,284,410 open
markets, or 97.4%, and the count had grown five-fold in five weeks as the
football season opened.
Why they are out. The unified catalog exists to compare the same market
across venues. A thirteen-leg college football ticket has no counterpart on
Polymarket, Predict, SX Bet, Hyperliquid or AlphaArcade, so nothing can be
matched or compared against it, and every leg it combines is already listed as
its own Kalshi market. Each ticket is typically bought once by the person who
built it, so the book is empty. The title is a bare list of legs ("yes BYU,
yes Texas A&M, yes Toledo, …") with no event or date, and every one of them
landed in category other. In practice anyone paging ?provider=kalshi walked
41 parlays for every ordinary market, and total described how many tickets
Kalshi's users had clicked together rather than how many markets Kalshi runs.
Kalshi itself exposes an mve_filter=exclude parameter because these swamp its
own listings; the catalog crawl now sends it.
If you were treating Kalshi's total as the venue's market count, the
new figure is the right one. Compare against pre-2026-09-08 numbers with
that in mind.
If you were resolving parlay tickers through the catalog, use
GET /v1/markets/{market_id} instead. It fetches Kalshi live by ticker and
is unaffected by this change, so kalshi:KXMVECROSSCATEGORY-SHARD1-… still
returns identity, status, outcomes and pricing.
If you actually want the parlays listed, tell us. The exclusion is a
documented coverage decision, not a technical limit, and it can be made
opt-in.
Sports matching is unaffected: it reads Kalshi's game series directly and
those series never contained multivariate markets.
FixInfra
Faster matching lookups and numeric-team spread safety
Sports matching now treats digits embedded in team names as names, not betting
lines. A live San Francisco row correctly carried +2.5, but the production
contract check read the 49 in “49ers” and incorrectly failed the release;
the same permissive pattern also existed as an ingestion fallback. Both paths
now share one conservative parser that accepts exactly one standalone number,
understands a typographic minus, and refuses dates, scores, records, and other
ambiguous labels. Structured provider line metadata takes precedence over text;
a malformed structured value is refused rather than replaced from the title.
This also protects names such as the Philadelphia 76ers.
Direct matching lookups now use an immutable, run-scoped Valkey index instead
of decoding the complete sports snapshot for every requested identifier. The
index retains its backward-compatible wire shape while new readers fetch only
requested fields from a run-scoped Valkey hash. Versioned metadata verifies the
run and hash cardinality; legacy indexes fall back to the previous scan, while
partial, malformed, or cross-wired hash data fails closed. Settled-event
lookups keep their archive-aware path. API
dependency work, including Valkey socket I/O, is bounded inside the server
write deadline so a stalled read can return a structured service error instead
of closing the connection as an unexplained EOF. Deployment smoke checks also
require every affected service rollout and reject stale provider snapshots.
No response schema changed.
New
NFL matched events on /v1/matching-markets/sports
/v1/matching-markets/sports now matches NFL games across Kalshi, Polymarket, Predict, and SX.Bet. Scope is full-game moneyline, spread, and total — canonical event IDs follow the existing convention, so a Week 1 game is nfl-cle-jax-2026-09-13 and resolves through the event_id filter like any other event. Canonical team codes are the standard NFL short codes (ari atl bal buf car chi cin cle dal den det gb hou ind jax kc lac lar lv mia min ne no nyg nyj phi pit sea sf tb ten was).
Two source-native spellings are aliased onto them: Kalshi writes Jacksonville as JAC where the canonical code is jax, and Polymarket writes the Los Angeles Rams as a bare la while spelling the Chargers lac.
The one behaviour worth reading if you already parse provider slugs: both Polymarket and Predict date NFL game slugs by the UTC date of kickoff, not the Eastern date. Measured on 2026-09-02, counting only games whose UTC and Eastern dates differ:
Venue
MLB
NFL
Polymarket
Eastern (25/25)
UTC (16/16)
Predict
Eastern (25/25)
UTC (18/18)
The convention differs per league, not per venue, and it replicates independently on both slug-based venues. Every affected NFL game is a 20:15 or 20:20 ET prime-time kickoff, which is roughly 20% of the schedule and the most liquid game of most weeks.
Kalshi's ticker uses the Eastern date and SX.Bet publishes a real kickoff timestamp, so PredictorSDK resolves NFL identity from each venue's real start time converted to Eastern rather than from the slug — Polymarket's structured gameStartTime, and Predict's category start time. The practical consequence: Polymarket's nfl-dal-nyg-2026-09-14 and Kalshi's KXNFLGAME-26SEP13DALNYG are the same game and both resolve to nfl-dal-nyg-2026-09-13. Join on the canonical event_id rather than on a parsed provider slug.
Two limits ship with this. Predict lists NFL only in season — its mapping is wired and was verified against its 51 historical NFL game categories, but it had no open NFL markets on the day this shipped, so NFL currently matches across the other three sources. AlphaArcade lists no NFL game lines — checked across its full 2,831-market catalog on 2026-09-02; it carries NFL season futures and player props, which are outside the matching scope for every league. Half, quarter, and team-total markets are out of scope: Polymarket publishes them but Kalshi's corresponding series hold no open markets, so they have no cross-source counterpart. All of these remain available as provider-native markets through GET /v1/markets and GET /v1/events/{event_id}, which already supported NFL sibling fanout.
FixInfraSDK
Catalog ingestion and API documentation hardening
Kalshi's full catalog now streams into an unpublished Redis snapshot one page
at a time and publishes atomically only after the crawl completes. This removes
the multi-million-row in-memory copies that caused a production jobs worker to
be OOM-killed while preserving the previous good snapshot on any interruption.
Publication now verifies every construction key's marker, TTL, and exact
cardinality under a Redis watch, so storage pressure rejects an evicted build
instead of activating partial data. Valkey capacity was raised to hold the
active catalog and its largest complete replacement at the same time.
An independent scheduled live check now fails when any provider's published
catalog snapshot is older than the configured freshness bound.
Sports matching now refuses AlphaArcade rows whose structured game date
contradicts an explicit date in the source's title or slug. Those rows remain
available in the provider-native catalog; only the unsupported identity claim
is withheld.
The SDK guides now document the provider catalog filter, the snapshot
freshness block, and typed 409 Conflict errors. Identifier descriptions and
matching guidance were corrected, the landing example was refreshed from one
coherent four-provider event, and the canonical OpenAPI YAML is available at
/openapi.yaml with a byte-for-byte CI check against the
source spec.
Fix
409 Conflict is now listed in the Error Handling guide
The status-code table in Error Handling listed
400 401 402 403 404 405 429 502 503 and omitted 409, which
GET /v1/markets/{market_id} and GET /v1/events/{event_id} have returned
since ambiguous bare identifiers stopped resolving to the wrong provider's
market on 2026-08-25.
Nothing about the API changed. 409 was declared on both operations in the
OpenAPI spec from the day it shipped and generates as a typed error in all three
SDKs — but a caller who built their error handling from that page had no 409
branch, and the page is the one place that says which statuses to retry. It now
carries a row and an entry under Retry guidance: 409 is a do-not-retry-unchanged
case, because the identifier is genuinely ambiguous and the same request
conflicts again. Re-send with ?platform= naming one of the candidates in the
response body, or with the composite {platform}:{id} form.
Both of that page's hand-maintained lists — the status-code table and the retry
bullets — are now checked against the spec on every CI run, in both directions.
A status code declared on an operation and missing from either list fails the
build, and so does a code listed on the page that no operation declares. The two
router-level statuses documented at the API level rather than per operation
(404 for an unrouted path, 405 for a rejected method) are exempt from
needing an operation response, and the check requires them to still be
documented at the API level rather than taking that on trust.
Fix
Documented example identifiers refreshed, and short-lived ones spent only where the shape needs them
Every expired example identifier in the API reference and the guides has been
refreshed against live data. The canonical event key behind the matching
endpoint's rich canonical_events example had expired, along with its
Polymarket slug, its Kalshi event ticker, and the Polymarket MLB game slug used
on /v1/markets/{market_id}, /v1/events/{event_id} and the
polymarket_market_slug filter. All of them now resolve.
The interesting part is which example got which identifier. Cross-source depth
only exists for imminent games — the venues list a spread days ahead, not
months — so the richest example is always the shortest-lived one. Re-measured
across 103 live canonical events, the furthest-dated event carrying a spread
with three independent sources was one day out, while the furthest-dated event
of any kind was nearly two months out and carried a single moneyline. Standing
one game in for every example therefore spends the shortest lifetime
everywhere.
Each site now gets the longest-lived identifier that still carries the shape it
is teaching:
The league field's description and the "the league is the first segment"
line in the docs illustrate the key format. They need no imminent game, so
they use a season-opener key that resolves for weeks.
The list-mode example needs a Polymarket row, so it moved to the
furthest-dated event that has one. It picked up a PREDICT row on the way,
so the two list-mode entries now show all four matching platforms rather than
three.
Only the include_submarkets=true example genuinely needs a three-source
spread, so it is the only one carrying a short-lived key. Its replacement was
chosen so the canonical key and the Polymarket slug are visibly different
strings for the same game, which is the whole reason the canonical layer
exists, and so the spread's subject is the home team — the side is read from
each source's own labels, never defaulted.
Hyperliquid's examples are the one case that cannot be fixed by refreshing.
Its entire live catalog is a handful of daily-recurring outcomes, so any
specific integer id is valid for about a day and a "current" one is wrong again
tomorrow. Those examples keep their ids and now say so in the example summary
itself — which the SDKs and this site render — instead of only in a comment in
the raw spec.
No API behaviour changed. The SDKs are regenerated because the descriptions and
examples they embed come from the spec.
BreakingFix
Unknown query parameters are now rejected instead of ignored
Every endpoint now returns 400 for a query parameter it does not read,
instead of accepting the request and dropping the parameter.
The parameter this was really about is ?platform= on
GET /v1/markets. That is the spelling
GET /v1/matching-markets/sports
uses for the same concept, so it is the natural guess — and it used to return
the entire catalogue with a 200 on it. Measured against production before
this shipped, ?platform=sxbet, ?providers=kalshi and ?nonsense=1 each
returned the identical total of 1,935,451 and the same first row as an
unfiltered page. A caller who tried any of them had no way to tell the filter
had been discarded. A misspelled filter that silently returns everything is
harder to diagnose than an error, and the fix for
the provider filter shipped a 400 for an
unrecognized value already, so a misspelled name was the only way left to
be silently ignored.
The rejection is uniform. It applies to every route on the API, including
GET /v1/health and the ones that read no parameters at all, because a
guarantee that holds on some endpoints is not one a caller can build against.
Provider webhook callbacks are the single exception — their caller is a third
party that would retry rather than read the error, and no API consumer can
reach them.
GET /v1/markets?platform=sxbet400 {"error": "unknown query parameter", "message": "This endpoint does not read the query parameter \"platform\". It accepts: category, cursor, limit, provider.", "status_code": 400}
The response names the key it rejected and lists the ones that endpoint
accepts, so the fix is visible in the error rather than requiring a trip to
the reference. On /v1/markets the parameter you want is provider — see
Filtering.
Who this affects. Only hand-built requests. The generated TypeScript,
Python and Go clients expose exactly the parameters each endpoint declares and
physically cannot send anything else, so no SDK call changes and the generated
clients are byte-identical. If you build requests yourself, drop any parameter
that is not listed under the endpoint in the reference. Like the router's
404 and 405, this answer comes from the router before the API key is read.
FixBreaking
Kalshi matching rows now name real markets, and their outcome IDs join to market detail
GET /v1/matching-markets/sports exists so you can take a provider's exact
native references and look them up on GET /v1/markets/{market_id}. Kalshi was
the last provider where that didn't hold, and it was broken in two different
ways at once.
market_id on a moneyline row was the event ticker, and it 404s. Kalshi
lists a game moneyline as one binary market per team — "Arizona wins" and
"San Francisco wins" are separate markets. Matching folded that pair into a
single source_markets[] entry and put their shared event ticker in
market_id, which GET /v1/markets/{market_id} has never served. Each Kalshi
market now gets its own entry:
outcome_id was a market ticker, not an outcome reference. On spread, total
and player-prop rows — which were already one market each — both outcomes
carried the row's own market_id, so the ID was not even unique inside its own
row and side was doing all the disambiguating. Kalshi outcome IDs are now
yes / no, byte-identical to what market detail returns for the same ticker.
Why that spelling. Kalshi publishes no per-outcome identifier: every market
is binary, and a selection is (ticker, yes|no). Kalshi names that side the same
way everywhere in its own API — as a field-name prefix on the market record
(yes_bid_dollars, no_ask_dollars), as the keys of its order book
(orderbook_fp.yes_dollars / no_dollars), as taker_side on a trade, and as
side on an order. Like SX Bet's outcomeOne / outcomeTwo, these IDs are
market-scoped: read them together with the row's market_id.
A Kalshi row can now appear more than once under one submarket. If you
assumed at most one entry per provider, group by provider. The number of
canonical events and submarkets returned is unchanged — min_sources counts
distinct providers, not entries, so a Kalshi-only submarket still does not
appear.
If you were piping a Kalshi moneyline market_id into
GET /v1/markets/{market_id} it was returning 404. It now resolves. The
event ticker is still available: it is the row's parent on the default
Dome-compatible map as event_ticker, and GET /v1/events/{event_id} is the
route that takes it.
If you were pairing market_id with side because the outcome IDs did not
round-trip, outcome_id now carries the same value as side and joins
directly. Both fields stay.
The default Dome-compatible markets map is unchanged: Kalshi rows still
publish event_ticker plus market_tickers[], with the same values as before.
The ?kalshi_event_ticker= lookup still accepts the event ticker, and now also
accepts the event tickers of a game's spread and total markets. Polymarket,
Predict, AlphaArcade and SX Bet rows are unchanged — Kalshi was the last
provider whose matching outcome IDs did not join.
NewFixSDK
GET /v1/markets takes a provider filter, and rejects a provider it does not recognize
The unified catalog is large — 1.76 million markets today — and
GET /v1/markets walks providers in a fixed sequence: kalshi, polymarket,
predict, sxbet, hyperliquid, alpha-arcade. Kalshi alone has exceeded
789,000 rows, so a caller who wanted SX Bet markets had to page through every
Kalshi, Polymarket and Predict row to reach them. There was no way to ask for
one provider, and the parameter people reasonably guessed at was accepted and
thrown away.
There is now a provider filter.
GET /v1/markets?provider=sxbetGET /v1/markets?provider=polymarket&category=sports
It selects on each row's own provider value, so it takes the same token
Sources lists as that platform's source key, and it
composes with category as an intersection.
Two things change about the response, and both are the point of the filter.
Every data[] row carries the requested provider — previously
?provider=sxbet returned the head of the Kalshi index. And
pagination.total counts only that provider's rows rather than the whole
catalog, so the per-provider totals add up to the unfiltered total instead of
repeating it six times. has_more and next_cursor describe the filtered
traversal too, so paging through one provider ends when that provider ends.
An unrecognized value is now a 400, where it used to be silently dropped.?platform=sxbet, ?provider=sx_bet, ?provider=notaplatform — all of them
previously returned 200 with a full page of Kalshi markets and the whole
catalog's total, which reads as "the filter worked and these are the results."
Only the six canonical IDs are accepted, case-insensitively, and the error names
the legal ones:
{ "error": "invalid provider: must be one of kalshi, polymarket, predict, sxbet, hyperliquid, alpha-arcade", "status_code": 400}
That includes spellings the platform override on
GET /v1/markets/{market_id} and GET /v1/events/{event_id} tolerates
(sx_bet, hl, aa). Those two parameters are a different feature — they name
the venue a single identifier should be resolved against, not a filter on a
list — and the accepted set here is exactly the published enum so a typo cannot
look like a successful filter.
If you were already sending platform= or provider= to this endpoint and
ignoring the result, nothing changes for platform= (it is still not a
parameter on this route, and still ignored) — but provider= with a value
outside the six now returns 400 instead of an unfiltered page. Read the six
values from the error message or from GET /v1/categories' neighbour,
Sources.
provider is part of pagination scope. Keep the same value when
following a next_cursor; changing or dropping it mid-traversal returns
400 pagination cursor does not match the current filters, the same way
category already did.
pagination.total under a filter is the filtered count. If you were
treating total as the catalog size, read it from an unfiltered call.
Unfiltered pagination is unchanged: same order, same totals, same cursors. The
SDKs gain an optional provider argument on getMarkets.
NewSDK
GET /v1/markets now tells you how old the catalog is
GET /v1/markets returned exactly two top-level keys — data and
pagination — and no timestamp anywhere. The endpoint reads a stored catalog
snapshot rather than calling the venues, so "how old is this page" had no
answer and was not "now": a page of 1.76 million rows could have been crawled
ten seconds or two hours ago and looked identical either way.
It sits beside data, not inside pagination, because it describes the data
rather than how the page was cut.
What the value means.observed_at is when PredictorSDK finished reading
the catalog — the same meaning it already carries on pricing.observed_at and
trading_fees.observed_at, and deliberately not as_of, which is reserved for
a venue's own stamp. The difference from those two is which read: they stamp
the upstream call your request made, while this stamps the background ingestion
crawl that built the snapshot, so it is normally minutes to hours old rather
than milliseconds.
It is a floor, and ?provider= makes it exact. Providers are crawled on
independent schedules, so the catalog is a merge of snapshots of different ages
and the published number is the OLDEST of them. Every row on the page is at
least that fresh; most are fresher. Under ?provider= the value is that
provider's own read time, the same way pagination.total already counts only
the filtered set — so filtering is the cheapest way to trade a conservative
bound for an exact one.
It does not move during a traversal. A market cursor stays bound to the
immutable snapshot that issued it, so every page of one traversal reports the
identical observed_at even while newer snapshots publish behind you. Start a
fresh first page to see a newer one.
observed_at is null when the bound snapshot carries no read time — treat
that as unknown age, not as fresh.
The TypeScript, Python and Go SDKs gain a MarketsSnapshot type on the list
response; the field is a parsed date-time, not a string. Nothing was removed
and no existing field changed, so a client that ignores the new block keeps
working.
Fix
What the rate-limit headers actually promise
X-RateLimit-Remaining does not count down. Across strictly sequential
requests inside one X-RateLimit-Reset window it repeats values, skips them,
and goes back up — measured on 2026-08-26 against a 1000 req/min key, 120
sequential requests over three windows produced 42 increases, the largest
+8. A client that slowed down as the number fell was reading a signal that
sometimes rose while it was spending.
Nothing about the numbers changed, because nothing about them is wrong. What
changed is what we say they mean, in openapi.yaml and in the guides.
The budget recovers continuously, not at a boundary. This is the piece that
made the old documentation misleading. X-RateLimit-Reset was described as
"when the current window resets", which invites the obvious model: spend
X-RateLimit-Limit requests, wait for the reset, get a fresh allowance. That is
not what happens. Four boundary crossings measured on the same day moved the
reported budget by +5, 0, +2 and +2 — never a step back to the limit. Earlier
requests age out gradually, which is also why X-RateLimit-Remaining can rise
while you are consuming it.
X-RateLimit-Remaining is now documented as advisory, with the measured
spread and the direction of the error: it runs ahead of your consumption, and
by the end of a fast burst it over-stated the true remaining budget by up to 44
requests. The gap widens the faster you send, so it is least accurate exactly
when a caller would consult it. Treat a low value as a reason to slow down;
never treat a high one as permission to burst.
Pace from X-RateLimit-Limit instead, which equals the rate_limit_per_min
already published for your plan on GET /v1/plans. Keep your own timestamps for
a rolling 60 seconds, or just space requests 60 / limit apart, and back off on
429. The 429 and its Retry-After are the authoritative signals; they are
computed from the limiter's own window instant and never disagree with
X-RateLimit-Reset. The
Error Handling guide has the
recipe.
We deliberately did not smooth the header. Clamping it to a monotonic
minimum would publish a number the rate limiter never reported, in a header
whose name promises the limiter's own accounting — the same kind of invention
this API refuses elsewhere when it leaves a venue's figure null rather than
computing a plausible one.
Also clarified: unauthenticated routes carry none of these headers, because the
limiter is only consulted once an API key has been read. That covers
GET /v1/plans and the router's own 404 and 405.
FixBreaking
SX Bet outcome IDs from matching now join to market detail
GET /v1/matching-markets/sports exists so you can take a provider's exact
native references and look them up on GET /v1/markets/{market_id}. For SX Bet
rows, half of that was broken: matching published outcome IDs shaped
<market_hash>:1 / <market_hash>:2, while market detail returned
outcomeOne / outcomeTwo for the same market. Two spellings for one
selection, and neither resolved on the other surface.
SX Bet rows now publish outcomeOne and outcomeTwo — in
canonical_events[].submarkets[].source_markets[].outcomes[].outcome_id and in
the default map's markets[].outcome_ids — which is byte-identical to what
market detail returns:
Why this spelling. SX Bet publishes no per-outcome identifier at all: a
selection is a market hash plus one of exactly two positions. It encodes that
position several ways in its own API — an integer on the settled market record,
a boolean on orders and fills — but outcomeOne / outcomeTwo is the only one
that addresses a side, and it is how SX Bet's order-book snapshot keys its two
level arrays. The old composite was ours, not SX Bet's, and it duplicated the
market_id the same row already carries.
These IDs are market-scoped, like Kalshi's yes / no on market detail: read
them together with the row's market_id. If you were splitting on the colon to
recover the market hash, use market_id instead — it is the same value.
Polymarket, Predict and AlphaArcade rows are unchanged; their outcome IDs
already matched market detail. Kalshi rows are also unchanged and are still the
one provider whose matching outcome IDs do not round-trip — they are the sibling
market tickers under the event, with side naming the selection.
BreakingFix
Ambiguous bare identifiers now return 409 instead of the wrong provider's market
GET /v1/markets/{market_id} and GET /v1/events/{event_id} accept a bare
identifier and infer the platform from its shape. Bare numeric ids and
kebab-case slugs do not name a platform — Polymarket and Predict both use those
shapes — so the service probed Polymarket first and returned whichever provider
answered first.
Those id spaces genuinely overlap, so "first to answer" was frequently the wrong
provider. Measured against production on 2026-08-25, across 93 Predict market ids
taken from our own GET /v1/matching-markets/sports response:
53 of 93 (57%) returned an unrelated Polymarket market with a 200 on
it. Predict market 1607914, an Arizona Diamondbacks vs. San Francisco Giants
game, came back as "Will Ze-Cheng Dou finish in the Top 5 at the 2026 Valspar
Championship?"
57 of 93 (61%) resolved to a different provider on /v1/markets than on
/v1/events, because a Polymarket market with that number existed while a
Polymarket event with it did not. A caller walking the documented pipeline —
matching row → event_id → /v1/events → nested market_id → /v1/markets —
silently changed provider partway through.
Nothing in the response said so. The provider field was correct about what was
returned; it just wasn't what you asked for.
What changed. Both endpoints now probe every candidate platform concurrently
and answer only when exactly one of them holds the identifier. When two do, the
request fails with 409 Conflict naming both instead of picking one:
{ "error": "ambiguous market_id", "message": "market_id \"1607914\" resolves on polymarket and predict, which are different markets. Retry with ?platform= naming the one you want, or use the composite {platform}:{id} form.", "candidates": ["polymarket", "predict"], "status_code": 409}
candidates is the actionable part: every entry is a legal platform value, so
a client can retry automatically. The SDKs expose this as a typed
ConflictError carrying an AmbiguousIdentifierError body.
GET /v1/events/{event_id} also now accepts the composite
{provider}:{native_id} form that GET /v1/markets already returned in
data[].id. Previously GET /v1/events/predict:1607914 was treated as an opaque
ambiguous id and surfaced as 502; it now resolves.
This is breaking for one case only: a bare numeric id or kebab-case slug,
sent with no ?platform=, that exists on both Polymarket and Predict. That
request used to return a Polymarket 200 and now returns 409.
For Polymarket callers the change is small — on a uniform sample of 160 integer
ids, 114 were real Polymarket markets and only 2 of those (2%) also existed on
Predict. For Predict callers it converts a majority of silently-wrong responses
into a loud, self-describing error. Nothing that returned the right market
before returns an error now.
The fix is one parameter, and it is the same rule for every provider:
# before — inferred, and wrong 57% of the time for Predict idscurl "$BASE/v1/markets/1607914"# after — deterministic, one of:curl "$BASE/v1/markets/1607914?platform=predict"curl "$BASE/v1/markets/predict:1607914"
Every response that hands you an identifier also hands you its platform:
GET /v1/markets returns the composite form directly in data[].id, and every
row of GET /v1/matching-markets/sports carries a platform field that
?platform= accepts verbatim (case-insensitively, including ALPHA-ARCADE). So
?platform={row.platform} is a single safe rule across all six providers.
Naming the platform is also strictly more available than not naming it: the probe
has to reach both candidates to prove there is no collision, so it fails if
either is having an outage, while an explicit platform depends only on that one.
Composite ids, explicit ?platform=, Kalshi tickers and SX Bet identifiers never
probe and can never return 409.
Fix
A dead SX Bet identifier no longer reports an upstream outage
GET /v1/markets/{market_id} and GET /v1/events/{event_id} answered
502 upstream service unavailable for some identifiers that could never
exist. The status code is the whole message: 502 tells a client the
venue is down and the request is worth retrying, while 404 tells it the
identifier is dead and it should stop asking. Reporting the first when
the second was true turned a single typo'd or expired identifier into an
unbounded retry loop.
SX Bet identifiers were the remaining gap, and both endpoints now
validate the shape before calling upstream:
/v1/markets — a market_id that is not a 0x-prefixed 64-hex
market hash now returns 400 invalid market_id. Previously
sxbet:0xdeadbeef and eleven other malformed shapes returned 502.
/v1/events — an event_id that is not a fixture id (an uppercase
letter followed by digits, e.g. L19825821) now returns
400 invalid event_id. This also closes a worse case: a single-digit
event_id such as sxbet:1 made the upstream market filter drop out
entirely, so the endpoint returned 200 with about a hundred markets
drawn from unrelated fixtures grouped under one title.
Well-formed identifiers are unaffected. An identifier that is shaped
correctly but does not exist still returns 404 — including
sxbet:0x0000…0000 and sxbet:L99999999 — and a genuine upstream
failure still returns 502, so a client can still tell "this is gone"
apart from "try again later". Both routes already documented 400 for an
invalid identifier, so no API schema or SDK signature changed.
This is the same classification the Hyperliquid and AlphaArcade lookups
already used: malformed input is 400, a confirmed miss is 404, and
502 is reserved for the case where we genuinely could not find out.
FixInfra
Every documented example identifier now resolves, and CI keeps it that way
Almost every copy-pasteable identifier in the reference and the guides had
expired. An external audit extracted 42 example market ids, event tickers, and
canonical event keys from api/openapi.yaml plus the docs and found 38 of them
dead — including the market_id parameter's own example (kalshi:KXNBA-26-SAS),
the quickstart's headline lookup, and the spec's list-mode and Kalshi-fanout
examples. The two worst cases returned 200 with an empty body rather than an
error, which is the hardest failure for a new caller to diagnose. Every example
identifier in the spec, the quickstart, the concept pages, the guides, and the
Yaak request stubs has been refreshed against live data and verified to resolve.
The rot was structural rather than editorial, so the strings alone were not the
fix. Kalshi game tickers, Polymarket game slugs, and canonical event keys stop
resolving once an event settles, and Hyperliquid's entire live catalog is a
handful of daily-recurring outcomes — so any specific Hyperliquid integer id is
valid for about a day. Three things change as a result:
The quickstart now leads with a call that needs no identifier, shows the
response, and then looks up a key taken from it. The first thing a new caller
copies no longer contains an identifier that can expire, and a callout says
plainly that the sample identifiers elsewhere in the docs are samples.
GET /v1/markets/{market_id} and GET /v1/events/{event_id} now say which
identifier spaces expire and where to get current ones. Long-dated markets
(Kalshi season futures, multi-year AlphaArcade questions) and settled
Polymarket, Predict, and SX Bet ids stay resolvable; game lines do not.
A live check requests every published identifier after each deploy and
fails when one stops resolving, counting an empty 200 as a failure. On
failure it prints current identifiers pulled from the API, so refreshing an
expired example is a substitution rather than an investigation.
Two examples also became more accurate in the process. The /v1/markets list
example previously showed a fabricated Polymarket id in 0x-hex form, which is
not the shape Polymarket ids take; it now shows real rows. The NFL examples on
/v1/matching-markets/sports were removed — NFL is not in that endpoint's
coverage (NBA, WNBA, NHL, MLB), so they could not have matched anything.
No API behaviour changed. The SDKs are regenerated because the descriptions and
examples they embed came from the spec.
FixBreakingSDK
Empty and untradeable order books no longer report `availability: live`
pricing.availability on GET /v1/markets/{market_id} meant one thing —
"every outcome carries a price" — and a price is not a market. An empty
AlphaArcade order book fell back to a stale catalog midpoint; a Predict market
quoted 0.01 / 0.99 published the arithmetic midpoint of that range; both
reported live. Sitting next to a venue with a real book, those numbers read
as an edge:
event
Predict
our availability
Polymarket
apparent edge
mlb-mia-wsh-2026-08-28
MIA 0.515
live
Miami 0.735
0.220
mlb-cin-chc-2026-08-28
CIN 0.485
live
Cincinnati 0.395
0.090
mlb-hou-nyy-2026-08-27
HOU 0.500
live
Houston 0.430
0.070
None of those edges existed. Each Predict quote came from a book tens of cents
wide, on a venue that publishes, per market, exactly how wide a book it counts
as liquidity.
A new indicative value now separates the two claims. live means every
outcome is priced and at least one outcome has a two-sided book the venue
itself treats as quoted. indicative means every outcome is priced but none of
those prices has such a book behind it — a mark, one side with nothing facing
it, or a two-sided book the venue's own spread threshold rejects. price,
bid, and ask are unchanged and nothing is nulled: the venue's numbers are
real information, and what changes is the claim made about them.
Three things drive the new grade:
Predict's per-market spreadThreshold is now read. It is the venue's own
declaration of the widest bid/ask spread it counts as liquidity — the same
number Predict's liquidity-reward rules tell makers to stay below. It is
genuinely per-market: measured live across 6,122 open markets it took three
values (0.06 on 4,828, 0.04 on 1,156, 0.03 on 16), so it cannot be a
constant. 56% of two-sided Predict outcomes in that sample were quoted
outside their own market's threshold.
An empty book is one answer, not two. AlphaArcade markets that never
traded carry no midpoint and already reported no_quotes; markets whose book
emptied out kept a stale one and reported live. Same upstream fact, so they
now agree — no_quotes where there is no price, indicative where there is
a mark.
One resting order is not a market. A book with a bid and no ask still
produces a price on both outcomes, and that is now indicative on every
provider. Settled Polymarket markets, whose 1/0 outcomePrices are
resolution marks with no book at all, land there too.
Venues that publish no spread threshold get no invented one; only the
two-sidedness test applies to them. A Polymarket market with a real book on its
primary outcome stays live even though the other outcome carries a mark only,
because the market is genuinely quoted.
What breaks.indicative is a new enum value, so a client that switches
exhaustively on availability needs a branch for it, and markets that
previously reported live now report indicative. Code that filters on
availability == "live" will see fewer markets and should — that is the fix. If
you only need "is there a number here", the guarantee on price now covers
liveandindicative: both promise a non-null price on every outcome.
Anything sized or arbitraged against a quote should read live alone, and take
the executable price from the venue's own book.
GET /v1/markets/{market_id} also gained an up-front statement of what the
pricing tier can and cannot support, so the limits are stated once at the top
rather than spread across six field descriptions.
Fix
Finished games no longer appear on the default sports matching page, and `include_settled` now does something
GET /v1/matching-markets/sports was serving games that had already been played
alongside today's slate. On 2026-08-25 the default limit=100 page carried four
MLB games from May and June — the oldest, mlb-tb-nyy-2026-05-23, 94 days past —
mixed in with that evening's fixtures. And include_settled, the parameter that
exists to separate the two, changed nothing: absent, =false and =true
returned identical key sets with the same total.
Both are fixed, and they were the same problem. include_settled now selects
which events a request draws from:
Default (include_settled absent or false) — events whose scheduled
start has not certainly passed. That is today's games plus a one-day grace, so
a late Pacific start that runs past midnight Eastern is never dropped while it
is still being played.
include_settled=true — the same set plus events whose game date is
further in the past, including ones a venue still lists as open.
This applies to lookups (?event_id=, ?polymarket_market_slug=, …) as well as
to list mode: one parameter, one meaning. If you hold a canonical event_id for
a game that has already been played, ask for it with include_settled=true;
without the flag the lookup answers 200 with an empty markets object.
Why the game date and not the venue's status. Those months-old rows were not
a mapping error on our side. Polymarket was still actively refreshing the
94-day-old market's quote — a two-sided book at 0.42 / 0.43, restamped six
minutes before we read it — and Predict reported tradingStatus: OPEN with no
resolution. Marking them settled would have published a resolution neither
venue has. What we can say honestly is that the game was scheduled for
2026-05-23, which the canonical event key has said all along. So the endpoint
filters on the date it already holds, and nothing on the response claims a market
is closed.
Nothing was added to the response. /v1/matching-markets/sports stays
identity-only — no status, no timestamps, no dates — so pagination.total,
has_more and cursor advancement all describe the filtered set, and a cursor
issued under one setting of include_settled still cannot be replayed against
the other.
Fix
Unrouted paths and wrong methods now return the documented JSON error body
Every documented error on this API is a JSON ErrorResponse — {"error": …, "status_code": …} with an optional message. Two were not: a path matching no
route, and a path called with a method it does not accept. Those two were
answered by the Go router's defaults, text/plain bodies reading 404 page not found and Method Not Allowed.
They are also the first two errors a new caller hits. A mistyped path or a POST
where a GET was meant produced a completely different response shape from
every other failure on the API, and a typed SDK client deserializing the body
got a string where it expected an error object — a parse problem stacked on top
of the original mistake.
Both now match everything else:
// GET /v1/nosuchroute{ "error": "not found", "status_code": 404, "message": "No route on this API matches this path." }// POST /v1/markets{ "error": "method not allowed", "status_code": 405, "message": "This path accepts GET, HEAD." }
The Allow header on a 405 is unchanged and still lists the methods that path
accepts; the message now repeats it, so a client reading only the body can
recover. Neither response requires a valid API key — the router answers before
the key is read — and neither status code changed. If you were matching on the
old plain-text body, match on the status code and the error field instead.
405 was previously absent from the reference. It is now documented at the API
level rather than per endpoint, because a rejected method belongs to no endpoint:
declaring it on GET /v1/markets would claim a status that GET /v1/markets
never returns. Errors a handler produces are unchanged — a market that does not
exist still returns 404 {"error": "market not found"}, distinguishable from the
routing 404 above by that field.
FixSDK
Corrected the documented `getEvent` call on the TypeScript and Go SDK pages
The "Client surfaces" table on the TypeScript and Go SDK reference pages
documented GET /v1/events/{event_id} with the path parameter as a separate
positional argument. It is a field on the request object. Copy-pasting either
row did not compile:
page
published
actual
TypeScript
client.getEvent(eventId, request)
client.getEvent(request)
Go
client.GetEvent(ctx, eventID, request)
client.GetEvent(ctx, request)
So the calls that work are client.getEvent({ eventId: "KXNBAGAME-26OCT20OKCSAS" })
and client.GetEvent(ctx, &predictorsdk.GetEventRequest{EventID: "KXNBAGAME-26OCT20OKCSAS"}).
The Python page was already correct and is unchanged. client.get_event(event_id, ...)
is right there, because the generator hoists a path parameter to a positional
argument in Python and keeps it on the request object in TypeScript and Go. The
three tables describe three genuinely different call shapes; they are not
supposed to match each other.
Only that one row was wrong — every other surface on all three pages already
matched its generated client. The row was hand-written from the endpoint's shape
rather than from the client, and nothing disagreed: the pages' runnable examples
never call getEvent, and code inside an MDX fence is not typechecked by the
docs build. Those tables are now verified against the generated clients and the
OpenAPI spec on every CI run, so a table row that cannot compile fails the build
instead of shipping.
Fix
Spread rows can no longer carry the opposing team's market
GET /v1/matching-markets/sports?include_submarkets=true returns spread
submarkets with a signed line and a subject. Because the sign is only
meaningful relative to the subject, a source market filed under the wrong
subject inverts the position for anyone trading off the row — it is a sign
error, not a cosmetic mismatch.
Two things could produce that inversion.
Team codes were matched as bare substrings. Canonical codes are two or three
characters and appear inside the other team's name: pit inside "Capitals",
ari inside "Mariners", lad inside "Philadelphia", la inside
"Dallas", tor inside "Storm", fla inside "Flames". A substring hit
decided the subject, and it was consulted before Polymarket's own
-spread-away- / -spread-home- slug token, so a collision beat the
authoritative signal. Sweeping every ordered same-league pair through the
classifier, 1,210 of 5,884 cases resolved to the opposing team, and 33 still did
with the structured slug signal present.
Subject resolution now reads explicit source-native signals first — the
provider's own team code, then Polymarket's slug side — and only then scans the
title, as whole-word phrases through the league alias table rather than as bare
substrings. The scan is greedy longest-first, so "Chicago Cubs" is no longer
decided by the "Chicago" it shares with the White Sox, and a title naming both
teams is treated as ambiguous instead of resolving to whichever name was read
first.
SX Bet's side was inferred from label order. Its market_name is only the
fixture's team pair — identical across the moneyline, every spread and every
total on that game — so the side lives exclusively in the outcome labels, each of
which names its own team and carries its own sign ("Baltimore Orioles -1.5"
against "St. Louis Cardinals +1.5"). The classifier read which label carried the
sign rather than which team the label named. It now reads the team out of the
label.
When no source-native signal resolves the side, the market is dropped from the
spread submarket space rather than filed against a default participant. A
spread you cannot attribute is not a spread; the previous behaviour booked it
against the away team, which is correct exactly half the time.
No response fields changed, and no correct row moved: all 257 spread source
markets in the live snapshot resolve identically before and after. Affected rows
are those on matchups where a team's code collides with the opponent's name —
WNBA Seattle at Toronto and MLB Seattle at Arizona are both real fixtures that
would have inverted.
NewSDK
pricing.as_of now declares what it measures, and market detail reports its own read time
GET /v1/markets/{market_id} now returns pricing.as_of_kind and pricing.observed_at. Both are additive; pricing.as_of is unchanged and existing consumers keep parsing as before.
The problem they solve is that as_of was the only quote timestamp on the response, and it does not mean the same thing on every platform. On Hyperliquid it is the order book's own server time and moves with the book. On Kalshi it is a record write that does not move at all while the market is quoted: across two live runs on 2026-08-24 it advanced on 0 of 39 and then 0 of 42 open markets — including five whose prices moved inside the window — and ran anywhere from 15 hours to 137 days old on markets reporting status: open with a live two-sided book. Four different meanings arrived in one RFC3339 string with nothing to tell them apart, so a consumer with a single code path had no good option: a 30-second bound rejected every Kalshi and Polymarket market, and loosening it enough to admit them silently accepted a badly stale Hyperliquid or SX Bet book.
as_of_kind names which meaning you received. quote — the stamp advances when the quote advances, so an age bound on it is meaningful (Hyperliquid, and SX Bet's V2 best-odds path). record_refresh — it advances on a periodic rewrite of the provider's record whether or not the quote moved, so it bounds record age in minutes and quote age not at all (Polymarket: it advanced on 60 of 60 and then 25 of 25 markets while only one of those prices changed, 45 to 405 seconds old). record_static — it does not advance while the market is quoted, so it bounds nothing at any threshold (Kalshi). unknown — as_of is null and the record carries no timestamp of any kind (Predict, AlphaArcade, and SX Bet from the 2026-08-25 V3 cutover, whose order book publishes an opaque monotonic version and no wall clock). The field is always present, and unknown whenever as_of is null — the two can never contradict each other.
One caveat on quote, because it is easy to over-read: it means the stamp tracks the quote, not that every quote provider supports the same threshold. Hyperliquid's l2Book time tracks wall clock to the second. SX Bet's marks when the resting top-of-book order was posted, so on a thin book it is legitimately old — 39 seconds to 2.5 hours measured, median around 24 minutes. Both are honest quote stamps; size the bound to the venue rather than applying one seconds-scale threshold everywhere.
observed_at is when PredictorSDK read the quotes. Unlike as_of its definition does not vary by platform, which makes it the field to bound when you want one threshold that behaves identically everywhere. It is named the way trading_fees.observed_at is, and for the same reason: as_of is the provider's own stamp, and reusing that name for a differently-defined value would bake the confusion into a second field. It bounds the age of the read, not of the quote — this route reads the venue live on every request and caches nothing, so a fresh observed_at next to a day-old as_of on a record_static provider is the honest description of what the venue served. It is stamped before the upstream call, so it is never newer than the moment the quotes were actually observed and now − observed_at never understates their age. It is null only when availability is unavailable; a no_quotes reading is a successful observation of an empty book and is stamped.
The per-provider staleness ranges above are published in the as_of reference description so a threshold can be picked from measured behaviour rather than guessed. They are observed behaviour, not a contract — a venue can change how it writes its records at any time. The Kalshi figure in particular is wide because as_of there tracks when the record was last written, which scales with how long-dated the market is: a same-day game market runs a day or two, while CHINAUSGDP-30 was status: open and quoting 0.15 / 0.19 on a stamp from 2026-04-09. That is the whole argument for record_static — there is no threshold to pick.
One correction while we are here: the 2026-06-05 entry announcing the pricing tier described as_of as a "conservative oldest-side quote timestamp". The oldest-side part is accurate and still holds. Calling it a quote timestamp without a per-provider caveat was not, and as_of_kind is now the authoritative answer on any given response.
NewSDK
Market detail now reports each venue's trading fee model
GET /v1/markets/{market_id} now returns trading_fees: the fee the prediction market itself charges to trade that market, normalized across all six platforms. (Nothing here relates to PredictorSDK's own subscription pricing.) Normalized price alone cannot answer "which venue is cheaper", because every venue's fee is non-linear in price and none of them is expressible as a single basis-point rate — two venues quoting the identical ask can differ by more than 100 bps in true cost, and which one is cheaper can invert during the trading day.
Every published model reduces to a per-share fee times the traded share count, so one descriptor covers all of them: taker.model is quadratic (rate × (price × (1 − price)) ^ exponent — Kalshi, Polymarket, AlphaArcade), min_price (rate × min(price, 1 − price) — Predict), notional (a flat fraction of notional), or none. Both the taker and maker legs are always returned and you pick; PredictorSDK does not infer which side you will be. rounding carries the venue's published rule where it publishes one.
trading_fees.availability keeps the same honesty contract as pricing.availability, and it is on a separate axis from the fee value: availability says what is known, the legs say what is charged. published means the full parameter set is known; partial means fees may be charged but a parameter is missing upstream, so nothing is guessed; account_specific means rates exist but are set per trading account and need your own venue credentials (SX Bet, Hyperliquid); unavailable means our own bounded parameter fetch degraded. A fee-free market is published with model: "none" and rate: 0 — an asserted zero, distinguishable from all three of those.
Two details worth reading before you wire this into a router. charge_basis tells you what the fee is assessed on: fill for most venues, but settlement_profit for SX Bet, which charges on the profit of a winning position at settlement rather than on entry notional — a different shape, not just a different rate. And scheduled_change carries the next published parameter change with its effective time: Kalshi currently runs its entire MLB family at a half-rate fee_multiplier with a per-event override restoring the full rate at first pitch, so the fee doubles on a game that is already trading.
Only Kalshi costs extra upstream work for this — its parameters live on the parent series plus any scheduled per-event override — and those hops are bounded and cached, degrading to availability: "unavailable" rather than failing the lookup. Every other platform reads its fee parameters off the same market record the lookup already fetches.
New
WNBA matched events on /v1/matching-markets/sports
/v1/matching-markets/sports now matches WNBA games across Kalshi, Polymarket, SX.Bet, and AlphaArcade — moneyline, spread, and total game lines plus points/rebounds/assists player props, with the same half, quarter, and team-total submarkets NBA already exposes. Canonical event IDs follow the existing convention, so a WNBA game is wnba-tor-wsh-2026-08-19 and resolves through the event_id filter like any other event. Canonical team codes are the standard WNBA short codes (atl chi con dal gs ind la lv min ny phx por sea tor wsh), including the Toronto Tempo, Portland Fire, and Golden State Valkyries expansion clubs; source-native spellings such as Kalshi's PDX/CONN and Polymarket's gsv/las/nyl/por are aliased onto them.
Predict does not currently list WNBA games, so WNBA events match across four sources rather than five. AlphaArcade contributes the moneyline, as it does for MLB.
Two correctness fixes ship alongside: Kalshi spread markets now resolve the favoured team from the ticker suffix through the canonical team-code table instead of comparing the source-native code directly. Previously any Kalshi spread whose suffix used a source-native spelling — WNBA PDX/CONN, and MLB ATH/AZ — fell back to the away team, which could place the spread on the wrong side and prevent it matching the same line on another platform.
AlphaArcade now derives its game identity from its structured secondaryTitle, yesTeamName/noTeamName, and gameStartTimeMs fields instead of parsing its market slug. Its slugs spell teams as nicknames with inconsistent hyphenation, and their trailing date goes stale when a game is rescheduled, so 7 of 16 live MLB game lines were silently failing to match — either producing no key at all or bucketing on a date no other platform used. Those games now match, and the same resolver is what lets AlphaArcade contribute WNBA.
FixSDK
Matching markets now link directly to provider events
GET /v1/matching-markets/sports now includes an optional event_id on each platform row, so callers can expand a cross-platform match through GET /v1/events/{event_id} without a provider-specific market-to-event lookup. The field uses the provider's native parent identity—Kalshi event ticker, Polymarket event slug, Predict market ID, SX Bet fixture ID, or AlphaArcade parent ULID—while preserving every existing market-level identifier. Send the row's platform value as the events endpoint's platform query parameter on every request: Predict market IDs and AlphaArcade ULIDs are shape-identical to Polymarket identifiers, so without it the lookup can resolve to an unrelated Polymarket event.
SDKFix
SDK v0.12.0 — parity fixes and current Fern runtimes
SDK v0.12.0 brings the TypeScript, Python, and Go packages up to date with the post-v0.11 API contract fixes, including complete rate-limit headers, authorization-dependency errors, stale-cursor behavior, and capped retry delays. The release is regenerated with current stable Fern CLI and language generators, preserves the existing PaymentRequiredErrorAction export, and includes refreshed client runtimes plus package-level build and smoke validation. Upgrade with npm i @predictorsdk/client@0.12.0, pip install predictorsdk==0.12.0, or go get github.com/PredictorSDK/sdk-go@v0.12.0.
FixSDKInfra
Fresh market snapshots, public plans SDKs, and interoperable retries
Unified market pages now bind to immutable, resolvable provider snapshots, so totals, has_more, cursors, and returned rows cannot diverge when old records are pruned. Provider catalogs persist independently and a separate bounded sports path keeps current matching from waiting on a large full-catalog crawl. GET /v1/plans is now part of the public OpenAPI contract and generated TypeScript, Python, and Go SDKs. Rate-limit reset headers use Unix seconds, API-key rate-limit 429 responses include Retry-After, and all SDK retry layers also recognize legacy millisecond epochs. Provider-originated 429 responses may omit Retry-After.
FixSDKInfra
SDK retry caps and contract parity fixes
AlphaArcade MLB moneylines now use the canonical moneyline|full|winner identity, restoring exact submarket overlap with current Predict and Polymarket games. TypeScript and Python retry delays remain at or below 60 seconds even after jitter is applied, matching Go and the documented client contract, and Python now correctly interprets legacy Retry-After-Ms values as milliseconds. Successful-response rate-limit headers and authorization-dependency errors are now explicit in OpenAPI, the public plan example includes all five plans, every SDK guide lists the typed 402 error, and both LLM indexes include every generated API reference page. Retained market-catalog cursors whose snapshot keys expired or were evicted under storage pressure now return the documented stale-cursor error instead of a storage failure; clients should complete traversal promptly rather than treating the full 24-hour retention window as guaranteed. The uncapped Kalshi catalog no longer retains unused MVE metadata for every passthrough row, and the production jobs/Valkey memory declarations now leave headroom for full provider snapshots and reliable AOF rewrites.
NewSDK
Canonical sports submarket mappings
GET /v1/matching-markets/sports now accepts include_submarkets=true to add an opt-in canonical_events identity map, plus event_id for exact canonical-key lookup. It exposes normalized event keys, participants, market type, segment, signed line, spread/player subject, outcomes, and exact provider-native market/outcome references while leaving the compact default response unchanged. Lookup mode accepts up to 100 unique identifiers per request. The generated TypeScript, Python, and Go SDKs ship this surface in v0.10.0. Current status, quotes, liquidity, and order-book depth remain separate market-data concerns.
New
Category filtering for market discovery
GET /v1/markets now returns a canonical category on every market and accepts ?category= to filter the unified catalog across providers. The new GET /v1/categories endpoint lists the supported top-level categories, starting with broad buckets such as sports, politics, crypto, finance, economics, and weather.
New
AlphaArcade added as a sixth provider
AlphaArcade prediction markets (settled on Algorand) now flow through the unified API alongside Kalshi, Polymarket, Predict, SX Bet, and Hyperliquid:
GET /v1/markets lists every AlphaArcade market — binary (Yes/No) and multi-choice (an options[] array, each option its own submarket) — under provider: alpha-arcade.
GET /v1/markets/{market_id} returns full detail with live pricing. AlphaArcade's order book is a 4-sided YES/NO book; the second side's quotes are derived by the cross-side complement (buying Yes at p equals selling No at 1−p), priced from one bounded get-full-orderbook fetch (pricing.source: orderbook). The catalog midpoint is the price mark when the book is empty, and the lookup degrades to mark-only (bid/ask null) rather than failing if the book fetch errors.
GET /v1/events/{event_id} resolves an AlphaArcade multi-choice market to its nested option markets, or a binary market (or single option id) to a single-market event.
AlphaArcade market ids are ULIDs (26-char Crockford base32, e.g. 01KQV5TQ9CE20WPEVJZX2ETNQD) and are not inferred, so look markets up via the composite id alpha-arcade:<ulid> (as returned by /v1/markets) or with ?platform=alpha-arcade (alias aa).
Unlike Hyperliquid, AlphaArcade also participates in cross-source sports matching: its MLB game lines (moneyline) are classified and matched against Kalshi, Polymarket, SX Bet, and Predict in GET /v1/matching-markets/sports, because AlphaArcade's MLB game slugs follow the same mlb-{away}-{home}-{date} convention Polymarket uses. So ALPHA-ARCADE can appear as a platform in matching-markets responses (currently MLB only). Non-MLB AlphaArcade markets are passthrough-only for now.
SDK enums (provider, platform) gain alpha-arcade across the TypeScript, Go, and Python clients — a backward-compatible addition.
New
Hyperliquid added as a fifth provider
Hyperliquid HIP-4 outcome markets now flow through the unified API alongside Kalshi, Polymarket, SX Bet, and Predict:
GET /v1/markets lists every Hyperliquid outcome market (crypto price binaries/buckets, plus economics and sports markets like CPI, the FOMC rate decision, the NBA Finals, and the 2026 World Cup) under provider: hyperliquid.
GET /v1/markets/{market_id} returns full detail with live pricing. Hyperliquid's order book is merged (buying Yes at p equals selling No at 1−p), so each market's two sides are quoted from one bounded l2Book fetch (pricing.source: orderbook); the lookup degrades to availability: unavailable rather than failing if the book fetch times out.
GET /v1/events/{event_id} resolves a Hyperliquid question (e.g. "2026 World Cup Champion") to its nested outcome markets, or a standalone outcome to a single-market event.
Hyperliquid integer ids overlap with Polymarket/Predict numeric ids, so look markets up via the composite id hyperliquid:<id> (as returned by /v1/markets) or with ?platform=hyperliquid (alias hl).
SDK enums (provider, platform) gain hyperliquid across the TypeScript, Go, and Python clients — a backward-compatible addition.
SDK
SDK v0.7.0 — market pricing tier types
Updated TypeScript, Go, and Python clients to v0.7.0 with the new market pricing tier: MarketDetailOutcome gains outcome_id, price, bid, ask, last, and bid_size/ask_size; MarketDetailResponse gains the MarketDetailPricing envelope (availability, scale, source, as_of, neg_risk) plus liquidity_usd, volume_24h_usd, volume_total_usd, contract-count mirrors, and open_interest. All additions are backward-compatible. Install via npm i @predictorsdk/client, pip install predictorsdk, or go get github.com/PredictorSDK/sdk-go.
New
Price, liquidity & volume on /v1/markets/{market_id}
GET /v1/markets/{market_id} now carries a full pricing tier, additively over the existing identity fields. Each outcomes[] entry gains outcome_id (the stable per-platform key: Kalshi yes/no, Polymarket CLOB token id, Predict on-chain id, SX Bet outcomeOne/outcomeTwo) plus price, bid, ask, last as 0–1 probability JSON numbers — price is the implied probability, derived mid-of-book → single side → last trade → platform mark — and bid_size/ask_size where the platform publishes per-side depth. A new pricing envelope discloses availability (live/partial/no_quotes/unavailable), scale (probability_0_1), source (market_record, or orderbook for SX Bet, whose quotes require one bounded second fetch against its best-odds order-book endpoint — on timeout the lookup still succeeds with identity intact), as_of (conservative oldest-side quote timestamp), and neg_risk. Market-level aggregates land as always-present-nullable liquidity_usd, volume_24h_usd, volume_total_usd plus Kalshi-native volume_24h_contracts, volume_total_contracts, and open_interest — values a platform doesn't natively expose are explicit null, never fabricated (Kalshi volume is contract counts, not USD; its deprecated upstream liquidity field is not surfaced as zero). No breaking changes: every new field is additive and existing consumers keep parsing unchanged.
New
/v1/markets/{market_id}
New GET /v1/markets/{market_id} returns a single market's detail across all four supported platforms. Accepts both the composite form returned by GET /v1/markets (kalshi:KXMLBGAME-26MAY262005HOUTEX-TEX) and the platform-native identifier (KXMLBGAME-26MAY262005HOUTEX-TEX, 540817, new-rhianna-album-before-gta-vi-926, 0x70953a9f01fce5c8…). Dispatch is composite-prefix → ID-format inference → Polymarket→Predict probe for ambiguous shapes; pass ?platform= to skip the probe. Response is strict-universal — id, provider, provider_id, title, normalized status (open/closed/settled), and outcomes[].name. Pricing, volume, liquidity, close timestamps, and parent event IDs are deliberately omitted at v0 because they aren't natively available across every platform without a second upstream fetch; they'll be added additively once the underlying coverage permits. Free-tier accessible, permission markets.read, cost 1 credit per call.
New
/v1/events/{event_id}
New GET /v1/events/{event_id} returns an event and its nested markets from any of the four supported platforms. Platform is inferred from the ID format (Kalshi KX…, SX Bet L\d+); for numeric IDs and kebab-case slugs (shared shape between Polymarket and Predict), the endpoint probes Polymarket first and falls back to Predict on 404 — so /v1/events/mlb-world-series-champion-2026 resolves without needing ?platform=. Pass ?platform= explicitly to skip the probe. For Kalshi sports tickers, the endpoint fans out across registered sibling series (spreads, totals, half/quarter lines, props) and merges them into one response — call /v1/events/KXMLBGAME-… to get the moneyline plus first-5 spread / first-5 total / RFI markets in one shot. Free-tier accessible, permission events.read, cost 1 credit per call regardless of fanout breadth.
New
MLB matched events on /v1/matching-markets/sports
/v1/matching-markets/sports now matches MLB game lines (moneyline, runline, total) across Kalshi, Polymarket, SXBet, and Predict. NBA and NHL coverage is unchanged.
New
New endpoint — Polymarket wallet positions
New endpoint: GET /v1/polymarket/wallet/positions returns a wallet's current Polymarket positions. Accepts either address or username. Available on Free. See the API reference.
New
/v1/polymarket/wallet now resolves usernames
GET /v1/polymarket/wallet now accepts a username query parameter as an alternative to address. See the API reference.
New
/v1/polymarket/wallet
New endpoint: GET /v1/polymarket/wallet?address=0x... returns the public profile (image and display name) for any Polymarket wallet. Available on Free. See the API reference.
SDK
SDK v0.3.2 — TypeScript, Go, and Python
Updated TypeScript, Go, and Python clients to v0.3.2. Install via npm i @predictorsdk/client, pip install predictorsdk, or go get github.com/PredictorSDK/sdk-go.
Infra
Lower latency on /v1/markets
Reads on /v1/markets and /v1/matching-markets/* are slightly faster after a cache infrastructure swap — no SDK or API changes.
New
/v1/markets on Free
Free-tier API keys can now call /v1/markets; quotas unchanged at 1,000 req/mo and 10 rpm. See Plans.