Changelog

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.

BeforeAfter
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 responseevent.provider
GET /v1/crypto-prices/binance?pagination_key=… and response.prices?cursor=… and response.data, with response.pagination
TypeScript client.getSportsMatchingMarkets({ sourceId })client.lookupSportsMatchingMarkets({ sourceId })
Python client.get_sports_matching_markets(source_id=…)client.lookup_sports_matching_markets(source_id=…)
Go client.GetSportsMatchingMarkets(ctx, &…Request{SourceID: …})client.LookupSportsMatchingMarkets(ctx, &…LookupSportsMatchingMarketsRequest{SourceID: …})
getMarket({ marketId, platform }) and getEvent({ eventId, platform })the same with provider
response.pagination.nextCursor loopsiterate 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:

"lookups": {
  "kalshi:KXNBAGAME-26OCT20OKCSAS": { "event_ids": ["nba-okc-sas-2026-10-20"] },
  "sxbet:L19766755": { "event_ids": ["nba-okc-sas-2026-10-20"] },
  "kalshi:KXNBA-27-SAS": { "event_ids": [] }
}

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.

What to change

BeforeNow
canonical_events[event_id] in list modedata[], each event with its event_id
canonical_events[identifier] in lookup modelookups[identifier].event_ids, with the events in data
An identifier that matched nothing: absentListed in lookups with "event_ids": []
A lookup returned the moneyline unless include_submarkets=trueA lookup returns every submarket unless include_submarkets=false
Kalshi and Pred outcome label: the market titleYes or No; the title is market_name
SX Bet market_name: the team pairIts two outcome labels
/v1/events nested alpha-arcade:<id>, prophetx:<id>, …<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:

{
  "provider": "kalshi",
  "event_id": "KXNBAGAME-26OCT20OKCSAS",
  "market_id": "KXNBAGAME-26OCT20OKCSAS-OKC",
  "market_name": "Oklahoma City wins",
  "outcomes": [
    { "canonical_outcome_key": "winner:okc", "outcome_id": "yes", "side": "yes" },
    { "canonical_outcome_key": "winner:sas", "outcome_id": "no", "side": "no" }
  ]
}

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.

What to change

  • 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:

  {
    "markets": {},
+   "snapshot": {
+     "observed_at": "2026-09-19T21:14:07.412Z"
+   }
  }

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.

What may need changing

  • 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:

VenueMLBNFL
PolymarketEastern (25/25)UTC (16/16)
PredictEastern (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=sxbet
400 {"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:

  "source_markets": [
-   {
-     "provider": "kalshi",
-     "market_id": "KXMLBGAME-26AUG272145AZSF",
-     "market_name": "Arizona wins",
-     "outcomes": [
-       { "canonical_outcome_key": "winner:ari", "outcome_id": "KXMLBGAME-26AUG272145AZSF-AZ", "side": "yes" },
-       { "canonical_outcome_key": "winner:sf",  "outcome_id": "KXMLBGAME-26AUG272145AZSF-SF", "side": "yes" }
-     ]
-   }
+   {
+     "provider": "kalshi",
+     "market_id": "KXMLBGAME-26AUG272145AZSF-AZ",
+     "market_name": "Arizona wins",
+     "outcomes": [
+       { "canonical_outcome_key": "winner:ari", "outcome_id": "yes", "side": "yes" },
+       { "canonical_outcome_key": "winner:sf",  "outcome_id": "no",  "side": "no"  }
+     ]
+   },
+   {
+     "provider": "kalshi",
+     "market_id": "KXMLBGAME-26AUG272145AZSF-SF",
+     "market_name": "San Francisco wins",
+     "outcomes": [
+       { "canonical_outcome_key": "winner:sf",  "outcome_id": "yes", "side": "yes" },
+       { "canonical_outcome_key": "winner:ari", "outcome_id": "no",  "side": "no"  }
+     ]
+   }
  ]

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.

What may need changing

  • 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=sxbet
GET /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.

What may need changing

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

Every response now carries a snapshot block:

  {
    "data": [ ],
+   "snapshot": {
+     "observed_at": "2026-08-25T13:41:22.108Z"
+   },
    "pagination": {
      "limit": 25,
      "total": 1393200,
      "has_more": true,
      "next_cursor": "eyJzIjoiNDQxMzZmYTMi..."
    }
  }

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:

  {
    "platform": "SXBET",
    "market_id": "0xb4d047a709aae881e5ccad9d123592967644ee1df1f17078c762b388e41b81c5",
    "outcome_ids": [
-     "0xb4d047a709aae881e5ccad9d123592967644ee1df1f17078c762b388e41b81c5:1",
-     "0xb4d047a709aae881e5ccad9d123592967644ee1df1f17078c762b388e41b81c5:2"
+     "outcomeOne",
+     "outcomeTwo"
    ]
  }

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.

Migration

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 ids
curl "$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:

eventPredictour availabilityPolymarketapparent edge
mlb-mia-wsh-2026-08-28MIA 0.515liveMiami 0.7350.220
mlb-cin-chc-2026-08-28CIN 0.485liveCincinnati 0.3950.090
mlb-hou-nyy-2026-08-27HOU 0.500liveHouston 0.4300.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 live and indicative: 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:

pagepublishedactual
TypeScriptclient.getEvent(eventId, request)client.getEvent(request)
Goclient.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.