Account stream
Real-time per-user events over WebSocket. Receive order, fill, position, redemption, and collateral changes as they happen.
WebSocket origin: wss://app.agara.xyz — connect to
/trade/v1/account-stream.
Authenticated: bearer token required in the first subscription. Subscribe
once per connection; the
server pushes typed JSON order, fill, position, redemption, and collateral
events. Use it as a low-latency supplement to
/trade/v1/portfolio/*, which remains the authoritative
source for your current account state and recovery.
This endpoint is the private counterpart to the public market stream. Application messages must be JSON text frames and cannot exceed 64 KiB. Binary application frames are rejected.
Authentication
Browsers can't set Authorization on a WebSocket handshake, so the
bearer travels inside the subscribe payload. Both token types work:
- A personal access token carrying the
account:streamscope (create one). - A Privy session JWT (browser app surface; identity tokens granted by the in-app Privy login flow).
An HTTP Authorization header does not replace the token in the subscription
payload. If the token is invalid, expired, lacks the scope, or belongs to an
account without a ready AGARA wallet, the server sends one typed error frame
and does not acknowledge the subscription.
Only one account subscription attempt is allowed per socket, even when the first attempt fails or is later unsubscribed. Open a new connection to correct a credential, rotate a token, or subscribe again.
Connect
This endpoint allows 300 connection attempts per IP per 60 seconds, shared across accounts on that IP. See stream connection limits.
No subprotocol negotiation is required. The HTTP upgrade can fail before a socket exists:
| HTTP response | What to do |
|---|---|
401 typed authentication failure | Correct an invalid supplied Authorization header; authentication still happens in the subscription payload |
426 websocket_upgrade_required | Send a valid WebSocket upgrade request |
429 rate_limited | Wait for Retry-After, then reconnect |
503 dependency_unavailable | Back off with jitter and reconnect |
These are HTTP Problem Details, not WebSocket frames.
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 the JSON subscription frame:
import asyncio, json, os, websockets
URL = f"{os.environ['AGARA_WS_URL']}/trade/v1/account-stream"
TOKEN = "agt_…"
async def main():
async with websockets.connect(URL) as ws:
await ws.send(json.dumps({
"op": "subscribe",
"channels": [{ "name": "account_events", "token": TOKEN }],
}))
async for raw in ws:
print(json.loads(raw))
asyncio.run(main())wscat -c "$AGARA_WS_URL/trade/v1/account-stream"
> { "op": "subscribe", "channels": [{ "name": "account_events", "token": "agt_…" }] }Channels
| Channel | Subject | What you get |
|---|---|---|
account_events | The token's owner | Order and fill updates, position changes, redemptions, and collateral balance changes |
Only one subscription attempt is allowed per connection. The token pins the channel to one user; rotate it by opening a new socket.
Client → server
Same op vocabulary as the public stream:
{ "op": "subscribe", "channels": [
{ "name": "account_events", "token": "agt_…" }
] }
{ "op": "unsubscribe", "channels": [
{ "name": "account_events", "token": "agt_…" }
] }
{ "op": "ping" }
{ "op": "list" }The protocol shape requires a token on unsubscribe, but the server never
echoes credentials in acknowledgements or subscription_list. ping and
list otherwise behave as on the public stream. After unsubscribe, open a new
socket if you need to subscribe again.
Server → client
Same frame envelope as the public stream:
op | When |
|---|---|
subscribed | Auth succeeded; events will start arriving |
unsubscribed | Ack for an unsubscribe entry |
update | One account event |
error | A typed authentication, protocol, subscription, or connection failure |
sequence_reset | You missed messages; reseed via REST, then keep listening. Carries a reason (lagged / stream_reset), same as the public stream |
pong | Reply to your ping |
heartbeat | Server-pushed every ~10 s |
subscription_list | Reply to your list request; credentials are never included |
subscribed is sent only after authentication succeeds:
{
"op": "subscribed",
"channel": "account_events"
}A list response likewise omits the token:
{
"op": "subscription_list",
"channels": [{ "channel": "account_events" }]
}Account-stream errors use the same nested envelope as the market stream:
{
"op": "error",
"failure": {
"code": "identity_token_expired",
"title": "Your session has expired",
"detail": "Please log in again to continue.",
"recovery": {
"strategy": "refresh_identity_token"
}
},
"action": "reconnect",
"channel": "account_events"
}For a code your client recognizes, switch on failure.code and act on
action. For an unknown code, do not honor either automatic instruction;
pause account mutations, log the frame, and update your client. An actual
socket close remains authoritative.
| Code | Recovery | Action | Close | What to do |
|---|---|---|---|---|
identity_token_expired | refresh_identity_token | reconnect | 4001 | Refresh once, open a new socket, subscribe, and reseed |
invalid_identity_token | none | none | 1008 | Replace the identity token before opening a new socket |
invalid_personal_access_token | none | none | 1008 | Replace the PAT before opening a new socket |
forbidden_scope | none | none | 1008 | Create a PAT with account:stream |
wallet_setup_incomplete | none | none | 1008 | Complete wallet setup before opening a new socket |
dependency_unavailable | retry | reconnect | 1013 | Back off, reconnect, subscribe, and reseed |
internal_error | none | reconnect | 1011 | Reconnect and reseed; do not retry a second subscription on this socket |
Malformed frames, unsupported binary data, oversized messages, slow clients, and restarts use the common WebSocket error matrix. The connection can also close because of a transport failure without sending an error frame; reconnect and reseed in that case.
update
{
"op": "update",
"channel": "account_events",
"sequence": 18243,
"data": {
"kind": "fill",
...
}
}Events with a positive sequence share a globally monotonic counter and arrive
in sequence order. Process every positive-sequence transition: an accepted
order, its fills, and its cancellation can all repeat the same order_id and
order_hash. Deduplicate fill records only by fill_id. An order_rejected
notice can use sequence: 0; deduplicate that notice by order_id and do not
compare its ordering with positive-sequence events. Sequence values can have
gaps within a channel; see the
public stream's sequence rules.
Event payloads
Every event under data carries a kind discriminator. Route on it.
Order events (fill, order_accepted, order_cancelled) also carry
order_hash, the EIP-712 hash of the order. For
signed orders you compute this
hash yourself before you submit, so you can match every event back to
the originating order without waiting on (or ever seeing) the placement
response, which is what makes correlating acks and fills across many
simultaneous submissions reliable. It's always present on fill,
order_accepted, and order_cancelled, alongside the server-assigned
order_id. On order_rejected it's best-effort and
may be absent; correlate on order_id there.
fill
Emitted once per fill leg the caller participates in. A single trade produces two frames in total (one to the taker, one to the maker), delivered to whichever side(s) the caller owns.
{
"kind": "fill",
"role": "TAKER",
"fill_id": "8421",
"order_id": "8c63c5e2-9f6d-4a3b-a8de-1bd0f4d9c14e",
"order_hash": "0x9f2c...",
"token_id": "21742633...",
"side": "BUY",
"price": 60,
"size": 10000000,
"price_scale": 100,
"size_scale": 1000000,
"settlement_mode": "NORMAL",
"fee_micro": "1200"
}| Field | Notes |
|---|---|
role | "TAKER" or "MAKER", your side of the fill |
order_id | Your local order UUID (echoed back by the engine on placement) |
order_hash | The order's EIP-712 hash, always present on order events. For signed orders you know it before submitting, so it's how you correlate the fill back to your submission |
token_id | The outcome token you traded. On NORMAL fills both roles trade the same token; on MINT / MERGE the roles touch different tokens |
side | Your side. On NORMAL fills, maker is the opposite of taker. On MINT both sides are BUY; on MERGE both are SELL |
price | Your leg's execution price in engine units. Divide by price_scale to get collateral units per share (60 / 100 = 0.60) |
size | Filled size in engine units. Divide by size_scale to get shares |
price_scale / size_scale | Self-describing scales; they travel with the event so old clients don't need an out-of-band lookup |
settlement_mode | "NORMAL", "MINT", or "MERGE" |
fee_micro | Platform fee charged to your side, as a decimal string of micro-collateral. Taker legs can be charged under an active fee policy in any settlement mode; maker legs report "0" |
order_accepted
{
"kind": "order_accepted",
"order_id": "8c63c5e2-9f6d-4a3b-a8de-1bd0f4d9c14e",
"order_hash": "0x9f2c...",
"token_id": "21742633...",
"market_title": "Will Brent crude close above $100 a barrel in 2026?",
"outcome_name": "Yes",
"side": "BUY",
"price": 60,
"remaining_size": 5000000,
"original_size": 8000000,
"price_scale": 100,
"size_scale": 1000000,
"tif": "GTC"
}Confirms that a new order rested on the book (in whole or in part)
after any immediate fills. original_size is the size you placed and
remaining_size is what's still open after any immediate fills (here
3M of the 8M filled on entry, 5M resting); convert each with
/ size_scale.
order_accepted is emitted only when an order rests on the book, so its wire
tif is "GTC". A REST GTD order also reports "GTC" here because the
engine rests it as GTC while the service tracks and enforces its expiration;
read the REST order row to distinguish them.
Use market_title and outcome_name to display a new order immediately, including when its token was absent from your initial REST response. The outcome label can be Yes, No, Up, Down, or another market label; it is independent of side. These fields do not supply an event link or logo. Keep those from REST when available.
order_cancelled
{
"kind": "order_cancelled",
"order_id": "8c63c5e2-9f6d-4a3b-a8de-1bd0f4d9c14e",
"order_hash": "0x9f2c...",
"token_id": "21742633...",
"side": "BUY",
"price": 60,
"remaining_size": 5000000,
"price_scale": 100,
"size_scale": 1000000,
"reason": "USER"
}reason | Meaning |
|---|---|
USER | A cancel request: you called DELETE /trade/v1/orders/{id} or cancel-all, your GTD order expired, or we cancelled your orders, for example because your account was frozen |
FAK_REMAINDER | FAK order's unfilled remainder swept off the book |
SELF_TRADE_PREVENTION | The maker side was cancelled to prevent self-trade |
MARKET_RESOLVED | The market resolved while the order was resting, so it was swept off the book |
INSUFFICIENT_COLLATERAL | Add collateral or reduce your other BUY orders in this market before placing this BUY again. It was cancelled because your resting BUY orders in this market (both outcomes) added up to more than your cash balance, which can follow a fill on one of your BUY orders, splitting collateral or a withdrawal |
Don't wait for order_cancelled for an order cancelled while it was still
PENDING: none is sent, and no order_accepted either.
GET /trade/v1/orders/{order_id} reports the order as CANCELLED. That happens
when:
- you cancel it while it is still
PENDING(the cancel returns200) - cancel-all reaches it while it is still
PENDING - it is a GTD order that expires while still
PENDING
Keep reconciling an order that was already SUBMITTING when the cancel
arrived. It is cancelled only if it rests on the book, so you can receive
order_accepted and fills before its order_cancelled. If it filled
completely first, no order_cancelled is sent and it ends MATCHED. If it
ends REJECTED or FAILED, an order_rejected event is sent instead. Treat
its status as provisional until every fill_id you received appears in
GET /trade/v1/orders/{order_id}/trades and GET /trade/v1/orders/{order_id}
reflects those fills and any order_cancelled or order_rejected you
received. A fill can still arrive after that, so if a late fill is plausible,
follow the API trading recovery procedure.
A cancel that returns 200 sends nothing to the venue and produces no event of
its own. If an earlier cancel removed the order from the book, the order's
earlier events, including fills and that cancel's order_cancelled, can still
arrive after the 200. If an earlier cancel reached the order after it had
already filled completely, that cancel sends no order_cancelled, the 200
can still say CANCELLED, and the order changes to MATCHED once its fills
appear on it. A FAK order that filled only in part changes from CANCELLED to
PARTIALLY_FILLED instead, and the change can come after its fills appear.
Its FAK_REMAINDER order_cancelled was sent when the order executed, before
the earlier cancel reached it, so you have it only if you were connected then.
Whether or not you received it, wait for PARTIALLY_FILLED for a FAK order
that filled only in part, and for MATCHED after a complete fill. See
Cancel an order.
order_rejected
Emitted whenever an accepted order later reaches a persisted FAILED or
REJECTED state. This includes engine rejections such as insufficient shares
and failures elsewhere in submission. REST remains the durable record.
{
"op": "update",
"channel": "account_events",
"sequence": 0,
"data": {
"kind": "order_rejected",
"order_id": "8c63c5e2-9f6d-4a3b-a8de-1bd0f4d9c14e",
"order_hash": "0x9f2c...",
"token_id": "21742633...",
"failure": {
"code": "insufficient_shares",
"title": "Insufficient shares",
"detail": "Available shares are lower than the requested amount.",
"recovery": {
"strategy": "none"
}
}
}
}| Field | Notes |
|---|---|
order_id | Your local order UUID. Always present; use it to correlate the notice and to deduplicate this sequence: 0 rejection |
order_hash | The order's EIP-712 hash when available; it is best-effort and may be null. Always fall back to order_id for correlation |
token_id | The outcome token the order traded. Best-effort; may be null |
failure.code | Stable terminal reason; branch on this value |
failure.title | Display text; do not parse it for control flow |
failure.detail | Optional display text, omitted when no safe public detail exists; clients must not require it |
failure.recovery | Typed recovery instruction for the terminal failure |
The possible terminal codes are chain_execution_failed,
insufficient_balance, insufficient_shares, internal_error,
market_closed, order_funding_failed, order_not_fillable,
order_submission_failed, post_only_would_cross, and sequence_conflict.
There is no free-text reason field.
Most balance, shares, and post-only rejections are caught synchronously as a
422. This event covers failures after acceptance.
Reconcile against
GET /trade/v1/orders/{id}, including when
the event uses sequence: 0.
tokens_minted and tokens_merged
Emitted when you split collateral into a YES/NO pair (mint) or combine
a pair back into collateral (merge), either through the
positions endpoints or an
account batch. Per-market (touches both
outcomes by construction), so the wire carries condition_id rather than a
single token_id. A cross-match is reported as a fill with
settlement_mode set to MINT or MERGE.
{
"kind": "tokens_minted",
"condition_id": "0x6e1c...",
"size": 10000000,
"size_scale": 1000000
}{
"kind": "tokens_merged",
"condition_id": "0x6e1c...",
"size": 10000000,
"size_scale": 1000000
}size / size_scale is the per-outcome quantity (mint credits this
much of each outcome; merge burns this much of each).
tokens_redeemed
Emitted when a position in a market resolved to a single winner clears and any
payout is credited to your collateral balance. A position that holds only
losing shares can report payout_micro: "0". Refetch positions for
condition_id and your portfolio summary when you receive the event. This
reports the account change; it does not confirm that any related on-chain
operation has finished.
{
"kind": "tokens_redeemed",
"condition_id": "0x6e1c...",
"winning_token_id": "21742633...",
"losing_token_id": "92018471...",
"winning_shares_redeemed": "10000000",
"losing_shares_zeroed": "2500000",
"payout_micro": "10000000",
"size_scale": 1000000
}| Field | Notes |
|---|---|
winning_token_id | The outcome that paid one collateral unit per share |
losing_token_id | The outcome that paid zero |
winning_shares_redeemed | Winning shares removed, as a decimal string in scaled units; divide by size_scale for shares |
losing_shares_zeroed | Losing shares removed, as a decimal string in scaled units; divide by size_scale for shares |
payout_micro | Collateral credited, as a decimal string of micro-collateral |
size_scale | Scale for both share fields |
This event reports the account change, not completion of the process that
initiated it. For automatic redemption, a positive
payout produces a REDEEM portfolio activity. A zero-payout clearance may not,
so the cleared position state is authoritative.
payouts_redeemed
The split counterpart of tokens_redeemed, emitted when a position in a market
resolved to a split clears.
Each outcome pays its own price per share, so the event lists both outcomes
instead of a winner and a loser. A split never arrives as tokens_redeemed;
route on kind.
{
"kind": "payouts_redeemed",
"condition_id": "0x6e1c...",
"size_scale": 1000000,
"payout_micro": "8110000",
"outcomes": [
{
"token_id": "21742633...",
"payout_per_share_micro": "370000",
"shares_redeemed": "10000000",
"payout_micro": "3700000"
},
{
"token_id": "92018471...",
"payout_per_share_micro": "630000",
"shares_redeemed": "7000000",
"payout_micro": "4410000"
}
]
}| Field | Notes |
|---|---|
payout_micro | Total collateral credited, as a decimal string of micro-collateral; the sum of the outcome payouts |
outcomes | Both outcomes, in the market's outcome order. An outcome you did not hold reports "0" shares and payout |
outcomes[].token_id | The outcome token |
outcomes[].payout_per_share_micro | What one share of this outcome pays, as a decimal string of micro-collateral |
outcomes[].shares_redeemed | Shares of this outcome removed, as a decimal string in scaled units; divide by size_scale for shares |
outcomes[].payout_micro | Collateral credited for this outcome: its shares times its price, rounded down to the micro unit, so a very small holding can pay "0" |
size_scale | Scale for every share field |
As with tokens_redeemed, this reports the account change, not completion of
the process that initiated it; the cleared position state is authoritative.
collateral_withdrawn and collateral_deposited
These events report collateral balance changes:
{
"kind": "collateral_withdrawn",
"amount_micro": "1000000",
"cash_balance_micro": "9000000",
"batch_hash": "0x51cda675...",
"batch_index": 2
}{
"kind": "collateral_deposited",
"amount_micro": "1000000",
"cash_balance_micro": "10000000",
"batch_hash": "0x51cda675...",
"batch_index": 2
}Both monetary fields are decimal strings. amount_micro is the change and
cash_balance_micro is your collateral balance immediately after that change.
collateral_withdrawn means the amount has left your available AGARA
collateral. It does not mean the withdrawal has settled on-chain. For a
batch withdrawal, keep reading
GET /trade/v1/batches/{batch_hash}
until it reaches a final state.
collateral_deposited can be a new collateral credit or compensation for a
withdrawal that did not complete. Refetch your
portfolio summary for the authoritative balance.
Batch provenance
When a position or collateral event belongs to an
account batch, it also carries
batch_hash and batch_index. Both fields are omitted otherwise:
{
"kind": "tokens_minted",
"condition_id": "0x6e1c...",
"size": 10000000,
"size_scale": 1000000,
"batch_hash": "0x51cda675...",
"batch_index": 0
}| Field | Notes |
|---|---|
batch_hash | The batch's hash: the digest you signed and the value POST /trade/v1/batches returned |
batch_index | The operation's zero-based position in the batch |
Together they identify the related operation, but they are a correlation pair,
not an event ID. Process every event in sequence order even when you have seen
the pair before. For example, a failed split can produce tokens_minted
followed by tokens_merged with the same pair. A failed withdrawal can produce
collateral_withdrawn followed by collateral_deposited, and recovery can
later produce another effect with that pair. Do not discard a repeated pair as
a duplicate.
Use
GET /trade/v1/batches/{batch_hash}
for the final batch status. The stream reports account changes, not the final
on-chain verdict.
These fields are best-effort delivery on a live stream: if the exchange restarts while your batch is in flight, the events may arrive without them. The batch's status endpoint is always the durable record.
Reconnect strategy
Reconnect automatically if this socket closes unexpectedly. Trade service connections may disconnect periodically and without prior notice, even if your client has not requested a disconnect. Do not wait for an error frame before reconnecting. Use backoff when opening a new socket, then restore your subscription and account state below.
The stream is live-only, with no replay. On reconnect you may have missed events. Recommended client loop:
-
Subscribe to
account_eventsand begin buffering events after thesubscribedacknowledgement. -
Seed via REST while buffering.
- Fully page
POST /trade/v1/orders/listfor the complete order lifecycle, including orders that have not reached the book. UsePOST /trade/v1/portfolio/open-orders/listfor the resting and active portfolio view. POST /trade/v1/portfolio/positions/list,GET /trade/v1/portfolio/summary, andGET /trade/v1/portfolio/tradesfor the rest of your current account state.GET /trade/v1/portfolio/activitiesfor durable account history.
Orders, portfolio open orders, trades, and activities page with keyset cursors: follow
next_cursorto the end for a complete seed. Positions are unpaginated. Positions and any trades page can be partial even with a200response. Retry the whole positions read whileunavailable_exchangesincludesAGARA. Check that field on every trades page; if a page includesAGARA, discard the entire accumulated walk and restart without a cursor. When seeding multiple exchanges, require the array to be empty on every page. - Fully page
-
Reconcile buffered order and fill records. Use
order_idandorder_hashto correlate an order, but do not discard a positive-sequence transition merely because either value repeats. Process those transitions in sequence order, deduplicate fills only byfill_id, and deduplicate asequence: 0order_rejectednotice byorder_id.A live frame can arrive before REST reflects it. For a
fill, pollGET /trade/v1/orders/{order_id}/tradesuntil itsfill_idappears, then pollGET /trade/v1/orders/{order_id}until the order state reflects all listed fills or a later terminal transition. A fully filled LIMIT or MARKET order isMATCHED. A partially filled GTC or GTD LIMIT order isPARTIALLY_FILLEDwithis_terminal: false. After anorder_cancelledframe withreason: "FAK_REMAINDER"for a partially filled FAK LIMIT or MARKET order, wait forPARTIALLY_FILLEDwithis_terminal: trueand retain its fills. The frame cancels the unfilled remainder. A FOK order never partially fills. Fororder_accepted,order_cancelled, ororder_rejected, poll the order until it reflects that event or a later state. Apply the same order-versus-fill check to orders in the REST seed. Only then redo the fully paginated orders read and refresh portfolio open orders. If the order state never catches up, stay paused and investigate.Do not arithmetically apply a buffered
fillor token or collateral effect to the REST inventory: REST responses do not carry a WebSocket sequence that tells you whether that change is already included, and token and collateral effects have no unique event ID. Everyfill,tokens_minted,tokens_merged,tokens_redeemed,payouts_redeemed,collateral_deposited, andcollateral_withdrawnevent is a signal to refetch positions and summary. Wait for an observable result, not merely two identical reads:Every positions response used as recovery evidence must exclude
AGARAfromunavailable_exchanges; require that array to be empty when rebuilding a complete multi-exchange view.- For a collateral event carrying
batch_hash, wait for the batch's final status and, on failure, itsunwound_at. If no later fill, mint, merge, redemption, deposit, or withdrawal can also have changed cash, require summarycash_balance_microto match the newest collateral frame. The same restriction applies to a collateral event without a batch hash. When later cash-changing events exist, do not compare summary with one frame in isolation; reconcile the complete ordered set of effects or stay paused. - For a mint or merge carrying
batch_hash, wait for its final batch status and, on failure, itsunwound_at; require positions to reflect the settled operation or completed unwind. - For redemption, require the resolved market's position to be cleared.
Before using
unwound_atas proof, check the batch queue. If a lower batch isFAILED_DIVERGENT, a higher failed row's marker does not prove its inventory was restored. Keep mutations paused and follow the stranded-sequence recovery rule.If an effect has no independently verifiable final condition, or REST never reaches it, stay paused and investigate.
- For a collateral event carrying
-
On
sequence_reset, buffer new events and redo the seed, then reconcile the buffer before trusting local state. Events that fired during the gap are gone, but the subscription remains active; REST is the authoritative recovery. If you seesequence_resetrepeatedly, your client may be reading too slowly; profile the read loop. -
Resume quoting only after the market stream also has an active-subscription + REST-watermark orderbook baseline and the full quote-readiness lifecycle risk decision is satisfied.
If the feed is temporarily unavailable the handshake returns 503. A live
connection receives failure.code: "dependency_unavailable",
action: "reconnect", then closes with 1013. Keep
reconnecting on your backoff; the handshake accepts once the feed is
back, and then subscribe, buffer, and seed as above.
The order matters: subscribing and buffering first prevents an event from landing unseen between the REST snapshot and the live feed.
A disconnect does not cancel resting orders. If account state is uncertain, pause new placements and use the REST cancel and order endpoints to reconcile before resuming.