This is the practical companion to the GET /events and GET /events/details reference pages. It assumes no prior knowledge of our data model. If you read one page before integrating, read this one — every section below documents something that has tripped up an integration in practice.
The five-minute summary. Events contain markets, and markets are what you trade. Sports events return their markets grouped by betting line — that grouping is the intended display structure, not an obstacle. Non-sports events return a flat list. Separately, some text fields arrive with unrendered placeholders you must substitute yourself. Handle those two things and the rest of the API is conventional.

1. The object model

Three levels, from broadest to narrowest: You place orders against a market id. Events and groups exist only to organise markets for display.

2. Sports events group their markets by betting line

A sports fixture has dozens of markets, and they are not independent questions — they belong to betting lines. “Moneyline 3-Way” is one line with three selectable outcomes; “Puck Line” is another with two. The API returns them already grouped that way, and that grouping is the intended display structure: render each group as one block with its outcomes side by side, the way a sportsbook does. Non-sports events have no betting lines, so their markets arrive as a flat list. Each element of event.markets is therefore either a group object or a market object:
The rule the server applies: markets sharing the same non-null marketDisplayName are collected into a group; a market whose marketDisplayName is null is emitted on its own. In practice that means sports fixtures come back as groups and everything else comes back flat — but a single event can contain both forms, so handle both. To confirm with real proportions: a sample of 10 live fixtures on the dev environment returned 39 groups, 97 markets inside them, and 0 bare markets.

Rendering it

Walk the array and branch on outcomes — group entries become a titled block, bare entries a single row:
Branch on the presence of outcomes, not on marketDisplayName. The display name is echoed onto every market inside a group as well, so testing it misclassifies every grouped market.
Two details that affect layout:
  • Grouping is keyed purely on the display name, so a lone market with a non-null marketDisplayName still arrives wrapped — a group of one. In the sample above, 4 of the 39 groups had a single outcome. Your group component must render sensibly with one child.
  • outcomes order is not guaranteed. For over/under and handicap lines, sort by metaData.opticOdds.points; otherwise sort by title for a stable display.

When you do want them flat

Some jobs are genuinely per-market — resolving a position, totalling exposure, looking up an order’s market. For those, flatten; just don’t flatten for display, or you throw away the sports structure your users expect:
event.title, event.subtitle and event.rulesSummary arrive fully rendered. Display them as they are. There are no placeholders to substitute to get a working integration. Two things are worth knowing:
  • §3a — one request header puts the closing time on your own viewer’s clock.
  • §3b — $TOKEN$ placeholders in rule and contract copy turn data-source names into links.

3a. Showing the closing time in your viewer’s zone

By default a title names the closing time in US Eastern, because that is where our market sits:
For a viewer in London that is accurate but useless — their clock says ten at night. Send an IANA timezone and we render the copy for you:
That is the whole integration. No placeholders, no renderer, no extra fields to read. The header applies to every endpoint that returns an event title — /events, /events/details, portfolio, bookmarks, orders, positions and trade history — so the same market reads the same time everywhere in your app.
Omit the header and nothing changes. You get US Eastern, exactly as before this existed. An unrecognised zone is ignored rather than rejected, so a bad value degrades to Eastern instead of failing the request.
closeAt is always UTC and never changes. Only the human-readable copy follows the header. Drive countdowns, sorting and anything time-critical from closeAt, not from the title.

Which values you can send

Any zone name from the IANA time zone database. There are 418, and every one of them is tested — if the zone you need is not in the table below, send it anyway and it will work. United States & Canada United Kingdom & Europe Middle East & Africa Asia Australia & New Zealand Latin America Two things worth knowing about the zone label:
  • It follows the event’s own closing date, not today’s. The same template reads EDT for a July close and EST for a January close. You do not have to handle that.
  • Only US and Canadian zones get letter abbreviations. Everywhere else the label is numeric, so London reads GMT+1 rather than BST and Tokyo reads GMT+9 rather than JST. That is the standard US-English rendering and it is unambiguous, just less pretty.
