WebSocket
Streaming
One WebSocket carries live market data and your order events. Subscribe to what you need; every subscription is answered with a snapshot, then pushed changes. Frames use the same shapes as the REST endpoints, so one parser serves both.
Connect
Partner
wss://pulse.bettoredge.com/v1/your-slug/stream Your account
wss://pulse.bettoredge.com/v1/bettoredge/stream- Partner: send your key as
x-api-keyon the upgrade request. It needs thereadscope, and the path slug must be yours. Order events need no player token on the socket — see order events. - Your account: send your personal token as
Authorization: Bearer. No key. The token's IP allowlist applies here too. - Any other path answers HTTP 404; a stream switched off for maintenance answers 503.
- Can't set headers (a browser)? Connect bare and, within 5 seconds, send
{"op":"auth","api_key":"…"}or{"op":"auth","token":"…"}as the first frame. Never ship a key or token to a browser you do not control. - Credentials are re-checked every 60 seconds. A revoked, expired or suspended credential closes the socket.
Example
import WebSocket from "ws";
const ws = new WebSocket("wss://pulse.bettoredge.com/v1/your-slug/stream", {
headers: { "x-api-key": process.env.PULSE_API_KEY },
});
ws.on("open", () => {
ws.send(JSON.stringify({ op: "subscribe", id: 1, channel: "depth", order_context_hash: "team:138847:1:home:team:42:0" }));
ws.send(JSON.stringify({ op: "subscribe", id: 2, channel: "orders" }));
});
const seen = new Set(); // event_id — delivery is at-most-once, but dedupe across reconnects
ws.on("message", (raw) => {
const frame = JSON.parse(raw.toString());
switch (frame.type) {
case "ack": /* frame.snapshot is the current state */ break;
case "depth": /* same shape as GET /markets/depth */ break;
case "order":
if (seen.has(frame.event.event_id)) return;
seen.add(frame.event.event_id);
console.log(frame.event.type, frame.event.data.order_id);
break;
case "error": console.warn(frame.code, frame.message); break;
}
});
ws.on("close", (code) => {
// 4001 / 4003: fix the credential first. Anything else: back off, reconnect, reconcile.
});Subscribe
Send subscribe or unsubscribe with a channel. Each is acknowledged, and a subscribe's acknowledgement carries the current snapshot.
{"op":"subscribe","id":1,"channel":"depth","order_context_hash":"team:138847:1:home:team:42:0"}
{"op":"subscribe","id":2,"channel":"contest","contest_id":"138847","contest_type":"team"}
{"op":"subscribe","id":3,"channel":"league","league_id":"1"}
{"op":"subscribe","id":4,"channel":"orders"}
{"op":"unsubscribe","id":5,"channel":"depth","order_context_hash":"team:138847:1:home:team:42:0"}depth
order_context_hash
One line's price ladder — the same shape as GET /markets/depth, sent when it changes (at most about twice a second per line).
contest
contest_id, contest_type
One contest: the board (same shape as a contest in /markets/available) and its game state.
league
league_id
Every contest in a league, as
contest and contest_state frames.orders
—
Order and position events. Partner: every connected player who granted portfolio:read. Personal token: your own account.
Frames you receive
welcome
First frame after a successful connect: environment (sandbox or production) and your per-connection limits.
ack
Answers each op, echoing its
id. A subscribe ack carries snapshot — the current state, built exactly as the REST endpoint builds it — or snapshot_error.contest
A contest's board changed.
contest has the /markets/available shape.contest_state
Status, clock and score changed, without the board.
depth
A subscribed ladder changed.
depth has the /markets/depth shape.order
An order event (below).
error
An op was refused, with
code: VALIDATION_ERROR (malformed, or a hash that names no line), SUBSCRIPTION_LIMIT (a per-connection cap) or RATE_LIMITED (over 20 ops a second). The connection stays open.{
"type": "depth",
"depth": {
"order_context_hash": "team:138847:1:home:team:42:0",
"levels": [
{
"price": 0.55,
"available": 420
},
{
"price": 0.56,
"available": 180
}
]
}
}
{
"type": "contest_state",
"state": {
"contest_id": "138847",
"contest_type": "team",
"status": "inprogress",
"clock": "Q3 08:41",
"scores": {
"home": 17,
"away": 13
}
}
}Order events
Subscribe to orders once; every event names the player it is about. No player token goes on the socket: a partner receives events for players with a live grant that includes portfolio:read, and a personal token receives its own.
order.placed
A buy placed through POST /orders/place was accepted —
order_type, max_slippage and expires_at included. Sent for API-placed buys only: an order placed in the BettorEdge app, or a sale, first appears as its fill, cancel or expiry. Event key order.placed:<order_id>.order.filled
Part or all of an order matched.
data.fill is the fill this event announces; filled_amount is cumulative.order.cancelled
The order stopped working — cancelled through the API, in the app, or because its market closed.
open_amount is what was still open when it stopped (and was returned) — check the event type or status, not open_amount, to know whether an order is working.order.expired
The order reached its
expire_time. open_amount is what was still open when it stopped, and was returned.position.settled
The contest graded. One event per order: result, stake, what was returned, commission and profit.
- A player who revokes your app stops producing events immediately.
- Sandbox keys receive Edge Coin (FREE) events only.
event_idis stable for the same fact — the same fill seen twice has the same id. Dedupe on it, including across reconnects.- Every order event reports the order's real
order_type(limit, ormarketwhen it may fill past its price). Money amounts are rounded to the cent.
{
"type": "order",
"event": {
"event_id": "order.filled:5512093:450",
"type": "order.filled",
"created_at": "2026-09-21T20:41:07.000Z",
"player_id": "20391",
"data": {
"order_id": "5512093",
"order_context_hash": "team:138847:1:home:team:42:0",
"market_type": "FOR_MONEY",
"title": "Kansas City Chiefs",
"action": "buy",
"order_type": "limit",
"price": 0.55,
"amount": 10,
"filled_amount": 4.5,
"open_amount": 5.5,
"fill": {
"position_id": "889120",
"amount": 4.5,
"contracts": 8.181818,
"price": 0.55
}
}
}
}Limits
Connections per key or personal token
3
Connections per partner
10
Contests per connection
100
Depth lines per connection
200
Leagues per connection
10
Client messages
20 / second
Client frame size
16 KB
The server pings every 30 seconds; any WebSocket client answers automatically. A connection that stops answering is closed.
Delivery, reconnect and reconcile
- At most once. Nothing is replayed: whatever was in flight when a connection drops is gone.
- Market frames are conflated — if a line changes faster than you read, you get the latest state, not every step.
- Order events are never conflated or reordered per connection; a client too slow to take them is closed with 4008.
- If the stream is switched off for maintenance, the upgrade answers HTTP 503. Retry with backoff.
- Reconnect with exponential backoff and jitter (for example 1s, 2s, 4s … capped at 30s).
- Re-send every subscribe. Each ack carries a fresh snapshot — replace your local state with it.
- Reconcile orders: GET /portfolio/orders and the order's activity for anything that may have changed while you were away.
- Keep deduping on event_id: an event can arrive after you already reconciled the change it describes.
Close codes
4001
UNAUTHENTICATED
No credential within 5 seconds, or the credential is invalid, expired or revoked. Fix the credential before reconnecting.
4003
FORBIDDEN
The credential is valid but not for this: another partner's slug, a key without
read, an IP not on the allowlist, a player's OAuth token instead of your key, or an app session token. Do not retry unchanged.4008
SLOW_CONSUMER
Order events could not be delivered fast enough (more than 1,000 queued, or over 1 MB buffered for 10 seconds). Reconnect, then reconcile.
4029
TOO_MANY_CONNECTIONS
A connection cap was reached. Close an existing connection, or back off and retry.
1008
POLICY_VIOLATION