> ## Documentation Index
> Fetch the complete documentation index at: https://docs.magicmarkets.com/llms.txt
> Use this file to discover all available pages before exploring further.

# WebSocket stream

> The wire format, the initial sync, commands, every message type, offers, account updates, errors, and reconnect discipline.

```
wss://magicmarkets.com/v2/stream?api_key=<key>[&lang=en]
```

The stream pushes event changes, live offers, your private betslip quotes, and your order and bet updates as they happen. You normally hold one socket open for the life of your program and call REST alongside it.

<Note>
  For a snapshot, REST is simpler. `GET /v2/events/` lists the events that have prices, and `GET /v2/offers/` returns the offers on up to 20 events per call. Both return the same objects the stream sends. A REST reply can be about 4 seconds behind live prices, so use the stream when you need each change as it happens. The stream is the only place `pmm` quotes and order updates are pushed. Over REST, you poll the betslip and the order.
</Note>

## Connection

| Parameter | Required | Notes |
| - | - | - |
| `api_key` | Yes | The same value as the `X-Api-Key` header |
| `lang` | No | `en` (default), `ko`, `zh-hans` |

Authentication is enforced at the HTTP handshake. A refused upgrade returns a non-101 status with a short plain-text body: `400 missing_credentials` or `invalid_lang`, `401 auth_rejected`, or `503 unavailable`. Many client libraries report this only as a handshake error. **Verify the key over REST first.** See [Errors and rate limits](/developers/errors-and-rate-limits).

The server does not restrict the `Origin` header, so a page in your own browser can connect directly. Any page that holds your key can trade your balance. Never put a key in a page that other people load.

<Warning>
  The retired `/magic-cpricefeed/v2` endpoint returns 502. Any code still pointing at it, or at `pro.magicmarkets.com`, is stale.
</Warning>

## Wire format

Every frame the server sends is a batch envelope:

```json theme={"dark"}
{"ts": 1757260800.269, "data": [ <message>, <message>, ... ]}
```

Each `data[]` entry is an array whose first element is a type tag:

```python theme={"dark"}
frame = json.loads(ws.recv())
for entry in frame["data"]:
    tag = entry[0]                              # "event" | "offer" | "pmm" | "order" | "sync" | ...
    payload = entry[1] if len(entry) > 1 else None
```

Batching boundaries carry **no meaning**. A frame may hold a register snapshot and its `response` together, or offers alongside account updates. Never rely on ordering, grouping, or a type appearing exactly once per frame. Iterate `data[]` and dispatch on `entry[0]`. Ignore tags you do not recognise rather than failing.

## Initial sync

On connect the server sends every currently priced event as `["event", ...]` entries, possibly across several frames, then `["sync", {"session_id": ...}]`. Account messages such as `balance` and `xrate` can arrive in the same frames, before `sync`. `GET /v2/events/` returns the same event objects over REST.

```json theme={"dark"}
["event", {"event_type": "normal", "sport": "fb",
  "event_id": "2026-09-13,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-09-13T15:00:00Z"}]
```

```json theme={"dark"}
["event", {"event_type": "multirunner", "sport": "af",
  "event_id": "2026-09-10,multirunner,100375008", "competition_id": 545,
  "competition_name": "USA NFL", "event_name": "NFL Super Bowl Winner",
  "teams": [{"team_id": 21614, "name": "Arizona Cardinals"}],
  "start_time": "2026-09-10T00:20:00Z", "end_time": "2027-02-14T21:00:00Z"}]
```

Dispatch on `event_type`. A `normal` event carries `home` and `away`. A `multirunner` event carries a `teams` array and an `end_time`.

The snapshot contains only events that currently have prices, and it can hold thousands of them across many frames. After `sync`, changed events re-arrive as `event` and events that lose their prices arrive as `remove_event`. Keep the `session_id`: it helps when correlating with REST errors or talking to support.

## Commands

Commands are JSON arrays sent by the client.

| Command | Result |
| - | - |
| `["register_event", "<sport>", "<event_id>"]` | An `offer` snapshot for the event, then an ok `response` |
| `["unregister_event", "<sport>", "<event_id>"]` | ok. Idempotent |
| `["list_registered_events"]` | `{"registered_events": [[sport, id], ...]}` |
| `["echo", "anything"]` | `{"status": "ok", "data": ["anything"]}`: the arguments, as an array |

Registering an event with no prices is not an error. You get an empty snapshot. There is no unknown-event error.

The registered-event cap is counted across **all** your connections. Exceeding it returns `customer_event_limit_exceeded`. Registrations **do not survive a reconnect**; re-register after any drop.

