---
title: "WebSocket stream"
method: GET
path: "/v2/stream"
tags: ["Streaming API"]
---

# WebSocket stream

`GET /v2/stream`

## Request

- Base URL: `https://magicmarkets.com` (the document declares no server; this is the origin it was published from)
- URL: `https://magicmarkets.com/v2/stream`
- Auth: API key in header `X-Api-Key`

**WebSocket endpoint** - upgrade an HTTP connection to receive
real-time prices. This is a separate service from the REST API.

### Connection

Authenticate with an API key:

```
ws://<host>/v2/stream?api_key=<api_key>
```

**Query parameters:**

| Param | Required | Description |
|-------|----------|-------------|
| `api_key` | yes | API key (the `X-Api-Key` value) |
| `lang` | no | Language code: `en` (default), `ko`, `zh-hans` |

The server does not restrict the `Origin` header, so browser-based
clients - including pages opened from `file://` - can connect
directly.

### Wire format

**Every** frame sent by the server is a batch envelope:

```json
{"ts": 1586042815.269000, "data": [ <message>, <message>, ... ]}
```

- `ts` - Unix timestamp in seconds with microsecond precision,
  stamped by the server when the frame is written.
- `data` - one or more messages. The `["offer", ...]`,
  `["response", ...]`, `["event", ...]`,
  `["remove_event", ...]`, `["sync", ...]`, live-event-state and
  account-update arrays shown below are the individual `data[]`
  entries; they are never sent as bare top-level frames.

Multiple messages may be batched into a single envelope - e.g. a
register snapshot and its ok `["response", ...]` together, or offers alongside
account updates. Batching boundaries are not semantically
meaningful - iterate `data[]` and dispatch on each entry's leading
type tag (`entry[0]`); never rely on ordering, grouping, or a type
appearing exactly once per frame.

### Initial sync

After connecting, the server sends a snapshot of the
currently-priced events followed by a `["sync", {...}]` marker
whose payload carries the `session_id` of this stream (useful
when correlating with REST errors or contacting support). Each
event is an `["event", {...}]` entry - a flat object carrying the
identifiers and metadata - delivered inside the envelope:

```json
{"ts": 1586042815.269000, "data": [
  ["event", {"event_type": "normal", "sport": "fb", "event_id": "2026-06-15,1001,2002", "competition_id": 1, "competition_name": "England Premier League", "competition_country": "XE", "home": "Arsenal", "away": "Chelsea", "event_name": "Arsenal vs. Chelsea", "ir_status": "pre_event", "start_time": "2026-06-15T15:00:00Z"}],
  ["event", {"event_type": "normal", "sport": "tn", "event_id": "2026-06-16,501,502", ...}],
  ["event", {"event_type": "multirunner", "sport": "af", "event_id": "2026-02-23,multirunner,100364405", "competition_id": 545, "competition_name": "USA NFL", "competition_country": "US", "teams": [{"team_id": 21614, "name": "Arizona Cardinals"}, {"team_id": 21615, "name": "Atlanta Falcons"}], "event_name": "NFL Super Bowl Winner", "start_time": "2026-02-23T21:00:00Z", "end_time": "2027-02-14T21:00:00Z"}],
  ["sync", {"session_id": "..."}]
]}
```

Two event shapes appear. A `normal` (match) event carries `home` and
`away`. A `multirunner` (outright / futures) event has no `home`/`away`;
instead it carries a `teams` array (`[{"team_id", "name"}, ...]`, one per
runner) and an `end_time`. Dispatch on `event_type`; read the runner list
from `teams` for multirunners.

The snapshot is not the full fixture list: it contains only events
that currently have live prices. The dump may span several
envelopes; `["sync", ...]` is the last `data[]` entry of the final one.
From then on, changed events are re-sent as `["event", {...}]`
entries, and an event whose prices disappear is delivered as
`["remove_event", {"sport": ..., "event_id": ..., ...}]`.

### Live event state

Events that are in play may additionally produce state updates as
`data[]` entries. Payloads are sport-specific; the football shapes:

```json
["event_time", {"sport": "fb", "event_id": "...", "time": ["1h", 23]}]
["event_score", {"sport": "fb", "event_id": "...", "score": [1, 0]}]
["event_red_cards", {"sport": "fb", "event_id": "...", "score": [0, 1]}]
```

- `time` - `[period, minutes]`, where the football periods are
  `"1h"`, `"2h"` and `"ht"`; `null` when no clock is available.
- `score` - `[home, away]` (the `event_red_cards` payload reuses
  the `score` key for the red-card counts).