America/Vancouver reads PDT all year because British Columbia stopped observing daylight saving. We follow the time zone database, so changes like this reach you without an API change.

What moves and what does not

A date that names when trading stops follows your zone, together with the time — they describe one instant: A date that names which period the market is about does not move, because that would change the question: The second market settles on October’s figure. It closes at 22:59 UTC on 31 October, so a viewer in Tokyo is already into November when it closes — but it is still October’s number being asked about. The same applies to week ending … copy.
Never derive a month, quarter or year from closeAt. A monthly event closes on the period boundary, so the same market reads September in UTC and October three hours east; a climate monthly event closes 23:59 local, which is the following month in UTC for every one of them. metaData.period is the one value that is the same for everyone — see §3c.

metaData.period — which period a market is about

Present on the cadences named after a period (monthly, quarterly, yearly, season, annual_release, per_meeting) and absent on the rest. Format follows the cadence: 2026-09, 2026-Q3, 2026. Read it whenever you need to group, label or sort by period. It is already the answer — formatting it is all that is left to do, and it is identical for every viewer in the world.

3b. $TOKEN$ tokens in rule and contract text

Rule copy references data sources through uppercase tokens:
Resolve them against the parent event’s metaData.links, keyed by the token name without the dollar signs:
So $BINANCE$ becomes a link to that URL with that label. Tokens appear in event.rulesSummary, in every event.metaData.contractSpec.* section, and in each market’s metaData.underlying.
The currency trap. The same text contains literal dollar amounts — between $685 and $689.99. A naive regex like /\$(.+?)\$/g matches $685 and $ and destroys the sentence. Match uppercase tokens only:
This is not hypothetical: the majority of rule strings in the system contain both a $BINANCE$ token and at least one dollar amount.
Known tokens today are BINANCE, NWS_CLI, USGS_EQ and NWS_TORNADO, but this list grows as data sources are added — resolve dynamically from links, never from a hard-coded map. If a token has no entry, render the bare token name rather than dropping the sentence. Also: underlying contains real \n\n paragraph breaks. Render them as paragraphs; collapsing whitespace produces an unreadable wall of text.
Rule and contract text names a fixed timezone on purpose — do not re-render it. “settles at 4:00 p.m. ET on Oct 16” describes the exchange’s own clock, and it is that instant for everyone whatever zone they read it in. The user-timezone header moves title and subtitle only; rulesSummary, contractSpec.* and each market’s metaData.underlying are settlement terms, and shifting their stated times would misstate the contract.

3c. Building your own copy instead

If you need different wording or another language, metaData.rulebook_variables exposes the values that were substituted into underlying:
Values are strings, including numeric ones ("689.99", not 689.99), and irrelevant variables are present but set to "" rather than omitted. A strikeType: "greater" market still carries empty rangeLow / rangeHigh keys. Test for empty string, not for key absence.

4. Two families of metadata

metaData is a union with no discriminator field. Probe for a key: Never assume a key exists, and ignore keys you do not recognise — new ones are added as new market types launch.

Strike fields depend on strikeType

On templated markets, read metaData.strikeType first, then the field it points at: The other strike fields may be absent or present-but-stale. strikeType is the authority.

5. Categories and the subcategory tree

Every event carries a category and a subcategory. The subcategory is the leaf of an up-to-two-level tree — a subcategory can itself sit inside another subcategory, exposed as parent.
That renders as the breadcrumb Commodities › Metals › Gold. The category sits above the tree and is a separate field — it is not part of the parent chain. Most events are not nested: parent is null, the subcategory is already top-level, and the breadcrumb is simply Category › Subcategory. Both forms occur in the same listing, so handle both:
You know you have reached the top when a node’s parentId is null. In current data the tree is never deeper than two levels, but walking parent costs nothing and will not break if a third level is ever added.
The two endpoints return different subcategory fields. GET /events gives you id, categoryId, imageUrl, name; GET /events/details gives you id, name, parentId — no categoryId, no imageUrl. category.imageUrl is likewise list-only. Both endpoints do return the nested parent. Do not assume a field you saw on one endpoint exists on the other.