## Message types

| Tag | Meaning |
| - | - |
| `event` | An event with prices. Full object, replaces what you hold |
| `remove_event` | The event no longer has prices. Drop it |
| `sync` | The initial snapshot is complete. Carries `session_id` |
| `offer` | A priced selection on a registered event. Full replacement for that `(sport, event_id, bet_type)` |
| `remove_offer` | That selection has no liquidity left. Delete it |
| `pmm` | Your private quote for an open betslip |
| `betslip` | A betslip you own changed |
| `betslip_closed` | `{betslip_id, close_reason}`. No further `pmm` will arrive |
| `order` | One of your orders changed |
| `bet` | A matched bet on one of your orders |
| `balance` | `{balance, open_stake, smart_credit}` in USDT. `smart_credit` is absent when the account has none |
| `xrate` | `{ccy, rate}`, an exchange rate changed |
| `info` | Feed status, sent every few seconds so an idle connection still sees traffic. Carries `registered_events`, the count registered on this connection |
| `response` | The result of a command you sent |
| `clear_events` | The server lost its upstream feed. Discard everything you hold; a fresh snapshot follows |
| `event_time`, `event_score`, `event_red_cards` | In-play clock, score and red cards for a registered event. Football `time` is `[period, minutes]` with periods `1h`, `2h`, `ht` |
| `ir_info`, `remove_ir_info` | A full in-running state snapshot for an event, and its removal. Informational |
| `event_exchange_dark_liquidity` | A rough estimate of additional liquidity per line beyond the published offers. Informational |

Two of these matter for correctness. On `clear_events`, drop every event, offer and in-play state you hold and wait for the new snapshot and `sync`. On `betslip_closed`, stop waiting for quotes on that betslip and create a new one to re-quote.

## Offers

```json theme={"dark"}
["offer", {
  "sport": "fb", "event_id": "2026-09-13,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]}}
  ]
}]
```

`price_list` is sorted by decimal price descending, one entry per price, so `price_list[0]` is the highest advertised price. Offer prices are indicative. A book can show prices that cross, and not every level can be traded. The price you can trade is the `pmm` quote for your betslip. `min` is `null` when there is no minimum. `max` is the stake available at that price. An empty `price_list` means no liquidity.

An `offer` is a full replacement for its `(sport, event_id, bet_type)`, not a delta. Overwrite, do not merge. `for` and `against` on the same selection are two separate offers.

## Your quotes and orders

After `POST /v2/betslips/`, the quote arrives here:

```json theme={"dark"}
["pmm", {"betslip_id": "b7f3...", "price_list": [{"effective": {"price": 2.02, "max": ["USDT", 120.0]}}]}]
```

Match it to your betslip by `betslip_id` and wait for a non-empty `price_list`. After `POST /v2/orders/`, watch `order` messages for your `order_id` until `status` is `done` or `failed`, and `bet` messages for the matched pieces.

## Errors

Command errors arrive in-band and leave the socket open:

```json theme={"dark"}
["response", {"status": "error", "code": "invalid_input"}]
```

Codes: `bad_json`, `invalid_input`, `already_registered`, `customer_event_limit_exceeded`, `invalid_customer`, `system_error`. `invalid_customer` is transient: retry after a short backoff. Treat anything else as opaque: log it and retry with backoff. See [Errors and rate limits](/developers/errors-and-rate-limits) for the full table.

**Backpressure.** If you read too slowly, the server closes with code `1008`. A client that has stopped reading sees only a TCP close.

**Silent drops.** Two failures close an established connection with a raw TCP close, no close frame and no error: an I/O error and an internal error. A quiet socket is not proof of a healthy one.

## Reconnect discipline

```python theme={"dark"}
delay = 1
while True:
    try:
        with connect(url) as ws:
            delay = 1
            resync(ws)        # collect events until sync
            re_register(ws)   # registrations are not preserved
            pump(ws)          # dispatch frames
    except Exception:
        time.sleep(delay)
        delay = min(delay * 2, 60)
```

Treat any exit from the pump as a reconnect trigger. Read fast: if you do heavy work per message, hand frames to a queue and process them off the read loop, or backpressure will close the connection. After a `1008` close, fix the read speed or register fewer events before you reconnect, because an immediate reconnect overflows again. On reconnect you get a fresh snapshot, so rebuild your state from it rather than assuming your cache survived.

## Next

* [Maintain a live price book](/developers/guides/price-book)
* [Stream many events](/developers/guides/multi-event-streaming)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.