- `["ir_info", {...}]` carries a full in-running state snapshot for
  an event (fields vary by sport), and
  `["remove_ir_info", {"sport": ..., "event_id": ...}]` signals the
  state is gone - treat both as informational.
- `["event_exchange_dark_liquidity", {"sport": ..., "event_id": ...,
  "lines": {...}}]` - a rough estimate of additional liquidity
  available per line on the event, beyond the published offers.
  Informational.

### Commands

**Register for offers on an event:**
```json
["register_event", "<sport>", "<event_id>"]
```

On success the server immediately sends one `["offer", ...]` per
active bet type on the event (the snapshot), then an ok
response - typically batched in one envelope:
`{"ts": ..., "data": [["offer", {...}], ["offer", {...}],
["response", {"status": "ok", "data": null}]]}`.
From then on, whenever the offers on the event change, the
full current set is re-broadcast (with the affected bet types
updated) and any bet type that has lost all liquidity is
delivered as `["remove_offer", ...]`.

**Unregister:**
```json
["unregister_event", "<sport>", "<event_id>"]
```
Server responds with `["response", {"status": "ok", "data":
null}]` - also when the event was not registered (unregistering is
idempotent). No further `offer` / `remove_offer` messages are sent
for that event.

**List currently registered events:**
```json
["list_registered_events"]
```
Server responds with the full set of `(sport, event_id)` pairs
the session is registered for:
```json
["response", {"status": "ok", "data": {
  "registered_events": [
    ["fb", "2026-06-15,1001,2002"],
    ["tennis", "2026-06-16,501,502"]
  ]
}}]
```

**Keepalive (echo):**
```json
["echo", "any-payload"]
-> ["response", {"status": "ok", "data": ["any-payload"]}]
```
Arguments are optional, may be any JSON values, and are echoed
back verbatim in `data`. The server also sends an `["info", ...]`
entry every few seconds, so an idle connection still receives
regular traffic.

### `offer` / `remove_offer` messages

Each `offer` describes the available stake at every price for one
`(sport, event_id, bet_type)` triple. The `bet_type` string fully
identifies the market side (it encodes the market, handicap,
outcome and for/against direction), so each triple is a distinct,
independently-updated offer - for and against on the same
selection arrive as two separate `offer` messages with different
`bet_type` values.

```json
["offer", {
  "sport": "fb",
  "event_id": "2026-06-15,1001,2002",
  "bet_type": "for,ah,h,1",
  "market_type": "ah",
  "in_running": false,
  "price_list": [
    {"effective": {"price": 2.0, "min": ["USDT", 5.0], "max": ["USDT", 150.0]}},
    {"effective": {"price": 1.99, "min": null, "max": ["USDT", 80.0]}}
  ]
}]
```

Each `price_list` entry is
`{"effective": {"price": <decimal>, "min": <stake|null>, "max": <stake>}}`;
stakes are `["USDT", amount]` arrays. The `min` and `max` keys are
always present:

- `min` - the minimum stake accepted at that price; `null` when
  there is no minimum.
- `max` - the total stake available at that price. Always a
  `["USDT", amount]` pair (a price with no available stake is not
  published).

Entries are ordered by `price` (the decimal odds) **descending**,
with at most one entry per price.

`remove_offer` carries only the `(sport, event_id, bet_type)`
triple - that bet type has no remaining liquidity for the event:

```json
["remove_offer", {
  "sport": "fb",
  "event_id": "2026-06-15,1001,2002",
  "bet_type": "for,ah,h,1"
}]
```

### Account update messages

Account-level updates arrive on the same WebSocket, as plain
entries inside the same `{"ts": ..., "data": [...]}` envelope that
carries `offer` / `remove_offer` - siblings of the market-data
messages.

```json
{"ts": 1586042815.269000, "data": [
  ["balance", {"balance": ["EUR", 10000.1], "open_stake": ["EUR", 152.55], "smart_credit": ["EUR", 300.0]}],
  ["xrate", {"ccy": "EUR", "rate": 1.1347}],
  ["order", {...}],
  ["bet", {...}],
  ["pmm", {...}],
  ["betslip", {...}],
  ["info", {...}]
]}
```

`data[]` may contain these account entry types:

- `balance`, `xrate` - amounts are in your account's native
  currency.
- `order` - `want_stake`, `stake` and `profit_loss` are in USDT;
  each entry of nested `bets[]` follows the `bet` format.
- `bet` - `want_stake`, `got_stake`, `profit_loss` and
  `status.response_pmm.effective.min`/`max` are in USDT.