Building the navigation

Three endpoints feed a category menu, and they return different projections: /categories/hierarchy is the efficient choice for a menu, with two caveats: its nested subcategories are a reduced six-field shape, and that list is flat — parent/child nesting is not expressed, so it alone cannot render Metals › Gold. For that, use /subcategories.
Counts come back as strings. activeSubcategoriesCount and openEventsCount on /categories/hierarchy are "4" and "8", not numbers. Parse before comparing — "10" < "4" is true as a string. (activeEventsCount on /subcategories is a real number. The two endpoints disagree.)
subCategoryId on /subcategories returns that subcategory’s CHILDREN, not the subcategory itself — subCategoryId=81 (Metals) gives Gold and Silver, while a leaf id gives an empty list. Note the capital C, which differs from subcategoryId on GET /events.
Also note these endpoints validate categoryId, page and limit as integers, whereas GET /events wants strings of digits. Same concepts, different encodings.

Cadence buckets in the navigation

GET /subcategories?withCadence=true powers a “by cadence” menu. It prepends virtual entries to the same subcategories array:
isVirtual: true means the entry is synthesized, not a row in the subcategories table — the server groups the events table by metaData.cadence on each request and shapes the result to look like a subcategory so it can sit in the same menu. Four things to handle:
  • Test isVirtual to separate buckets from real subcategories. Buckets have no id and cannot be passed as subcategoryId anywhere — to filter events by cadence use GET /events?cadence=.
  • count is currently-live events, not all events at that cadence. The 15min bucket reads 6 while thousands of 15-minute events exist, because only six windows are open at once. Top-level bucket counts also honour categoryId only — they ignore subCategoryId and isActive.
  • pagination.count counts only the real rows, so it is smaller than subcategories.length. The virtual entries are also repeated on every page — don’t accumulate them while paging.
  • Every bucket is a valid filter value. The menu and GET /events?cadence= are seeded from the same canonical set, so a filter link can be built straight from any bucket.
To filter a listing, pass subcategoryId. The filter includes descendants: requesting subcategoryId=81 (Metals) returns events whose subcategory is Gold or Silver, not just events assigned to Metals directly. Passing a leaf id (82 = Gold) returns only that leaf. So you can render a “Metals” tab with one call — no need to fan out over child ids and merge client-side.
The expansion is one level deep: the server collects the id you passed plus its direct children. Since the tree is never deeper than two levels today, that covers everything — but it would not reach grandchildren if a third level were ever added.

6. Recurring events: grouping rounds by cadence

Short-cadence markets — 5-minute and 15-minute crypto, hourly ranges, daily settlements — are published as a stream of separate events, one per period. Each round is its own event with its own id, its own markets and its own order book. There is no single “BTC 15-minute market” object to subscribe to. A real sequence looks like this: To build a “rounds” strip — past, live and upcoming periods for one asset — the grouping key is subcategoryId + cadence.

Step 1 — find the subcategoryId

Cadence filtering is almost always scoped to one underlying, and you address that by numeric id. Fetch the list once and cache it:
This endpoint returns data.subcategories (plus a success flag) — a different envelope from /events, which returns data.events. Don’t assume a shared response shape across modules.

Step 2 — query by cadence

cadence is an ordinary query parameter. The canonical set is 5min, 10min, 15min, 1hr, daily, weekly, monthly, quarterly, yearly, season, per_meeting, annual_release and election; anything else returns 400. A cadence describes a recurring window. One-off markets — a sports fixture, an IPO announcement, a company’s KPI report — carry no metaData.cadence and cannot be reached through this filter. The absence is deliberate: treat such an event as a one-off rather than inferring a cadence from its dates.
Real results from the four combinations you are most likely to need:
cadence composes correctly with categoryId and subcategoryId. All filters are applied together, so ?categoryId=34&subcategoryId=67&cadence=5min&status=open returns exactly the live 5-minute BTC round — verified against a running instance. If you have seen advice to omit cadence and filter client-side because “it is ignored when subcategoryId is present”, that is not the current behaviour; passing it server-side is both correct and cheaper than over-fetching.
That third row is the one to internalise: the current round is status=open with isFuture left at its default of false, and it is a single event. Upcoming rounds are also status: "open" but isFuture: true, so status alone does not separate them — you must pass isFuture. Because these calls are scoped to a crypto subcategory, isFuture here means the pre-reveal round; isFutureTradable=true is the same query under a clearer name. Future rounds have not revealed their target yet, which is visible in the data: their titles read BTC 15 min · $TBD target until they open.

