Agara

Streaming

Real-time market data over WebSocket. Subscribe to orderbook depth, top-of-book quotes, the public trades tape, and market lifecycle events.

WebSocket origin: wss://app.agara.xyz — connect to /trade/v1/market-stream.

Public — no token required. Subscribe to one or more channels per connection; you receive JSON frames as the market moves. A single connection can carry many subscriptions across many markets.

For per-user order, fill, position, redemption, and collateral events, see the account stream — same protocol, authed.

Connect

Any WebSocket client works. The endpoint speaks JSON text frames; no subprotocol negotiation is required.

export AGARA_BASE_URL="https://app.agara.xyz"
export AGARA_WS_URL="wss://app.agara.xyz"
export AGARA_CHAIN_ID="8453"
export AGARA_COLLATERAL_ADDRESS="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
export AGARA_CONDITIONAL_TOKENS_ADDRESS="0xe12D566cE5Dd8d817488E85a81386878649e3A18"
export AGARA_CTF_EXCHANGE_ADDRESS="0xD06f3fA925D35077A11dB42c6614D684f10B2aD6"

Connect with any WebSocket library and send JSON frames:

import asyncio, json, os, websockets
 
URL = f"{os.environ['AGARA_WS_URL']}/trade/v1/market-stream"
 
async def main():
    async with websockets.connect(URL) as ws:
        await ws.send(json.dumps({
            "op": "subscribe",
            "channels": [
                { "name": "orderbook", "token_id": "21742…36455" },
            ],
        }))
        async for raw in ws:
            print(json.loads(raw))
 
asyncio.run(main())
wscat -c "$AGARA_WS_URL/trade/v1/market-stream"
# then paste:
> { "op": "subscribe", "channels": [{ "name": "best_quote", "token_id": "21742…36455" }] }

Channels

ChannelSubjectWhat you get
orderbooktoken_idFull book snapshot, then deltas
best_quotetoken_idTop-of-book bid/ask, emitted only on real moves
tradescondition_idOne frame per executed trade across all outcomes of the market
market_statuscondition_idHalt / resume / resolve / fee-policy / cross-match events

All channels require a specific subject id. There is no firehose.

Client → server

All client frames have an op. Unknown ops return an error frame — the connection stays open.

subscribe

{ "op": "subscribe", "channels": [
    { "name": "orderbook",     "token_id":     "21742…36455" },
    { "name": "best_quote",    "token_id":     "21742…36455" },
    { "name": "trades",        "condition_id": "0x2174…" },
    { "name": "market_status", "condition_id": "0x2174…" }
] }

Each entry gets its own subscribed ack. Re-subscribing to a channel you already have is a no-op (just a fresh ack).

unsubscribe

Mirror shape — the server stops sending and replies with unsubscribed.

ping and list

{ "op": "ping" }
{ "op": "list" }

ping returns { "op": "pong", "server_time": "..." }. list returns { "op": "subscription_list", "channels": [...] } so you can audit what the connection has open.

Server → client

Every server frame has an op. Subject-bearing frames carry exactly one of token_id or condition_id, never both — clients route on channel and read the relevant field.

opWhen
subscribedAck for a subscribe entry
unsubscribedAck for an unsubscribe entry
updateOne event for a channel you subscribed to
errorA request, subscription, or connection failed. Carries an action telling you what to do; reconnect errors close the connection (see error)
sequence_resetYou missed messages on this channel — discard local state and follow the recovery table
pongReply to your ping
heartbeatServer-pushed every ~10 s
subscription_listReply to your list

update

{
  "op": "update",
  "channel": "orderbook",
  "token_id": "21742…36455",
  "sequence": 12346,
  "data": {
    "kind": "delta",
    "bids": [[60, 12000]],
    "asks": [[62, 0]],
    "price_scale": 100,
    "size_scale": 100
  }
}

For orderbook, the first update carries a snapshot. best_quote sends the current quote when at least one side exists; an entirely empty book stays quiet until a quote appears. For trades and market_status, the first update is the next event after subscribe — there is no replay.

sequence

sequence on every update is a globally monotonic event counter — it tells you the absolute order in which events happened across the whole exchange.

Within a single (channel, subject) pair, frames you receive are strictly increasing: next.sequence > last.sequence, except when a sequence_reset interrupts. After a reset the counter resumes from whatever the next event carries, still increasing from there. A backwards step or equality inside one subject is a bug — please file an issue.

Across channels or subjects, sequence still tells you the true ordering, but the order frames arrive on your WebSocket is not guaranteed to match it. If you need a strict cross-channel timeline, buffer locally and sort by sequence.

The counter is gappy within a channel: an orderbook subscriber sees 1000, 1003, 1007, … because the gaps are events on other channels.

sequence_reset