- `pmm`, `betslip` - the live quote and state of an open betslip
  (see the Quickstart). `price_list` entries follow the same
  `{"effective": {"price", "min", "max"}}` format as `offer`
  messages; `price_list` and `total` are in USDT, prices sorted
  descending.
- `betslip_closed` - `{"betslip_id": ..., "close_reason": ...}`. The
  betslip expired (betslips are short-lived) or was closed; no
  further `pmm` quotes will arrive for it. Create a new betslip
  to re-quote the selection.
- `info` - feed status; `registered_events` is the number of
  events currently registered on this connection.
- `clear_events` - the server lost its upstream market data feed:
  discard all event, offer and live-state data you hold. A fresh
  snapshot (events, then `["sync", ...]`) follows when the feed
  recovers.

The order, presence, and count of entry types within `data[]` are
not contractual - the example above shows one possible ordering
only. Dispatch on each entry's type tag.

**`balance` message fields:**
- `balance`: `[currency, amount]` - current account balance in the customer's native currency.
- `open_stake`: `[currency, amount]` - total stake across all unsettled bets, in the same currency.
- `smart_credit`: `[currency, amount]` - extra funds available for betting on
  top of the balance, equal to the amount the account is expected to win from
  the bets already placed. Absent when the account has no smart credit.

### Errors

Recoverable command-level errors arrive in-band on the open
connection. The socket stays up; you can keep sending commands.
Like every other message, the error element is a `data[]` entry
inside the envelope (possibly batched with other messages):

```json
{"ts": 1586042815.269000, "data": [["response", {"status": "error", "code": "<code>"}]]}
```

Codes emitted directly by the stream:

| Code | When |
|------|------|
| `bad_json` | Frame is not valid JSON, not a JSON array, or an empty array. |
| `invalid_input` | The command name is not a string or not recognised, or its argument shape is wrong for the command. |
| `already_registered` | `register_event` for an event already registered on this session. |
| `customer_event_limit_exceeded` | `register_event` would exceed your registered-events limit (counted across all your connections). Unregister something first. |
| `invalid_customer` | `register_event` while the feed does not recognise your customer record (e.g. not yet propagated after a server restart). Retry after a short backoff; contact support if it persists. |
| `system_error` | Transient server-side failure - retry after a short backoff. |

Note there is no "unknown event" error: registering an event the
feed has no prices for succeeds with an empty snapshot, and
`unregister_event` of an unregistered event returns ok. Treat any
other code string as **opaque** - log it and retry after a short
backoff.

**Authentication and admission are enforced at the HTTP handshake.**
A refused upgrade returns a non-101 status with a short `text/plain`
body naming the reason. Clients see a handshake error.

| Status | Body | What to do |
|--------|------|------------|
| 400 | `missing_credentials`, `invalid_lang` | `api_key` is missing, or `lang` is not an allowed value. Fix the request. |
| 401 | `auth_rejected` | The key was not accepted. Do not retry with the same key; check it against a REST endpoint. |
| 503 | `unavailable` | The server cannot accept connections right now. Retry with backoff. |

Treat all other 4xx as 401 and all other 5xx as 503. The `websockets`
library raises `InvalidStatus` with the body on `exc.response.body`.

**Backpressure** - if you read too slowly, the server's outbound
buffer overflows and the server closes with code **1008**. Read
faster or register fewer events. An immediate reconnect overflows
again. A client that stopped reading never receives the frame and
sees only the raw TCP close.

Two classes of failure drop an *established* connection silently
(raw TCP close, no WebSocket close frame, no in-band error):

- **I/O error** - any read or write failure on the socket.
- **Internal error** - a rare server-side failure; not
  client-triggerable and observably identical to an I/O error.
  Reconnect with backoff.

## Query parameters

- `api_key` string, required
- `lang` 'en' | 'ko' | 'zh-hans'

## Response `default`

Failures are delivered in-band as `["response", {"status": "error", "code": ...}]` entries inside the `{"ts": ..., "data": [...]}` envelope on the open WebSocket, or by silent TCP close - see the **Errors** section above.

## Other responses

- `101`: Switching protocols - WebSocket connection established
- `400`: Refused at the handshake - `api_key` missing or `lang` invalid.
- `401`: Refused at the handshake - the key was not accepted.
- `503`: Refused at the handshake - the server cannot accept connections right now; retry with backoff.

---

[API](https://skmtc.dev/magicmarkets/apis/magicmarkets-api.md) · [All operations](https://skmtc.dev/magicmarkets/apis/magicmarkets-api/llms.txt) · [OpenAPI document](https://skmtc.dev/magicmarkets/apis/magicmarkets-api/revisions/00c2a7dcce2a?raw)