Reading cadence off an event you already have

Our own web client reads event.metaData.cadence and nothing else as the primary source — that is the canonical field. But it defends against three real-world variations, and you should too if you accept events from more than one endpoint:
Then normalise, because the same period appears under several spellings: As a last resort the cadence can be recovered from metaData.templateId, which ends with it — BTC_15min → 15min, BNB_1hr_range → (no suffix; falls through). Our client does exactly this:
Use that only as a fallback — templateId is opaque and not every template encodes its cadence in the name.

Where cadence actually lives

Cadence is a property of the event, not of the category or subcategory. It is event.metaData.cadence, and the ?cadence= filter is a containment test against that JSON — there is no cadence field on a subcategory, so you cannot ask “which cadences does Bitcoin offer?” without enumerating its events. The two are orthogonal dimensions, which is why grouping needs both:
  • subcategoryId = which underlying (Bitcoin, Gold, EUR/USD)
  • cadence = how often it repeats (5min, 15min, 1hr, daily, …)
A single subcategory spans many cadences simultaneously. Bitcoin alone carries 5min, 15min, 1hr, daily, monthly and yearly series at the same time; most subcategories run two or more. Filter on only one dimension and you get every series for that asset interleaved, or every asset at that cadence.

Why all three parameters are needed

  • subcategoryId scopes to one asset. Crypto subcategories are per-asset, so this filters server-side — no ticker-prefix matching needed.
  • cadence separates overlapping series. The same asset runs 5-minute, 15-minute and hourly series at once. Drop it and you get them interleaved.
  • isFuture splits revealed from unrevealed. By default one call returns only one kind, so make two requests, as above — or send isFuture=all to get both in a single listing. isFutureTradable is the same filter under a name that cannot be confused with a sports outright — prefer it in new code.
Do not compute the round sequence arithmetically from closeAt. It is tempting to assume rounds are exactly one cadence apart and derive the next period by adding 15 minutes — but real data does not honour that:
  • Gaps. Feed interruptions leave holes; we observe jumps of several hours in an otherwise 15-minute series.
  • Duplicate closeAt. Two events can share an identical close time. In our data every such duplicate is a terminated round that was reissued — filter out status: "terminated" or your strip renders doubled pills.
Sort by closeAt and render what the API returns; never synthesise the periods yourself.
Because each round is a distinct event, navigating between periods means navigating between event ids — and the market ids change with them. Anything you cache per round (order book, positions view) must be keyed by event id, not by asset.

7. Prices

All prices are integer cents, 1–99 — the same scale you submit orders on. 63 means 63¢, which reads as roughly a 63% implied probability.
Every price field is nullable, and null is common. A market with no order book yet returns null for yesBid, yesAsk, noBid, noAsk, currentPrice and lastPrice — not 0. Coercing null to zero renders as a 0% probability, which is wrong and very visible. Show a placeholder instead.
priceDelta is the change against lastDayPrice and is legitimately negative. totalVolume and volume are different fields from different systems: volume is the live figure that sortBy=volume and minVolume operate on and is always present; totalVolume is a persisted column that is frequently null. Use volume.

8. Status, results and settlement

