Skip to Content
Market Feed guide

Market Feed integration guide

A practical walkthrough for operators building their own UI on top of the Market Feed, with concrete requests, responses, and the behaviours you handle in a client. The response bodies below are the published contract examples, and the API reference lists every field.

The feed is read-only. It gives you the tenant-filtered market catalog, current prices, and a resync snapshot. You never write through it: positions go through the position API, and live updates arrive on the realtime channel. The feed is both the catalog source for a custom UI and the fallback/resync read when the realtime stream is interrupted.

Authentication

The feed accepts two credentials in the same header:

Authorization: Bearer <tenant API key or widget session token>
  • Tenant API key, from your backend only. The key must carry the read:markets scope, and it must never reach a browser.
  • Widget session token, the short-lived token your backend mints with POST /v1/widget/sessions for one user. A browser may hold it, so a page of yours can read the feed directly. Browser calls are allowed only from the origins our team registered for your tenant (CORS).

Either way the tenant is resolved from the credential, so you only ever see markets enabled for your tenant.

Failure cases (all share the standard error envelope below):

  • No credential, malformed header, unknown/revoked/expired key -> 401 invalid_api_key
  • A widget session token that does not verify or has expired -> 401 invalid_session_token
  • Key without read:markets -> 403 insufficient_scope

Endpoints

All paths are under /v1 on your operator API host. Every response carries Cache-Control: no-store (payloads are per-tenant and price-bearing, so do not cache them in a shared or intermediary cache) and an X-Request-Id you can quote in support requests.

List markets

GET /v1/markets

Returns the markets enabled for your tenant, ordered by slug. By default only tradable markets (open, suspended) are returned; pass ?status= to reach terminal ones.

Query parameters:

parammeaning
statusexact status filter: open, suspended, resolved, voided
categoryexact category filter
limitpage size, 1 to 100, default 50
cursoropaque cursor from a previous page’s next_cursor
{ "data": [ { "slug": "will-it-rain", "question": "Will it rain?", "status": "open", "category": null, "image_url": null, "closes_at": null, "outcomes": [ { "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a01", "label": "Yes", "price": "0.62" }, { "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a02", "label": "No", "price": "0.38" } ], "stats": { "volume": "0.000000", "volume_24h": "0.000000", "open_interest": "0.000000", "position_count": 0 }, "sequence": 1, "epoch": 1 } ], "next_cursor": null, "currency": "EUR" }

Paginate by following next_cursor until it comes back null. Treat the cursor as opaque: do not parse or construct it.

GET /v1/markets?limit=50 GET /v1/markets?limit=50&cursor=<next_cursor>

A price is a decimal string between 0 and 1. It is null while a market has no price yet, so render that case too. Which price you see follows your account’s liquidity mode. A pooled account sees the market’s shared price, the same for every pooled operator, and every pooled operator’s positions move it. A self_funded account has its own price for a market, and only its users’ positions move it. The response shape is the same in both modes.

stats are totals over your own users’ positions on the market, refreshed about once a minute. Other operators’ positions are not in them. They are not in step with the prices and carry no sequence, so do not join them to price updates. A resolved market has stats: null. currency is your tenant’s display currency and the unit of the stats amounts.

Market detail

GET /v1/markets/{slug}

The human-facing read for rendering one market. It adds description, opens_at, the amount limits in stake_limits, and, once terminal, resolved_outcome_id and resolved_at. stake_limits has three fields: max_stake_per_position, min_stake_per_position (the smallest stake one position accepts) and max_open_stake_per_user. A null field means there is no limit. Check a stake against them before you send it.

{ "slug": "will-it-rain", "question": "Will it rain?", "status": "open", "category": null, "image_url": null, "closes_at": null, "outcomes": [ { "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a01", "label": "Yes", "price": "0.62" }, { "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a02", "label": "No", "price": "0.38" } ], "stats": { "volume": "0.000000", "volume_24h": "0.000000", "open_interest": "0.000000", "position_count": 0 }, "currency": "EUR", "sequence": 1, "epoch": 1, "description": null, "opens_at": null, "resolved_at": null, "resolved_outcome_id": null, "stake_limits": { "max_stake_per_position": null, "min_stake_per_position": null, "max_open_stake_per_user": null } }

A market that is not enabled for your tenant returns 404 not_found, the same as a market that does not exist. The feed never tells you a market exists in another tenant.

Snapshot

GET /v1/markets/{slug}/snapshot

The machine resync target: a minimal, stable-shaped document with the market’s sequence and epoch plus per-outcome state. Fetch it when your realtime client sees a sequence gap or an epoch change, then resume applying deltas.

{ "slug": "will-it-rain", "status": "open", "sequence": 1, "epoch": 1, "outcomes": [ { "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a01", "price": "0.62" }, { "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a02", "price": "0.38" } ] }

Price history

GET /v1/markets/{slug}/history?granularity=1m|1h|1d

Chart data: a last-published-price step series per outcome. granularity is required. Optional from and to are RFC 3339 UTC; they default to the trailing 24h (1m), 30d (1h), or 90d (1d). The archive keeps 90 days. A range that would exceed 1,500 points per outcome is rejected with 400 validation_failed: narrow the range or coarsen the granularity. Only 1m points are stored; 1h and 1d are the last sample in each bucket. We cache a history response for 60 seconds on our side.

{ "slug": "will-it-rain", "granularity": "1h", "outcomes": [ { "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a01", "points": [["2026-09-22T08:00:00Z", "0.62"]] }, { "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a02", "points": [["2026-09-22T08:00:00Z", "0.38"]] } ] }

History carries no sequence or epoch: it is append-only past data, and live continuation is the realtime channel’s job. The same visibility rule as detail applies (404 for a market not enabled for your tenant).

Freshness: sequence and epoch

Every market payload carries a sequence and an epoch. There is no ETag.

  • sequence increases as a market’s state changes. If it has not changed since your last read, you can skip reprocessing.
  • epoch identifies a full rebuild of the feed’s read model. If it changes, refetch the snapshot before trusting further deltas.

This is the same freshness mechanism the realtime channel uses, so pull and push never disagree.

Polling

If you poll instead of using the realtime channel, poll no faster than every 5 seconds; tighter loops just burn your rate budget. During a realtime outage every feed response carries an X-PM-Poll-Interval header with the interval in seconds (15 today). Poll no faster than that while the header is present. It is absent at all other times.

Rate limits

Feed reads are limited per minute, and the two credentials count apart:

  • Reads with your tenant API key: 600 per tenant.
  • Reads with a widget session token: 60 per end user. These never count against the tenant limit, so your users’ widget traffic cannot use up the budget of your backend.

On breach you get:

HTTP/1.1 429 Too Many Requests Retry-After: 42

with a rate_limited error body. Wait Retry-After seconds, then retry. Reads are side-effect-free, so retrying is always safe.

Maintenance pauses

If the Market Feed is paused by a kill switch, every endpoint returns:

HTTP/1.1 503 Service Unavailable

with a feed_paused error body. This is a temporary, feed-only pause; back off and retry. It does not affect positions or other surfaces.

Error envelope

Every error shares one shape:

{ "error": "validation_failed", "message": "granularity is required", "details": { "field": "granularity" }, "request_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0b", "ts": "2026-09-22T10:00:00.000000Z", "source": "fp" }

error is the stable machine code to branch on; request_id matches the X-Request-Id header and is what to quote when asking for help, together with source. source is an opaque value: quote it as it is and do not try to decode it.