REST reference

The read API — leagues, participants, contests, markets, liquidity, orderbook depth, and live game state, normalized across every connected venue. Authenticate with an API key. Use the “Try it” panel under each endpoint to send live requests right from this page.

Base URL

text
https://api.openmarkets.ai/flow/v1

The catalog, liquidity, depth, and live-state endpoints on this page are read-only GETs (with one batch POST for fan-out reads). Pass your key as the X-API-Key header on every request.

The API is not read-only overall. Account, balance, order, and execution endpoints live under /flow/v1/auth/* and work with the same key — see Venues & balances and Placing orders. Your key reaches its own account with no X-OpenMarkets-Account header; GET /auth/me (below) reports what it can do.

Example IDs are illustrative. The friendly ids and hashes throughout these docs (lg_nfl, ct_abc123, p_kalshi, the 6-part position hash) exist only for readability. In production every id and hash is an opaque string — typically pg-<digits>-<rand>-<n>, and a few partners are bare UUIDs. Treat them all as opaque: store verbatim, never construct one, split a hash, or pattern-match on a prefix like p_kalshi.

Response envelope

Every successful response uses the same top-level structure: data, optional pagination, and meta.

json
{
  "data": [...] | {...},
  "pagination": {
    "next_cursor": "2026-04-17T22:00:00.000Z" | null,
    "has_more": true,
    "limit": 50
  },
  "meta": {
    "timestamp": "2026-04-17T22:00:00Z",
    "api_version": "v1"
  }
}

Catalog

Leagues, participants, contests, and markets.

GET/flow/v1/leagues

List leagues you have access to.

NameInTypeRequiredDescription
limitqueryintegernoMax items (default 50, max 200)
Try it
GET/flow/v1/leagues

Max items (default 50, max 200)

GET/flow/v1/leagues/:id

Retrieve a single league. Returns 404 with not_found if the league ID does not exist. participant_type describes the league's competitors: 'team', 'athlete' (e.g. tennis, golf), or null when the league doesn't declare one — don't assume it's always 'team'.

NameInTypeRequiredDescription
idpathstringyesLeague ID returned from /leagues
Try it
GET/flow/v1/leagues/lg_nfl

League ID returned from /leagues

Example response.data
{
  "id": "lg_nba",
  "name": "NBA",
  "participant_type": "team",   // "team" | "athlete" | null
  "status": "active"
}
GET/flow/v1/participants

List participants (teams or athletes) in a league. Requires league_id. Returns a slim shape suited to dropdowns and lookups.

NameInTypeRequiredDescription
league_idquerystringyesLeague to scope to (required)
limitqueryintegernoMax items (default 50, max 200)
Try it
GET/flow/v1/participants

League to scope to (required)

Max items (default 50, max 200)

GET/flow/v1/participants/:id

Retrieve a single participant. Returns 404 not_found if the participant ID does not exist.

NameInTypeRequiredDescription
idpathstringyes
Try it
GET/flow/v1/participants/p_lakers
GET/flow/v1/contests

List contests, filtered and paginated by scheduled start. Each list item carries a has_markets boolean — true when the contest has markets defined — so you can hide contests with nothing to price without a second call.

NameInTypeRequiredDescription
league_idquerystringnoScope to a specific league
statusqueryenumnoDefault: open
start_afterqueryiso8601noOnly contests starting at or after this timestamp
start_beforequeryiso8601noOnly contests starting at or before this timestamp
parent_contest_idquerystringnoChildren of a parent (e.g. matches in a tournament)
cursorqueryiso8601noPass the previous pagination.next_cursor to fetch the next page
limitqueryintegernoMax items (default 50, max 200)
Try it
GET/flow/v1/contests?status=open

Scope to a specific league

Default: open

Only contests starting at or after this timestamp

Only contests starting at or before this timestamp

Children of a parent (e.g. matches in a tournament)

Pass the previous pagination.next_cursor to fetch the next page

Max items (default 50, max 200)

GET/flow/v1/contests/:id

Retrieve a single contest.

NameInTypeRequiredDescription
idpathstringyes
Try it
GET/flow/v1/contests/ct_abc123
Example response.data
{
  "id": "ct_abc123",
  "league_id": "lg_nfl",
  "title": "Kansas City Chiefs vs. San Francisco 49ers",
  "type": "match",
  "starts_at": "2026-09-07T20:20:00Z",
  "status": "open",
  "has_markets": true,
  "tags": { "home_team": "KC", "away_team": "SF" },
  "created_at": "2026-08-01T12:00:00Z",
  "updated_at": "2026-08-15T18:30:00Z"
}
GET/flow/v1/contests/:id/markets

List all markets (moneyline, spread, total, etc.) available on a contest.

NameInTypeRequiredDescription
idpathstringyes
Try it
GET/flow/v1/contests/ct_abc123/markets
GET/flow/v1/markets/:id

Retrieve a single market definition (not specific to any contest).

NameInTypeRequiredDescription
idpathstringyes
Try it
GET/flow/v1/markets/mk_ml

Liquidity & depth

Best-available prices per position, and full per-partner orderbook depth.

GET/flow/v1/contests/:id/liquidity

Current aggregated liquidity positions for the contest, with per-partner prices and distribution-model fair values. available is a float — notional size in USD, not a whole-share/contract count — so don't parse it as an integer.

NameInTypeRequiredDescription
idpathstringyes
Try it
GET/flow/v1/contests/ct_abc123/liquidity
Position shape
{
  "position_hash": "ct_abc123:mk_ml:side_home:var_0:p_kc:tf_full",
  "contest_id": "ct_abc123",
  "title": "Kansas City Chiefs — Moneyline",
  "market_key": "moneyline",
  "side_key": "home",
  "participant_id": "p_kc",
  "consensus_price": 0.52,
  "partner_liquidities": [
    {
      "partner_id": "kalshi",
      "partner_name": "Kalshi",
      "price": 0.49,
      "american_odds": "+104",
      "available": 5400.0,
      "liquidity_hash": "kalshi:ct_abc123:mk_ml:side_home:var_0:p_kc:tf_full"
    }
  ]
}
GET/flow/v1/depth/:position_hash

Full orderbook depth across all partners for a single 6-part position hash. Bulk depth lookup is planned but not yet available — query one hash at a time.

NameInTypeRequiredDescription
position_hashpathstringyesSame value returned by /contests/:id/liquidity
Try it
GET/flow/v1/depth/ct_abc123%3Amk_ml%3Aside_home%3Avar_0%3Ap_kc%3Atf_full

Same value returned by /contests/:id/liquidity

Response.data
{
  "position_hash": "ct_abc123:mk_ml:side_home:var_0:p_kc:tf_full",
  "partners": [
    {
      "partner_id": "kalshi",
      "partner_name": "Kalshi",
      "levels": [
        { "price": 0.49, "available": 2100 },
        { "price": 0.48, "available": 3300 }
      ],
      "total_available": 5400,
      "best_price": 0.49,
      "fetched_at": "2026-04-17T22:00:00Z"
    }
  ],
  "fetched_at": "2026-04-17T22:00:00Z"
}
GET/flow/v1/contests/:id/price-history

Historical price / liquidity series for a contest's positions, from live tick-by-tick out to all-time. Paid capability — requires the price_history entitlement (403 without it). With resolution=auto (the default) the bucket size is chosen from the window width, so you can request any range without tuning it. Always read resolution off the response: it reports what was actually served, which may be coarser than requested. Caveats: an unknown contest id returns 200 with an empty points array, not a 404; and series_truncated appears when the window held more series than limit could cover.

NameInTypeRequiredDescription
idpathstringyes
fromqueryintegernoRange start, unix ms (default: 1h before to)
toqueryintegernoRange end, unix ms (default: now)
resolutionqueryenumnoBucket size. auto picks from the window: ≤6h → tick, ≤24h → 5m, ≤7d → 1h, ≤90d → 4h, beyond → 1d. Explicit values are honoured where the data supports them — 1m is served from ticks inside the 6h window and degrades to 5m beyond it (the response says which).
position_hashquerystringnoComma-separated position hashes to scope to. Strongly recommended: a contest can carry hundreds of series, and scoping is both far faster and avoids series_truncated.
partner_idquerystringnoScope to one venue
limitqueryintegernoMax points returned, budgeted evenly ACROSS series so each keeps a plottable shape. Max 50000.
Try it
GET/flow/v1/contests/ct_abc123/price-history?resolution=auto&limit=5000

Range start, unix ms (default: 1h before to)

Range end, unix ms (default: now)

Bucket size. auto picks from the window: ≤6h → tick, ≤24h → 5m, ≤7d → 1h, ≤90d → 4h, beyond → 1d. Explicit values are honoured where the data supports them — 1m is served from ticks inside the 6h window and degrades to 5m beyond it (the response says which).

Comma-separated position hashes to scope to. Strongly recommended: a contest can carry hundreds of series, and scoping is both far faster and avoids series_truncated.

Scope to one venue

Max points returned, budgeted evenly ACROSS series so each keeps a plottable shape. Max 50000.

Response.data
{
  "contest_id": "ct_abc123",
  "resolution": "5m",
  "from": 1751050800000,
  "to": 1751054400000,
  "points": [
    { "t": 1751050800000, "position_hash": "ct_abc123:mk_ml:side_home:var_0:p_kc:tf_full", "price": 0.51 }
  ]
}

Arbitrage

Cross-venue arbitrage: two mutually exclusive positions whose combined price is under 1.0, so both legs together lock a profit regardless of the result. We detect these continuously across every connected venue as liquidity changes, and expose the live set here. Paid capability — requires the arbitrage:read entitlement (403 without it).

Fees decide whether an arb is real. Venue fee structures differ enough that a healthy-looking gross edge is routinely eaten entirely by taker fees. Every arb carries after_fee_roi_pct and profitable_after_fees computed from each venue's own fee formula, and the legs we surface are the pair that is cheapest after fees — not the pair that looks cheapest gross. Trade off the after-fee numbers, not roi_pct.

Both fields are null when a venue didn't publish a fee packet for one of the legs. That means “fees unknown”, not “fee-free” — so after_fee=true excludes those arbs rather than assuming the best case.

Arbs are ephemeral: the underlying detection cache holds each one for at most 120 seconds and it is rewritten on every liquidity change. Treat a response as a snapshot of this instant. For anything latency-sensitive, use the arbitrage WebSocket channel instead of polling.

GET/flow/v1/arbitrages

Live cross-venue arbitrage opportunities. Filters compose as AND. Scope with contest_id or league_id where you can — an unfiltered call returns every open arb across every league. Each arb carries both legs with every venue quoting them; options[0] on each side is the selected, cross-venue pair the metrics describe.

NameInTypeRequiredDescription
contest_idquerystringnoComma-separated contest ids
league_idquerystringnoComma-separated league ids
market_idquerystringnoComma-separated market ids (e.g. moneyline only)
partner_idquerystringnoOnly arbs where this venue quotes at least one leg
min_roiquerystringnoMinimum GROSS guaranteed profit %, before fees
after_feequeryenumnotrue → only arbs still profitable after taker fees on both legs. Arbs with unknown fees are excluded.
min_after_fee_roiquerystringnoMinimum AFTER-FEE profit %. Implies after_fee=true.
min_investmentquerystringnoMinimum deployable USD across both legs
sortqueryenumnoDescending. Under after_fee_roi, arbs with unknown fees sort last.
limitqueryintegernoMax arbs returned. Max 500. meta.total reports the pre-truncation count.
Try it
GET/flow/v1/arbitrages?sort=roi&limit=100

Comma-separated contest ids

Comma-separated league ids

Comma-separated market ids (e.g. moneyline only)

Only arbs where this venue quotes at least one leg

Minimum GROSS guaranteed profit %, before fees

true → only arbs still profitable after taker fees on both legs. Arbs with unknown fees are excluded.

Minimum AFTER-FEE profit %. Implies after_fee=true.

Minimum deployable USD across both legs

Descending. Under after_fee_roi, arbs with unknown fees sort last.

Max arbs returned. Max 500. meta.total reports the pre-truncation count.

Response.data
[
  {
    "arbitrage_id": "3f2a91c4-8b17-4d02-9e55-7ac1b0d3e884",
    "contest": {
      "id": "ct_abc123",
      "label": "Lakers @ Rockets",
      "league_id": "lg_nba"
    },
    "market": { "id": "mk_ml", "variable_id": "var_0" },
    "combined_price": 0.968,
    "roi_pct": 3.31,
    "after_fee_roi_pct": 1.18,
    "profitable_after_fees": true,
    "max_investment": 412.5,
    "sides": [
      {
        "market_side_id": "side_home",
        "title": "Lakers",
        "options": [
          {
            "partner_id": "p_kalshi",
            "partner_name": "Kalshi",
            "price": 0.478,
            "after_fee_price": 0.492,
            "available": 900,
            "title": "Lakers",
            "position_hash": "ct_abc123:mk_ml:side_home:var_0:p_lal:tf_full",
            "source": "streaming"
          }
        ]
      },
      {
        "market_side_id": "side_away",
        "title": "Rockets",
        "options": [
          {
            "partner_id": "p_prophetx",
            "partner_name": "ProphetX",
            "price": 0.49,
            "after_fee_price": 0.496,
            "available": 640,
            "title": "Rockets",
            "position_hash": "ct_abc123:mk_ml:side_away:var_0:p_hou:tf_full",
            "source": "polling"
          }
        ]
      }
    ],
    "detected_at": "2026-04-17T22:00:00.000Z"
  }
]

Acting on an arb

  • Both legs or neither. The profit only exists as a pair. A filled first leg with a failed second is a naked position, not an arb — size to what both venues can actually absorb (max_investment is the binding constraint across the two) and have a plan for a one-sided fill.
  • They close fast. Between detection and your order reaching a venue, the price that created the edge may already be gone. The narrower the edge, the more this matters.
  • Verify depth before sizing. available is size at the best price only. For the full ladder, pass a leg's position_hash to GET /depth/:position_hash (requires depth:read).
  • Detection is not advice. We report a priced relationship between two venues. Venue rules, settlement-source differences, and your own account limits are yours to check.

Live contest state

Live game state — score, clock, period, box score — churns on a different cadence than contest metadata, so it lives on its own endpoints. Available where we have a sports-data feed for the league. For real-time updates, subscribe to the contest_state channel on the WebSocket stream instead of polling.

GET/flow/v1/contests/:id/state

Live game state for a contest — game_status, clock, period, scores or leaderboard, and a sport-specific situation block. Returns 404 if no state has been cached for the contest yet. For team games, scores is { home_score, away_score, period_scores[] } (per-period breakdowns) — not { home, away }. For individual-sport contests the block is a leaderboard instead.

NameInTypeRequiredDescription
idpathstringyes
Try it
GET/flow/v1/contests/ct_abc123/state
Response.data
{
  "contest_id": "ct_abc123",
  "state": {
    "game_status": "in_progress",
    "clock": { "display": "7:42", "remaining_seconds": 462, "is_running": true },
    "period": { "number": 3, "label": "3rd Quarter", "type": "quarter" },
    "contest_result": "pending",
    "winner_participant_id": null,
    "scores": {
      "home_score": 71,
      "away_score": 68,
      "period_scores": [
        { "home_score": 24, "away_score": 22 },
        { "home_score": 25, "away_score": 20 },
        { "home_score": 22, "away_score": 26 }
      ]
    },
    "situation": null
  },
  "refreshed_at": "2026-05-26T02:14:30Z",
  "cached_at": "2026-05-26T02:14:31Z"
}
POST/flow/v1/contest-state/batch

Fan-out read: live state for many contests in one request (max 200). Body is { contest_ids: string[] }. Returns a map keyed by contest_id — contests with no cached state are simply absent (no 404). Use this to hydrate a multi-game scoreboard without N round-trips.

Try it
POST/flow/v1/contest-state/batch
Request + Response
// Request body
{ "contest_ids": ["ct_abc123", "ct_def456"] }

// Response.data — keyed by contest_id; misses are omitted
{
  "ct_abc123": {
    "contest_id": "ct_abc123",
    "state": { "game_status": "in_progress", "scores": { "home_score": 71, "away_score": 68, "period_scores": [] } },
    "refreshed_at": "2026-05-26T02:14:30Z"
  }
}

Partners catalog

The exchange venues OpenMarkets aggregates. Connecting a venue is done by the user in the hosted flow, not through the API — see /developers/partners-and-balances.

GET/flow/v1/partners

The catalog of exchange venues OpenMarkets supports. The response also carries the credential schema each venue expects — that is what OpenMarkets' hosted connect flow renders; you never collect credentials yourself. New venues appear without a client release.

Try it
GET/flow/v1/partners
Response.data[]
{
  "data": [
    {
      "partner_id": "p_kalshi",
      "name": "Kalshi",
      "status": "active",
      "credential_fields": [
        { "key": "api_key",         "label": "API Key",           "type": "text",     "required": true },
        { "key": "private_key_pem", "label": "Private Key (PEM)", "type": "textarea", "required": true }
      ]
    }
  ]
}
GET/flow/v1/partners/:partner_id

Retrieve a single partner descriptor (same shape as an item from /flow/v1/partners). Returns 404 not_found if the partner ID does not exist.

NameInTypeRequiredDescription
partner_idpathstringyes
Try it
GET/flow/v1/partners/p_kalshi

Your key & entitlements

GET /flow/v1/auth/me reports what the calling key (or JWT) is and what it can do — key type, whether it can place orders, your effective rate limits (which may exceed the documented defaults), recent usage, and your reasoning-credit balance. This is the entitlements endpoint: read can_execute before offering trading, and router_account_id to know which account your reads and writes land on.

GET/flow/v1/auth/me

The caller's own context. Works with an API key (its own account — no X-OpenMarkets-Account header needed) or a player JWT. can_execute:true means this key may post fills via POST /auth/orders/buy (see Placing orders); a data key is always false.

Try it
GET/flow/v1/auth/me
Response.data (API key)
{
  "auth_method": "api_key",           // "api_key" | "jwt"
  "router_account_id": "ra_...",      // the account your /auth/* reads + orders hit
  "can_execute": true,                // gates POST /auth/orders/buy
  "current_key": {
    "key_type": "execution",          // "data" | "execution"
    "rate_limit_per_minute": 300,     // YOUR real limits — can exceed the 60/min default
    "rate_limit_per_day": 50000
  },
  "usage": { "requests_last_24h": 1204, "requests_last_7d": 8130, "errors_last_24h": 3 },
  "credits": {                        // reasoning-credit wallet (fuels the MCP "ask" tool)
    "allowance": 1293,                // monthly plan credits remaining (resets each period)
    "purchased": 0,                   // bought credits (persist across periods)
    "total": 1293,                    // allowance + purchased
    "included_per_month": 5000,       // meter denominator
    "used_this_period": 3707          // consumed this period
  }
}

Performance & P&L

GET /flow/v1/auth/performance returns realized profit-and-loss for the calling account over a date window, computed server-side from settled orders — so you don't roll up order history on the client. Results are always partitioned by currency (real USD vs. practice ATLAS — never blended), and can optionally be broken down per day, league, venue, model, or opportunity type via group_by.

GET/flow/v1/auth/performance

Currency-partitioned P&L for the authed account, aggregated from settled orders over a window (default: last 90 days). Optionally grouped with group_by. Uses the key's own account — no X-OpenMarkets-Account header needed; returns 403 scope_required if the key has no account.

NameInTypeRequiredDescription
fromqueryiso8601noWindow start (ISO datetime). Default: 90 days ago.
toqueryiso8601noWindow end (ISO datetime). Default: now.
group_byqueryenumnoPer-group breakdown inside each currency bucket. Default: none.
Try it
GET/flow/v1/auth/performance?group_by=none

Window start (ISO datetime). Default: 90 days ago.

Window end (ISO datetime). Default: now.

Per-group breakdown inside each currency bucket. Default: none.

Response.data (group_by=league)
{
  "by_currency": [
    {
      "funding": {
        "currency": "USD",
        "is_real_money": true,
        "symbol": "$",
        "precision": 2,
        "label": "Real money"
      },
      "staked": 1250.0,          // SUM(amount) over settled win/lose orders
      "net_pl": 143.75,          // SUM(net_winnings) - SUM(amount)
      "roi": 0.115,              // net_pl / staked (0 when staked == 0)
      "settled_count": 42,
      "pending_count": 3,        // still-open orders in this currency
      "wins": 25,
      "losses": 15,
      "pushes": 2,
      "cancelled": 0,
      "groups": [                // present when group_by != "none"
        { "key": "lg_nba", "label": "NBA", "staked": 800.0, "net_pl": 96.0,
          "roi": 0.12, "settled_count": 28, "wins": 17, "losses": 10, "pushes": 1 }
      ]
    }
  ],
  "from": "2026-04-16T00:00:00.000Z",
  "to": "2026-07-15T00:00:00.000Z",
  "group_by": "league"
}

Account, orders & execution

Reading a user's connected venues, balances, and order history — and placing orders — all live under /flow/v1/auth/* and are documented on their own pages:

  • Venues & balances GET /auth/account/partners, GET /auth/account/balances.
  • Placing orders POST /auth/orders/buy, GET /auth/orders, resting, cancel, and resolution.
  • Rolled-up positions over REST: GET /auth/account/positions (cursor-paginated, default 50 / max 200) — one row per (venue, market, side) with cost_basis, payout, net_pnl (null while open), outcome, and status. Filter with ?status=open or ?status=settled (server-side, so finding your open positions doesn't page the whole lifetime book) and ?partner_id. The response data.source tells you which book answered:
    • Default (source: "orders") — rolls up your OpenMarkets order book (everything placed through Flow POST /auth/orders/buy). These settle the moment we resolve the market, so a finished game never lingers as open. This is what you want for "what did I bet through OpenMarkets, and how did it settle?"
    • ?history=1 (source: "history") — rolls up the connected account's full venue history (partner_account_history), a separate ledger that includes bets placed outside OpenMarkets, keyed on the venue's own position_key. Retroactive coverage varies by venue. Use this for "everything this connection has ever done." Same MCP list_account_positions tool, same row shape.
    Drill into the underlying events with GET /auth/account/history (always the connect-history ledger).
  • Venue logo_url is on GET /partners (catalog), /auth/orders, /auth/opportunities, and now /auth/account/partners + /auth/account/balances.

Pagination

List endpoints return a pagination object. When has_more is true, pass next_cursor back as the cursor query param to fetch the next page. Cursors are opaque but stable — don't construct them yourself.

Coming soon

Planned but not yet available:

  • Batch depth lookup — multiple position hashes in one request
  • Webhooks for liquidity and settlement events