{
  "op": "sequence_reset",
  "channel": "orderbook",
  "token_id": "21742…36455",
  "reason": "lagged"
}

You missed messages for that channel + subject. reason tells you which of the two causes it was — recovery is identical either way:

  • "lagged"your client wasn't reading fast enough. The subscription remains active, but some messages were dropped. Move slow work out of the read loop before reconnecting.
  • "stream_reset"the live stream restarted. The subscription remains active, but you still need to rebuild local state.

Your subscription is still active. What to do depends on the channel:

ChannelWhat the next update will beWhat you should do
orderbookThe next frame may be a delta after client lagDiscard your local book; fetch the REST snapshot or unsubscribe and resubscribe, then ignore deltas until a snapshot arrives
best_quoteThe current top-of-book when either side is non-empty; otherwise no frame until a quote appearsDiscard your local quote; fetch the REST orderbook if you must distinguish an empty book from a delayed update
tradesThe next trade whenever it happens — public trades during the gap are lostResume from the next trade; the API does not currently replay the public tape
market_statusThe next lifecycle event whenever it happens — events during the gap are lostRefetch market state via REST if it matters
account_eventsThe next account event whenever it happens — events during the gap are lostBuffer new events, then reseed with POST /trade/v1/portfolio/open-orders/list and POST /trade/v1/portfolio/positions/list; reconcile by stable IDs before resuming live processing

If you see sequence_reset repeatedly under steady load, it's almost always slow read on your side — profile your read loop before assuming a server-side fault.

error

Something went wrong. The connection normally stays open, but an error with action: "reconnect" is followed by closure. Every error carries a code and a human-readable message, and — when there's a recovery you can act on — an action.

{
  "op": "error",
  "code": "subject_unavailable",
  "message": "subject feed ended; resubscribe to resume",
  "action": "resubscribe",
  "channel": "orderbook",
  "token_id": "21742…36455"
}

action tells you what to do:

  • "resubscribe" — one subscription stopped, but the connection is healthy and your other subscriptions keep flowing. Discard any local state for that subject and send a fresh subscribe for the same channel + subject; the next update is a new baseline. You'll see this as subject_unavailable if a feed ends mid-stream.
  • "reconnect" — this connection can't serve you any further. Close the socket and open a new one, then resubscribe. Two cases: the credential on an account stream was rejected (the token is tied to the subscription, so a fresh one means a fresh connection — refresh it before reconnecting); or the market-data feed is temporarily unavailable (code: "feed_unavailable"), in which case the handshake will return 503 until it's back, so reconnect on a backoff and let it retry.
  • no action field — retrying the same request won't help. Fix the request or stop that subscription. These carry a code you can switch on: unknown_token (no such market), terminal_market (stop subscribing because the market has finished), too_many_subscriptions (you hit the per-connection cap), invalid_message (malformed frame), or wrong_endpoint (that channel belongs on the other stream).

Knowing a feed stopped

A subscription normally goes quiet only because the market itself is quiet — no new orders, no trades. You can't tell a quiet market from a stopped feed by silence alone, so never infer health from the gap between updates. The heartbeat confirms the connection is alive; it does not promise every subscription is still flowing.

Instead, rely on the frames above — the server tells you when a feed actually stops:

You receiveScopeSubscription still live?What you do
sequence_resetone subjectyesdiscard local state and follow the channel recovery table above
error · action: "resubscribe"one subjectnoresubscribe that subject, rebuild from the next update
error · code: "terminal_market"one subjectnostop subscribing to that subject; the market has finished
error · action: "reconnect"whole connectionnoreopen the socket, resubscribe everything

As a backstop against a connection that stops delivering frames without closing, keep your own staleness timer: send a ping on an interval and, if no frame of any kind arrives within a short window, reconnect. See Reconnect strategy.

If the market-data feed goes down entirely, every connection receives error with action: "reconnect" (code: "feed_unavailable") and is closed, and new connections are refused with 503 until it recovers. There's nothing to do differently — your normal reconnect-with-backoff loop is the recovery path: keep retrying and the handshake succeeds once the feed is back. A brief blip won't trigger this; you'll just get a sequence_reset and the feed continues. (In rarer cases we may close the connection with no frame at all — treat it the same.)

Channel payloads

orderbook

First update after subscribe is a snapshot:

{ "kind": "snapshot",
  "bids": [[60, 10000], [59, 5000]],
  "asks": [[62, 8000], [63, 4000]],
  "tick_size": 1,
  "price_scale": 100,
  "size_scale": 100 }

Subsequent frames are deltas (no tick_size):

{ "kind": "delta",
  "bids": [[60, 12000]],
  "asks": [[62, 0]],
  "price_scale": 100,
  "size_scale": 100 }

