Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.adipredictstreet.com/llms.txt

Use this file to discover all available pages before exploring further.

WSS wss://ws-gateway.adipredictstreet.com/ws/user
Per-partner activity gateway. Subscribe once with your API key and you’ll receive live events for every order, fill, and vault-position change keyed to the wallet associated with the key. Trying to subscribe to a public channel (token_trade_matches, token_trade_settlements, token_book, token_ohlc, condition_lifecycle, system) on this gateway returns { "code": "forbidden" } in rejected[] — use /ws/market for public streams.

Authentication

How to send it
X-Api-Key: ps_live_<keyId>_<secret> header on the upgrade request
?key=<token> query parameter — for browser clients that can’t set custom headers on the WebSocket upgrade
The handshake rejects missing, malformed, revoked, expired, suspended, or IP-denied keys with a 4401 <reason> close frame before any subscribe command is accepted. There is no fallback to any other auth scheme; a bad key ends the connection cleanly.

Multi-wallet partners — X-User-Wallet required on upgrade

Partners whose key was provisioned as multi_wallet (one key fanning out across many sub-account vaults — see API keys / multi_wallet flow) must declare the acting wallet on the upgrade request — same shape as their HTTP calls:
X-Api-Key:     ps_live_<keyId>_<secret>
X-User-Wallet: 0x1234567890AbCdEf1234567890aBcDeF12345678
Both headers are read at handshake time. The socket scopes every private subscription to the wallet declared in X-User-Wallet, and vault_positions resolves to that wallet’s vault. Without X-User-Wallet, the gateway closes 4401 api_key_no_associated_wallet even though the key itself is valid — multi_wallet keys carry no fixed wallet on the partner row, so the gateway has nothing to bind to. single_wallet partners do not send this header; their wallet is fixed at the partner row’s associated_wallet.
Authority model — symmetric with HTTP /api/me/*. The gateway binds whatever X-User-Wallet value the partner sends, the same way /api/me/* HTTP calls do. The platform does not enforce per- wallet ownership against the partner row at the request boundary — partner authority is enforced at the KYB / onboarding contract level. Partners are responsible for sending only wallets they legitimately act on behalf of.
Browser clients that can’t set custom headers on a WebSocket upgrade fall back to the query-param form:
wss://ws-gateway.adipredictstreet.com/ws/user?key=<token>&user_wallet=0x...
key= and user_wallet= carry exactly the same semantics as the headers; mixing the two (e.g. header X-Api-Key + query user_wallet=) is also accepted.
Every private channel requires the portfolio:read scope on the API key. Subscribing without it surfaces { "code": "api_key_scope_missing", "message": "channel user_orders needs portfolio:read" } in rejected[]. See API keys for the full scope catalog.For vault_positions, each requested vault must be covered by the API key’s associated_vault row — vaults outside the grant come back under rejected[] with forbidden.
Auth is validated once at handshake. Rotating the key does not invalidate an open socket — close and reopen to switch identity. Revoking a key closes every live socket bound to that keyId immediately via Redis apikey:invalidate pub/sub.

Close codes

Close codeMeaning
4401 api_key_bad_format / api_key_unknown_key / api_key_bad_secretMalformed token or the server doesn’t recognise the keyId / secret pair. Request a new key from your PredictStreet integration contact.
4401 api_key_revoked / api_key_expiredKey was explicitly killed or reached its expiry. Issue a fresh one.
4401 api_key_suspendedThe partner itself is SUSPENDED in admin. Contact ops — do not auto-retry.
4401 api_key_ip_deniedCaller IP isn’t in the key’s allowlist.
4401 api_key_no_associated_walletKey authenticated, but no wallet to bind the socket to. single_wallet: admin must attach associated_wallet to the partner row. multi_wallet: caller forgot the X-User-Wallet header / user_wallet= query param on the upgrade.
4401 api_key_user_wallet_invalidX-User-Wallet value isn’t a 0x-prefixed 40-hex address. Trim and lower-case before sending.
4401 api_key_auth_disabled / api_key_auth_unconfiguredServer-side misconfig (feature flag off or pepper missing). Ops issue, not the caller’s.
1008 forbidden originWebSocket Origin header not in the allowlist.

Connect greeting

The server pushes a single connected frame after the upgrade handshake so you don’t need a parallel REST call to learn the wallet tied to the key:
{
  "type": "connected",
  "data": {
    "gateway":         "user",
    "walletAddress":   "0x1234567890AbCdEf1234567890aBcDeF12345678",
    "authMethod":      "api_key",
    "protocolVersion": 2
  }
}

Available channels

Channelids shapeRequired scopeServer pushes
user_ordersnone (scoped to the key’s wallet)portfolio:readorder_placed, order_cancelled
user_fillsnone (scoped to the key’s wallet)portfolio:readuser_fill (chain-confirmed)
vault_positionsvault address[] (1+ required)portfolio:readvault_position_balance_changed, vault_position_split, vault_position_merged, vault_position_redeemed
See Server events for full payload shapes per type.

Subscribe example

import WebSocket from 'ws';

const KEY = process.env.PREDICTSTREET_API_KEY; // ps_live_<keyId>_<secret>
const ws = new WebSocket(
  'wss://ws-gateway.adipredictstreet.com/ws/user',
  { headers: { 'X-Api-Key': KEY } },
);

ws.on('open', () => {
  ws.send(JSON.stringify({
    id: 1,
    cmd: 'subscribe',
    params: { subscriptions: [{ channel: 'user_orders' }, { channel: 'user_fills' }] },
  }));
});

ws.on('close', (code, reason) => {
  if (code === 4401) console.error('auth rejected:', reason.toString());
});

Subscribe response

{
  "id": 1,
  "type": "subscribed",
  "accepted": [
    { "sid": 20, "channel": "user_orders" },
    { "sid": 21, "channel": "user_fills" }
  ],
  "rejected": []
}

Push examples

order_placed / order_cancelled (channel: user_orders)

{
  "type":    "order_placed",
  "sid":     20,
  "channel": "user_orders",
  "data":    { "orderId": "...", "tokenId": "...", "side": "buy", "price": "0.41", "size": "10", "tsMs": 1776949200000, "clientOrderId": "mm-bot-1735000000000" }
}
clientOrderId is present when the original POST /api/orders/place supplied one; absent otherwise. The cancel frame echoes the same value from orders.client_order_id so a partner driving its state machine off WS gets the correlation key on both sides without a follow-up REST call.

user_fill (channel: user_fills)

Fires the moment the matcher prints a trade where your wallet is on either side — this is a match-time notification, not a settlement- confirmed one. The matcher emits before on-chain settlement completes, so the payload deliberately omits fields that only exist post- settlement: per-wallet fee, txHash, blockNumber, and the on-chain orderHash. For those, poll GET /api/me/trades (or GET /api/me/fees for the per-side fee breakdown) once you receive this event — see user_fill vs /api/me/trades below. side is 'buy' or 'sell' from your perspective. orderId / clientOrderId are your side of the match (the buyer’s id when side === 'buy', the seller’s when side === 'sell'); use them to correlate the fill back to the placement you submitted. The market is identified at the asset level (tokenId + conditionId + outcomeIndex); resolve to your application marketId via GET /api/markets if needed.
{
  "type":    "user_fill",
  "sid":     21,
  "channel": "user_fills",
  "data": {
    "walletAddress": "0x12345678...",
    "side":          "buy",
    "tradeId":       "9c3a1f4e-2b07-4d7a-8b8d-2a9f2b1c0001",
    "orderId":       "76a93f88-3f3c-4dca-9d06-1a89c2bb8c54",
    "clientOrderId": "mm-cid-2026-05-05-001",
    "tokenId":       "9871...",
    "conditionId":   "0xabc...",
    "outcomeIndex":  0,
    "price":         "0.41",
    "quantity":      "1000000",
    "source":        "matcher",
    "tsMs":          1776949200000
  }
}
clientOrderId is forwarded only if the matcher’s trade payload carries it (today the matcher does not always populate this field; the key is omitted from the JSON when absent — compactRecord strips nulls). All other fields above are present on every emission.

user_fill vs /api/me/trades

The two surfaces serve different jobs and intentionally publish different shapes:
FieldWS user_fillREST /api/me/trades
tradeId✓ (as id)
orderId (your side)
clientOrderId✓ (when matcher provides)
tokenId / conditionId / outcomeIndex
marketId (application id)
vaultAddress
fee
side'buy' / 'sell''buy' / 'sell'
price / quantity
timestamptsMs (ms-epoch)createdAt (ISO 8601)
fee is intentionally absent from the WS payload because:
  1. The fill event fires from the matcher, before exchange.fee_ledger is written. Per-wallet fee is not yet resolved at emit time.
  2. The fee row is keyed by (trade_id, role)role is 'maker' if the wallet placed the resting order, 'taker' otherwise. The matcher event does not carry the role/fee join.
  3. Fee semantics are side-specific: per the on-chain _chargeFee rules, the BUY-taker fee is denominated in outcome shares (not USDC); the SELL-taker fee is in USDC. The single fee column on the trade row tracks the SELL-side USDC fee only — the per-wallet view that REST returns reads COALESCE(fee_ledger.amount, t.fee) to surface the correct number per role.
Recommended pattern: treat user_fill as the real-time “fill happened” signal — use it to re-render the activity feed, update positional UI, trigger re-balance logic. Then call GET /api/me/trades (with limit=50 or so, paginated by before) for the post-settlement view — that includes the resolved fee, the application-level marketId, and the vaultAddress. Combine the two: WS for latency, REST for accounting. If you need the per-side fee breakdown (both maker and taker rows for the same trade), use GET /api/me/fees instead — it returns one row per (tradeId, role) from the fee_ledger.

vault_position_balance_changed (channel: vault_positions)

reason is the chain-event tag (ASCII bytes4). Common values: "OUTF" (trade-side credit), "INFL" (non-trade inflow), "SPLT" / "MERG" / "REDM" (split / merge / redeem), "XFER" (direct transfer). New tags can land — treat unknown as generic.
{
  "type":    "vault_position_balance_changed",
  "sid":     22,
  "channel": "vault_positions",
  "id":      "0x1234567890AbCdEf1234567890aBcDeF12345678",
  "data": {
    "vaultAddress": "0x12345678...",
    "tokenId":      "9871...",
    "conditionId":  "0xabc...",
    "outcomeIndex": 0,
    "balanceAfter": "1000000",
    "reason":       "OUTF",
    "txHash":       "0xc01780b2c76494d8...",
    "blockNumber":  1234567,
    "tsMs":         1776949200000
  }
}

Rejection codes

codeWhen
invalid_paramsunknown channel, malformed id, missing required ids, or wallet-scoped channel sent with ids
api_key_scope_missingkey is missing portfolio:read (required by every private channel)
forbiddentried a public channel on this gateway (use /ws/market); also raised when vault_positions ids aren’t covered by the API key’s associated_vault row
subscription_cap_exceededconnection already holds the maximum subscriptions (256)
subscription_too_many_idsa single subscription / add_ids call exceeded the per-channel id cap (100). Message: "subscription accepts at most 100 ids". Split your ids across multiple subscriptions on the same connection.

Commands

update_subscription / unsubscribe / list_subscriptions / ping.

Server events

Full event payload catalog per channel.

Reconnect

Heartbeat, sid rebuild, snapshot resync.

API keys

Scope catalog, rotation, revoke.