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

# User feed

The user feed streams your account‑scoped updates (orders, fills, cancels).

<CodeGroup>
  ```javascript JavaScript theme={null}
  // user_feed_quickstart.js 
  const WebSocket = require('ws'); 
  const token = process.env.FOURCASTERS_TOKEN; 
  function connectUserFeed() { 
    const ws = new WebSocket('wss://streaming-api.4casters.io/v2/user', { 
      headers: { Authorization: token }, 
    });
    let pingTimer; let lastPong = Date.now(); 
    ws.on('open', () => { 
      console.log('user feed connected'); 
      pingTimer = setInterval(() => { 
        if (ws.readyState === WebSocket.OPEN) { 
          ws.ping(); 
        } 
        if (Date.now() - lastPong > 30000) { 
          console.warn('user feed: missed pong >30s, closing'); 
          ws.terminate(); 
        } 
      }, 10000); 
    }); 
   
    ws.on('pong', () => { lastPong = Date.now(); }); 
   
    ws.on('message', (buf) => { 
      try { 
        const msg = JSON.parse(buf.toString()); 
        console.log('user update:', JSON.stringify(msg, null, 2)); 
      } catch { console.log('user update (raw):', buf.toString()); } 
    }); 
   
    ws.on('error', (e) => console.error('⚠️ user feed error:', e.message));
   
    ws.on('close', (code, reason) => { 
      console.log(`user feed closed: ${code} ${reason}`); 
      clearInterval(pingTimer); 
      // basic backoff reconnect 
      setTimeout(connectUserFeed, 1000); 
    }); 
  } 
   
  connectUserFeed(); 
  ```
</CodeGroup>

## Messages

The user feed sends each update as a **bare JSON object** (no tuple envelope). All updates share one envelope; the action is determined by which of `unmatched` / `matched` are populated and by `origin`.

### Common envelope

<CodeGroup>
  ```typescript Schema theme={null}
  {
    matched:    null | MatchedBlock,
    unmatched:  null | UnmatchedBlock,
    origin:     "offer" | "wager",

    gameID:             string,
    parentGameID:       string | null,
    eventName:          string,
    league:             string,
    sport:              string,
    live:               boolean,
    start:              string,   // RFC3339
    awayRotationNumber: string,

    platform:           string,   // e.g. "api"
    createdAt:          string,   // RFC3339
    messageID:          string,   // Redis stream entry id, e.g. "1715361234567-0"
  }
  ```
</CodeGroup>