Each level is [price, size] as integers in engine units. Divide price by price_scale for collateral units and size by size_scale for shares (60 / 100 = 0.60, 12000 / 100 = 120 shares); the scales travel on every frame. tick_size is an integer in the same units as price — divide it by price_scale too (1 / 100 = 0.01). Size 0 removes the level. kind distinguishes the two shapes — route on it.

best_quote

{ "bid": { "price": 60, "size": 12000 },
  "ask": { "price": 62, "size": 8000 },
  "price_scale": 100,
  "size_scale": 100 }

price/size are integers in engine units — apply price_scale / size_scale the same way as orderbook levels. Either side can be null if that side of the book is empty. Frames are emitted only when the top-of-book actually moves.

trades

{ "kind": "trade",
  "fill_id": "42",
  "taker_token_id": "21742633...",
  "maker_token_id": "21742633...",
  "side": "BUY",
  "price": 60,
  "size": 12000,
  "price_scale": 100,
  "size_scale": 100,
  "settlement_mode": "NORMAL" }

One frame per executed trade across both outcomes of the binary market — route on taker_token_id if you only care about a specific side. Fields:

FieldMeaning
fill_idStable identifier for the trade
taker_token_id / maker_token_idOutcome tokens involved on each side. Equal on "NORMAL" trades; different on "MINT" / "MERGE" (where one side bought / sold YES and the other NO via the collateral pair)
side"BUY" or "SELL" — the taker's side; the resting maker did the opposite on "NORMAL" trades
priceThe taker's price per share for taker_token_id, in engine units. Divide by price_scale to get collateral units (60 / 100 = 0.60). See the note below for "MINT" / "MERGE" trades
sizeTrade size in engine units. Divide by size_scale to get shares
price_scale / size_scaleSelf-describing scales — they travel with the event so old clients don't need an out-of-band lookup
settlement_mode"NORMAL" for a regular book crossing; "MINT" when paired buys of opposite outcomes minted new shares; "MERGE" when paired sells of opposite outcomes burned shares back to cash. Tape / chart UIs typically filter to "NORMAL"; volume aggregators usually want all three.

The tape is always taker-perspective: price is what the taker paid (or received) for taker_token_id.

  • On "NORMAL" trades both sides trade the same token at the same price, so price is simply the execution price.
  • On "MINT" / "MERGE" trades the taker and maker hold opposite outcomes (YES vs NO). The price you'd compute from the maker's side is the complement — if the maker's NO traded at 0.40, the taker's YES price is 0.60. They sum to one collateral unit, the value of a YES+NO pair. So price for these trades is 1 − maker_price; the tape reports the taker's number directly so you never have to do that subtraction yourself.

No user identifiers are emitted. For per-account fill events with order_id, fees, and your role (taker vs maker), use the account stream.

How YES and NO match

In a binary market the YES and NO order books are linked, so a trade can print even when no one is on the other side of your book:

  • A BUY YES and a BUY NO match when their prices sum to at least one collateral unit — together they fund a full YES+NO pair, which the market mints into the two new positions (settlement_mode: "MINT").
  • A SELL YES and a SELL NO match the same way in reverse, burning a pair back to cash (settlement_mode: "MERGE").

The engine always fills against the same-outcome book first and only crosses to the complementary book if size remains. This cross-book matching is on only when the market's cross_match_enabled flag is set (see market_status); when it's off, the two books trade independently and you'll only ever see "NORMAL" trades.

market_status

{ "kind": "market_resolved", "winning_token_id": "21742633..." }
kindCarries
market_creatednum_outcomes (currently 2), tick_size, price_scale, size_scale, min_price, max_price, cross_match_enabled
market_halted
market_resumed
outcome_proposedproposed_token_id
market_resolvedwinning_token_id
fee_policy_updated—; future fills can use the updated policy
cross_match_toggledenabled

Reconnect strategy

Connections drop — handle them. Recommended client loop:

  1. Reconnect with exponential backoff capped at a few seconds. A 503 on the handshake means the feed is temporarily unavailable — keep backing off and retrying; it'll accept once the feed is back.
  2. Resend your subscribe set.
  3. Trust nothing locally until each channel has fresh state. The first orderbook update is a snapshot. best_quote sends the current quote when at least one side is non-empty. For trades and market_status, the first update is the next event. Public trades are not replayable; refetch the event detail if you need current market status.
  4. On sequence_reset mid-stream, follow the sequence_reset recovery table. On an error with an action, follow error.

Run a staleness timer alongside this. Send a ping on an interval (every ~25 s is plenty) and track when you last received any frame. If nothing arrives within a slightly longer window (~35 s), reconnect even though you saw no error.

A live WebSocket update stream replaces the polling pattern from the orderbook REST endpoint for any latency-sensitive strategy. The REST endpoint still works for one-shot snapshots and HTTP-only clients.

On this page