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

# WSS /ws/user

> Authenticated WebSocket gateway for own order lifecycle and trade fills. X-Api-Key at handshake, portfolio:read scope.

```
WSS wss://ws-gateway.dev.predictstreet.sde.adifoundation.ai/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](/auth/api-keys)) 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`.

<Note>
  **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.
</Note>

Browser clients that can't set custom headers on a WebSocket upgrade
fall back to the query-param form:

```
wss://ws-gateway.dev.predictstreet.sde.adifoundation.ai/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.

<Note>
  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](/auth/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`.
</Note>

Auth is validated **once at handshake**. Rotating the key does not
invalidate an open socket — close and reopen to switch identity.
Revoking a key on the admin panel closes every live socket bound to
that `keyId` **immediately** via Redis `apikey:invalidate` pub/sub.

### Close codes

| Close code                                                               | Meaning                                                                                                                                                                                                                                        |
| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `4401 api_key_bad_format` / `api_key_unknown_key` / `api_key_bad_secret` | Malformed token or the server doesn't recognise the `keyId` / secret pair. Rotate the key on the admin panel.                                                                                                                                  |
| `4401 api_key_revoked` / `api_key_expired`                               | Key was explicitly killed or reached its expiry. Issue a fresh one.                                                                                                                                                                            |
| `4401 api_key_suspended`                                                 | The partner itself is `SUSPENDED` in admin. Contact ops — do not auto-retry.                                                                                                                                                                   |
| `4401 api_key_ip_denied`                                                 | Caller IP isn't in the key's allowlist.                                                                                                                                                                                                        |
| `4401 api_key_no_associated_wallet`                                      | Key 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_invalid`                                       | `X-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_unconfigured`               | Server-side misconfig (feature flag off or pepper missing). Ops issue, not the caller's.                                                                                                                                                       |
| `1008 forbidden origin`                                                  | WebSocket `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:

```json theme={null}
{
  "type": "connected",
  "data": {
    "gateway":         "user",
    "walletAddress":   "0xb27d13d9bc68e08249146f3e5f17bc08c77c66ce",
    "authMethod":      "api_key",
    "protocolVersion": 2
  }
}
```

## Available channels

| Channel           | `ids` shape                           | Required scope   | Server pushes                                                                                                |
| ----------------- | ------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------ |
| `user_orders`     | **none** (scoped to the key's wallet) | `portfolio:read` | `order_placed`, `order_cancelled`                                                                            |
| `user_fills`      | **none** (scoped to the key's wallet) | `portfolio:read` | `user_fill` (chain-confirmed)                                                                                |
| `vault_positions` | **vault address\[]** (1+ required)    | `portfolio:read` | `vault_position_balance_changed`, `vault_position_split`, `vault_position_merged`, `vault_position_redeemed` |

See [Server events](/api-reference/websocket/events) for full payload
shapes per `type`.

## Subscribe example

<CodeGroup>
  ```javascript Node — X-Api-Key header theme={null}
  import WebSocket from 'ws';

  const KEY = process.env.PREDICTSTREET_API_KEY; // ps_live_<keyId>_<secret>
  const ws = new WebSocket(
    'wss://ws-gateway.dev.predictstreet.sde.adifoundation.ai/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());
  });
  ```

  ```javascript Browser — ?key= query param theme={null}
  // Browsers can't set custom headers on the WebSocket upgrade. Pass
  // the key as a query parameter instead — same validation path.
  const KEY = 'ps_live_<keyId>_<secret>';
  const url = `wss://ws-gateway.dev.predictstreet.sde.adifoundation.ai/ws/user?key=${encodeURIComponent(KEY)}`;
  const ws  = new WebSocket(url);

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

  ```python Python — X-Api-Key header theme={null}
  import asyncio, json, websockets

  KEY = 'ps_live_<keyId>_<secret>'
  URL = 'wss://ws-gateway.dev.predictstreet.sde.adifoundation.ai/ws/user'

  async def run():
      async with websockets.connect(
          URL, extra_headers={'X-Api-Key': KEY},
      ) as ws:
          await ws.send(json.dumps({
              'id': 1, 'cmd': 'subscribe',
              'params': {'subscriptions': [{'channel': 'user_orders'}, {'channel': 'user_fills'}]},
          }))
          async for raw in ws:
              ev = json.loads(raw)

  asyncio.run(run())
  ```
</CodeGroup>

## Subscribe response

```json theme={null}
{
  "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`)

```json theme={null}
{
  "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`](/api-reference/portfolio/me-trades) (or
[`GET /api/me/fees`](/api-reference/portfolio/me-fees) for the per-side
fee breakdown) once you receive this event — see
[user\_fill vs `/api/me/trades`](#user_fill-vs-apimetrades) 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`](/api-reference/markets) if needed.

```json theme={null}
{
  "type":    "user_fill",
  "sid":     21,
  "channel": "user_fills",
  "data": {
    "walletAddress": "0xb27d13d9...",
    "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:

| Field                                      | WS `user_fill`            | REST `/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`                       | ✓                         | ✓                      |
| timestamp                                  | `tsMs` (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`](/api-reference/portfolio/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`](/api-reference/portfolio/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.

```json theme={null}
{
  "type":    "vault_position_balance_changed",
  "sid":     22,
  "channel": "vault_positions",
  "id":      "0xb27d13d9bc68e08249146f3e5f17bc08c77c66ce",
  "data": {
    "vaultAddress": "0xb27d13d9...",
    "tokenId":      "9871...",
    "conditionId":  "0xabc...",
    "outcomeIndex": 0,
    "balanceAfter": "1000000",
    "reason":       "OUTF",
    "txHash":       "0xc01780b2c76494d8...",
    "blockNumber":  1234567,
    "tsMs":         1776949200000
  }
}
```

## Rejection codes

| `code`                      | When                                                                                                                                                                                                  |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_params`            | unknown channel, malformed id, missing required `ids`, or wallet-scoped channel sent with `ids`                                                                                                       |
| `api_key_scope_missing`     | key is missing `portfolio:read` (required by every private channel)                                                                                                                                   |
| `forbidden`                 | tried 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_exceeded` | connection already holds the maximum subscriptions (256)                                                                                                                                              |
| `subscription_too_many_ids` | a 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. |

## Related

<CardGroup cols={2}>
  <Card title="Commands" icon="terminal" href="/api-reference/websocket/commands">
    update\_subscription / unsubscribe / list\_subscriptions / ping.
  </Card>

  <Card title="Server events" icon="bolt" href="/api-reference/websocket/events">
    Full event payload catalog per channel.
  </Card>

  <Card title="Reconnect" icon="rotate" href="/concepts/websocket/reconnect">
    Heartbeat, sid rebuild, snapshot resync.
  </Card>

  <Card title="API keys" icon="key" href="/auth/api-keys">
    Scope catalog, rotation, revoke.
  </Card>
</CardGroup>