`messageID` is the stream entry id added by the streaming-api before send. Persist the latest one you have processed — it is your replay cursor. See [Replaying missed messages](#replaying-missed-messages) below.

### How to identify the action

* `unmatched.filled === 0 && unmatched.offered > 0` -> order placed
* `unmatched.offered === 0` -> order cancelled
* `matched != null && unmatched == null` -> matched as taker
* `matched != null && unmatched != null` -> matched as maker (partial fill if `unmatched.remaining > 0`)

Updates for orders placed with `orderType: "postArb"` also carry `isPostArb: true` inside `matched` / `unmatched` (on placed, filled, and cancelled updates); the field is omitted otherwise.

### Side and market semantics by bet type

The meaning of `side` (and which extra fields appear) inside `matched` / `unmatched` depends on `type`:

| `type`         | `side`                   | `number`                  | `market`                                            |
| -------------- | ------------------------ | ------------------------- | --------------------------------------------------- |
| `moneyline`    | participant ObjectID hex | —                         | —                                                   |
| `spread`       | participant ObjectID hex | spread line (e.g. `6.5`)  | —                                                   |
| `total`        | `"over"` / `"under"`     | total line (e.g. `209.5`) | —                                                   |
| `moneyline1x2` | `"yes"` / `"no"`         | —                         | participant ObjectID hex (home or away) or `"draw"` |

`market` is only present on `moneyline1x2` updates: it names the 3-way outcome the market is about (home team, away team, or the draw), and `side` says whether the bet is yes or no on that outcome. All fields are from **your** perspective — as maker you see your posted side; as taker you see the side you took.

### Action: order placed

A new offer was put on the book. `unmatched.filled` is `0`, `unmatched.offered > 0`, and `matched` is `null`.

<CodeGroup>
  ```json JSON theme={null}
  {
    "unmatched": {
      "filled": 0,
      "offered": 999,
      "remaining": 999,
      "orderID": "62619e6b40e36d0494600f67",
      "wagerRequestID": "62619e6b40e36d0494600f70",
      "type": "moneyline",
      "side": "607349dc22a237cf46b021fb",
      "odds": -105
    },
    "matched": null,
    "origin": "offer",
    "gameID": "603eb5d05eca45001243aedc",
    "parentGameID": null,
    "eventName": "PHILADELPHIA-76ERS-VS-MIAMI-HEAT",
    "league": "NBA",
    "sport": "basketball",
    "live": false,
    "start": "2026-04-22T01:00:00.000Z",
    "awayRotationNumber": "571",
    "platform": "api",
    "createdAt": "2026-04-21T18:11:54.614Z",
    "messageID": "1715361234567-0"
  }
  ```
</CodeGroup>

### Action: order cancelled

The user (or the system on their behalf) cancelled an offer. The distinguishing field is **`unmatched.offered === 0`**.

<CodeGroup>
  ```json JSON theme={null}
  {
    "unmatched": {
      "filled": 0,
      "offered": 0,
      "remaining": 0,
      "orderID": "62619e6b40e36d0494600f67",
      "wagerRequestID": "62619e6b40e36d0494600f70",
      "type": "spread",
      "side": "607349dc22a237cf46b021fb",
      "number": 6.5,
      "odds": -110
    },
    "matched": null,
    "origin": "offer",
    "gameID": "603eb5d05eca45001243aedc",
    "parentGameID": null,
    "eventName": "PHILADELPHIA-76ERS-VS-MIAMI-HEAT",
    "league": "NBA",
    "sport": "basketball",
    "live": false,
    "start": "2026-04-22T01:00:00.000Z",
    "awayRotationNumber": "571",
    "platform": "api",
    "createdAt": "2026-04-21T18:12:30.000Z",
    "messageID": "1715361234890-0"
  }
  ```
</CodeGroup>

### Action: order matched (taker)

The user took someone else's offer. `unmatched` is `null`, `matched` carries the fill, and `origin` is `"wager"`.

<CodeGroup>
  ```json JSON theme={null}
  {
    "unmatched": null,
    "matched": {
      "txID": "62619e6b40e36d0494600f99",
      "amount": 100.0,
      "risk": 100.0,
      "win": 95.24,
      "odds": -105,
      "orderID": "62619e6b40e36d0494600f67",
      "wagerRequestID": "62619e6b40e36d0494600f70",
      "type": "moneyline",
      "side": "607349dc22a237cf46b021fb"
    },
    "origin": "wager",
    "gameID": "603eb5d05eca45001243aedc",
    "parentGameID": null,
    "eventName": "PHILADELPHIA-76ERS-VS-MIAMI-HEAT",
    "league": "NBA",
    "sport": "basketball",
    "live": false,
    "start": "2026-04-22T01:00:00.000Z",
    "awayRotationNumber": "571",
    "platform": "api",
    "createdAt": "2026-04-21T18:13:00.000Z",
    "messageID": "1715361240000-0"
  }
  ```
</CodeGroup>

### Action: order matched (maker, partial fill)

Someone hit the user's posted offer. Both `matched` and `unmatched` are populated; `unmatched.remaining` shows what is still on the book. Because `unmatched` is set, `origin` is `"offer"`.

<CodeGroup>
  ```json JSON theme={null}
  {
    "unmatched": {
      "filled": 100,
      "offered": 500,
      "remaining": 400,
      "orderID": "62619e6b40e36d0494600f67",
      "wagerRequestID": "62619e6b40e36d0494600f70",
      "type": "spread",
      "side": "607349dc22a237cf46b021fb",
      "number": 6.5,
      "odds": 110
    },
    "matched": {
      "txID": "62619e6b40e36d0494600fa0",
      "amount": 100,
      "risk": 100,
      "win": 110,
      "odds": 110,
      "orderID": "62619e6b40e36d0494600f67",
      "wagerRequestID": "62619e6b40e36d0494600f70",
      "type": "spread",
      "side": "607349dc22a237cf46b021fb",
      "number": 6.5
    },
    "origin": "offer",
    "gameID": "603eb5d05eca45001243aedc",
    "parentGameID": null,
    "eventName": "PHILADELPHIA-76ERS-VS-MIAMI-HEAT",
    "league": "NBA",
    "sport": "basketball",
    "live": false,
    "start": "2026-04-22T01:00:00.000Z",
    "awayRotationNumber": "571",
    "platform": "api",
    "createdAt": "2026-04-21T18:14:00.000Z",
    "messageID": "1715361250000-0"
  }
  ```
</CodeGroup>

### Action: order matched (moneyline1x2)

Fills on 3-way soccer markets carry the **`market` + `side` pair** described above instead of a participant-hex `side`. Here the user took "yes" on the home team at +120 (a bet that Arsenal wins):

<CodeGroup>
  ```json JSON (taker) theme={null}
  {
    "unmatched": null,
    "matched": {
      "txID": "685e321440e36d0494601b07",
      "amount": 100.0,
      "risk": 100.0,
      "win": 120.0,
      "odds": 120,
      "orderID": "685e2f1c40e36d0494601a22",
      "wagerRequestID": "685e321440e36d0494601b00",
      "type": "moneyline1x2",
      "side": "yes",
      "market": "607349dc22a237cf46b021fb"
    },
    "origin": "wager",
    "gameID": "685e21aa0be1b7d5a3f9c4d2",
    "parentGameID": null,
    "eventName": "ARSENAL-VS-CHELSEA",
    "league": "EPL",
    "sport": "soccer",
    "live": false,
    "start": "2026-07-04T19:00:00.000Z",
    "awayRotationNumber": "3012",
    "platform": "api",
    "createdAt": "2026-07-03T14:25:02.114Z",
    "messageID": "1751552702114-0"
  }
  ```

  ```json JSON (maker, partial fill) theme={null}
  {
    "unmatched": {
      "filled": 100,
      "offered": 500,
      "remaining": 400,
      "orderID": "685e2f1c40e36d0494601a22",
      "wagerRequestID": "685e2f1c40e36d0494601a19",
      "type": "moneyline1x2",
      "side": "no",
      "market": "607349dc22a237cf46b021fb",
      "odds": -120
    },
    "matched": {
      "txID": "685e321440e36d0494601b07",
      "amount": 120.0,
      "risk": 120.0,
      "win": 100.0,
      "odds": -120,
      "orderID": "685e2f1c40e36d0494601a22",
      "wagerRequestID": "685e2f1c40e36d0494601a19",
      "type": "moneyline1x2",
      "side": "no",
      "market": "607349dc22a237cf46b021fb"
    },
    "origin": "offer",
    "gameID": "685e21aa0be1b7d5a3f9c4d2",
    "parentGameID": null,
    "eventName": "ARSENAL-VS-CHELSEA",
    "league": "EPL",
    "sport": "soccer",
    "live": false,
    "start": "2026-07-04T19:00:00.000Z",
    "awayRotationNumber": "3012",
    "platform": "api",
    "createdAt": "2026-07-03T14:25:02.114Z",
    "messageID": "1751552702114-1"
  }
  ```
</CodeGroup>

Note the two perspectives of the same fill: the taker sees `side: "yes"` at `+120`, the maker sees `side: "no"` at `-120` on the same `market`.

## Replaying missed messages

The v2 user feed supports **gap recovery after a dropped connection**. Every message carries a `messageID` (a monotonically increasing stream entry id, e.g. `"1751552702114-0"`). If your connection drops, you can fetch exactly the messages you missed.

### Endpoint

```
GET https://streaming-api.4casters.io/v2/user/messages?afterID=<messageID>&beforeID=<messageID>
```

Authentication is the same as the WebSocket: token in the `Authorization` header, `Auth` header, or `token` query parameter. Only messages belonging to the authenticated user are returned.

| Parameter  | Required | Meaning                                                                                                 |
| ---------- | -------- | ------------------------------------------------------------------------------------------------------- |
| `afterID`  | yes      | The `messageID` of the **last message you processed** before the drop. Results start strictly after it. |
| `beforeID` | no       | Exclusive upper bound. Omit to read to the end of the retained history.                                 |

### Response: `success`

Your `afterID` was still present in the retained history, so the returned list is a **complete** gap-fill — nothing between `afterID` and `beforeID` was lost:

```json theme={null}
{
  "status": "success",
  "messages": [
    { "...": "feed messages in order, each with its messageID" }
  ]
}
```

Apply the messages in order and resume processing live messages.

### Response: `cache_expired`

Your `afterID` has aged out of the retained history, so the server **cannot prove the replay is complete**:

```json theme={null}
{
  "status": "cache_expired",
  "availableMessages": [
    { "...": "whatever is still retained, oldest first" }
  ]
}
```

`availableMessages` is whatever is still retained, oldest first. Messages older than the retention window are not recoverable through replay — reconnect promptly rather than relying on long look-back.

### Example: reconnecting client with replay

A complete Node.js client (keepalive ping/pong omitted for brevity — see the quickstart at the top of this page). Two things keep it correct: every message — live, buffered, or replayed — passes through one forward-only cursor check, and a failed replay reconnects rather than going live over the gap.

<CodeGroup>
  ```javascript JavaScript theme={null}
  // user_feed_replay_client.js
  const WebSocket = require('ws');
  const token = process.env.FOURCASTERS_TOKEN;
  const HOST = 'streaming-api.4casters.io';

  // Your replay cursor: the messageID of the last message you processed.
  // Persist it durably (disk/db) so replay also works across process restarts.
  let cursor = null;

  // messageIDs are "<unix-ms>-<seq>"; compare numerically part by part.
  function isAfterCursor(messageID) {
    if (!cursor) return true;
    const [ms, seq] = messageID.split('-').map(Number);
    const [cMs, cSeq] = cursor.split('-').map(Number);
    return ms > cMs || (ms === cMs && seq > cSeq);
  }

  function processMessage(msg) {
    // Replay can overlap live delivery, so every message — live, buffered,
    // or replayed — passes through this check. The cursor only moves forward.
    if (!isAfterCursor(msg.messageID)) return;
    cursor = msg.messageID;
    // Business logic goes here. `msg` is the same envelope everywhere.
    console.log('user update:', msg.origin, msg.messageID);
  }

  async function replayGap() {
    const params = new URLSearchParams({ afterID: cursor });
    const res = await fetch(`https://${HOST}/v2/user/messages?${params}`, {
      headers: { Authorization: token },
    });
    if (!res.ok) throw new Error(`replay request failed: ${res.status}`);
    const body = await res.json();

    if (body.status === 'success') {
      // Complete gap-fill: nothing between the cursor and now was lost.
      body.messages.forEach(processMessage);
    } else if (body.status === 'cache_expired') {
      // Apply what is still retained; anything older is not recoverable.
      console.warn('replay window expired - messages may have been lost');
      body.availableMessages.forEach(processMessage);
    } else {
      throw new Error(`unexpected replay status: ${body.status}`);
    }
  }

  function connect() {
    const ws = new WebSocket(`wss://${HOST}/v2/user`, {
      headers: { Authorization: token },
    });

    let live = false; // false while replay is in flight
    const buffer = [];
    // Settles when the open handler is done, so a reconnect never starts a
    // second replay while this one is in flight.
    let openTask = Promise.resolve();

    ws.on('open', () => {
      openTask = (async () => {
        console.log('user feed connected');
        if (cursor !== null) {
          try {
            await replayGap();
          } catch (e) {
            // Going live now would advance the cursor over the unfilled gap
            // and lose it — reconnect and retry from the same cursor.
            console.error('replay failed, reconnecting:', e.message);
            ws.terminate();
            return;
          }
        }
        // Drain messages buffered during replay; processMessage skips
        // anything the replay already covered.
        buffer.splice(0).forEach(processMessage);
        live = true;
      })();
    });

    ws.on('message', (buf) => {
      const msg = JSON.parse(buf.toString());
      if (live) processMessage(msg);
      else buffer.push(msg);
    });

    ws.on('close', async (code, reason) => {
      console.log(`user feed closed: ${code} ${reason} - reconnecting`);
      await openTask.catch(() => {});
      setTimeout(connect, 1000);
    });

    ws.on('error', (e) => console.error('user feed error:', e.message));
  }

  connect();
  ```
</CodeGroup>