Check status per market, not per event — individual markets can be halted while the event stays open. open is the only status that accepts orders. Market statuses: pending, open, halted, closed, settled, expired, terminated. Events add live (sports fixture in progress). result is null until the market resolves, then YES or NO.
Compare result case-insensitively. Values are uppercase, but a small number of legacy rows carry lowercase yes, so result === 'YES' will misread them.
Two settlement fields deserve explicit warnings:
  • metaData.earlySettlement describes conditions under which a market can settle before expiry. It is informational. Settlement is performed server-side; do not evaluate these conditions to predict a payout. Its conditions object is keyed by strikeType — look up the entry matching this market’s own strikeType, since the others describe sibling markets.
  • metaData.settlement is internal worker bookkeeping. A status of "failed" here does not mean the market failed to settle — it records one unsuccessful price-capture attempt. Use status and result as the authority on whether a market is resolved, and settlementPrice for the value.

9. Which endpoint returns what

There are seven events endpoints and no two return the same projection. Pick by what you need to render, not by what reads best in a URL. Three traps in that table:
/events/trending and /events/search omit metaData. Titles and subtitles are still rendered for you, and user-timezone still applies — but metaData.links, needed to resolve $TOKEN$ rule text, is absent. Fetch the event by id when you need to render rule copy from those responses.
/events/trending omits closeAt. No countdown, no expiry label, from that payload alone.
/events/people-also-traded does not return events at all — just four fields, and the id is called eventId, not id.
/events/search also omits marketDisplayName on its markets, so the grouping rule from section 2 never fires there: its markets is always flat. The m.outcomes ?? [m] flatten remains safe everywhere.

GET /events vs GET /events/details in detail

The two endpoints return the same kind of object, but not the same fields. This is the difference most likely to bite you: you build a list screen against /events, then reuse the same model on a detail screen and find fields missing — or vice versa. Both wrap the event in data.events[]. The list adds pagination; the detail endpoint does not, and always returns exactly one element.
These fields are absent, not null. event.volume on a detail response is undefined, so event.volume ?? 0 silently yields 0 — a real volume figure of zero and “this endpoint doesn’t return volume” become indistinguishable. If you need volume on a detail screen, carry it over from the listing.
Practical consequence: if you generate a shared type from one endpoint, mark every field in the table above as optional, or model the two responses separately. The market grouping rule, all metadata, and all placeholder behaviour are identical across both — only this field set differs.

10. Query parameters

Two behaviours to know before you build filters: Unknown parameters are rejected. The query string is strictly validated — anything not in the documented list returns 400 rather than being ignored. A typo fails loudly, and you cannot pass your own tracking parameters through. isFuture defaults to false. It means two different things depending on the category the call is scoped to: under Sports it selects season-long outrights (Championship Winner, MVP), and everywhere else it selects pre-reveal rounds — published ahead of time, tradable before their window, with the target revealed at open. Both are hidden unless you ask for them. An unscoped call with isFuture=true returns both sets. isFutureTradable is the unambiguous name for the rounds, whatever the call is scoped to, and it wins when both params are sent. Prefer it in new code. Each event and market carries both booleans in the response, so you never have to infer which meaning applied. Pair either with sortBy=openAt to list upcoming rounds in order. status takes a list, and isFuture takes all. To read every event a player could see in one request, instead of one per status plus one for future events:
Separate statuses with commas, lowercase, no spaces — anything else returns 400. Events from every listed status come back interleaved in one paginated set, each with its own status and isFuture, so there is nothing to merge or de-duplicate. When the list includes live, results are ordered live first and then by openAt, and sortBy / sortOrder are ignored. page and limit are strings of digits ("2", not 2). Drive pagination from pagination.hasNextPage, not by comparing count to limit — the latter breaks on the final page. Finally, GET /events/details takes a capital-I Id. Lowercase id is rejected as an unknown parameter.
GET /events/details returns the event inside an array. Even though it resolves exactly one event, the payload is { "data": { "events": [ … ] } } — the same envelope as the list endpoint, minus pagination. Read data.events[0], not data.

11. A worked example

Rendering one event end to end:
Every market inside lines[].markets carries the id you submit orders against, its own status to gate the buy button, and its own nullable prices to display.