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

# Frames and behaviour

> Every server frame, error codes, replay, heartbeats, limits, close codes and a reconnect recipe.

## Which keys may connect

The stream checks the key before the upgrade. A refusal is an HTTP status with the error body every REST call uses, listed under [Upgrade responses](#upgrade-responses).

| Check | Rule | Refusal |
| - | - | - |
| Key | An active key that has not expired. | `401 UNAUTHENTICATED` |
| Scope | The key holds `basic` or `live:read`. | `403 PERMISSION_DENIED` |
| Plan | `growth`, `pro`, `scale`, `enterprise`, `custom`, `legacy`, and every plan whose name starts with `legacy-`. A key on a free plan (`pilot` or `explore`) connects unless its monthly reveal allowance is zero; an unmetered free key always connects. A key on any other plan connects only when it is unmetered. | `403 PLAN_REQUIRED` |
| Credit | At least 20,000 credits left, one minute; an unmetered key always passes. | `402 QUOTA_EXCEEDED` |
| Rate | The per-minute request limit of the key, and a limit per address. | `429 RATE_LIMITED` |

While free keys are switched off, a key on a free plan gets `403 PERMISSION_DENIED`.

The stream checks the key again before every minute charge, through an authentication cache that holds a key for up to 30 seconds. A key that was revoked or expired, or lost the scope or the plan, gets an error frame and the socket closes with `1008`.

## Server frames

Every frame is a JSON object with a `type`; schema `StreamServerFrame`. Ignore types and fields you do not know: new ones arrive as additive changes.

### ready

Once, after the upgrade and the first minute charge. `connectionId` names this connection in our logs, and error frames carry it as `requestId`.

```json theme={null}
{
  "type": "ready",
  "connectionId": "0192f3a4-5b6c-7d8e-9f01-23456789abcd"
}
```

### subscribed

After every accepted `subscribe`. `subscription` is what you receive from now on, normalised: duplicates dropped, EVM token addresses lowercased, and `platforms` or `tokens` set to `null` when they do not filter. `replaced` is the subscription it replaced, or `null`.

```json theme={null}
{
  "type": "subscribed",
  "subscription": {
    "topic": "posts",
    "platforms": [
      "fomo",
      "pump"
    ],
    "tokens": [
      "tok_solana_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
      "tok_base_0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
    ]
  },
  "replaced": {
    "topic": "posts",
    "platforms": null,
    "tokens": null
  }
}
```

### unsubscribed

After every `unsubscribe`. `replaced` is the subscription it cleared, or `null`.

```json theme={null}
{
  "type": "unsubscribed",
  "replaced": {
    "topic": "posts",
    "platforms": [
      "fomo",
      "pump"
    ],
    "tokens": [
      "tok_solana_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
      "tok_base_0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
    ]
  }
}
```

### post

One per delivered post. `item` is the post envelope, the same object `GET /v3/posts` returns (schema `PostEnvelope`). `cursor` is the stream cursor of this post: store the last one you processed.

```json theme={null}
{ "type": "post", "cursor": "s_eyJzZXEiOjEwNDIzMH0", "item": { "kind": "post", "id": "pst_…", "canonical": { … }, "sources": { … } } }
```

### replay\_done

After the last replayed post and before the first live one. `cursor` is the newest cursor the replay sent, or your own `since` when nothing was replayed.

```json theme={null}
{
  "type": "replay_done",
  "cursor": "s_eyJzZXEiOjEwNDIzMX0"
}
```

### error

A frame the stream rejected, or the moment before the stream closes the socket. Act on `code`; `message` is for people. `requestId` is the connection id.

```json theme={null}
{
  "type": "error",
  "error": {
    "type": "invalid_request",
    "code": "INVALID_ARGUMENT",
    "message": "The cursor is older than the replay window.",
    "requestId": "0192f3a4-5b6c-7d8e-9f01-23456789abcd"
  }
}
```

## Error frames

| `code` | When | The socket |
| - | - | - |
| `INVALID_ARGUMENT` | The frame is not JSON or not an object; it has an unknown `type`, field or `topic`; a `platforms` or `tokens` filter breaks the rules of [`subscribe`](https://docs.fomoscan.sh/v3/websocket/overview#subscribe); `since` is malformed, or names no post ("The cursor is not valid."). | Stays open. Nothing changes. |
| `INVALID_ARGUMENT` | A frame larger than 64 KB. | Closes with `1009`. |
| `INVALID_ARGUMENT` | `since` is older than the replay window: "The cursor is older than the replay window." | Stays open. The error comes before `subscribed`, and the subscription runs without a replay. |
| `RATE_LIMITED` | More than 10 `subscribe` or `unsubscribe` frames in 60 seconds. | Stays open. The frame is ignored. |
| `OVERLOADED` | The stream could not attach the subscription. | Stays open. Send the `subscribe` again. |
| `OVERLOADED` | Charging a minute failed on our side 2 times in a row. | Closes with `1011`. Reconnect with `since`. |
| `QUOTA_EXCEEDED` | The balance cannot pay the next minute. | Closes with `1008`. |
| `UNAUTHENTICATED` | At a minute charge the key is revoked or expired. | Closes with `1008`. |
| `PERMISSION_DENIED` | At a minute charge the key has lost the stream scope, or free keys are switched off. | Closes with `1008`. |
| `PLAN_REQUIRED` | At a minute charge the plan of the key no longer includes the stream. | Closes with `1008`. |

At most 10 error frames in 10 seconds answer rejected frames; past that a rejected frame changes nothing and gets no answer. The replay window error, `OVERLOADED` for a `subscribe`, and the error before a close always arrive.

## Replay with `since`

* Every `post` frame carries a `cursor` (`s_…`). Store the last one you processed and send it back as it is; do not parse it.
* A `subscribe` with `since` sends the posts published after that cursor which match the new filters, oldest first, then `replay_done`, then live posts. Live posts published while the replay loads wait and follow `replay_done`; a post in both arrives once.
* The replay window is the last 5 minutes by publication time. A `since` older than that gets the error frame `INVALID_ARGUMENT` "The cursor is older than the replay window.", then `subscribed`, and no `replay_done`: live posts flow, and you fill the gap from `GET /v3/posts`, deduplicated on `item.id`.
* A `since` that names no post gets `INVALID_ARGUMENT` "The cursor is not valid.", and the subscription does not change.
* Stream cursors work only here. The `c_` cursors of REST collections are a different thing.

## Ordering and duplicates

* Posts arrive in the order we published them, which can differ from `canonical.postedAt`. Sort by `postedAt` when you need the author time order.
* On one connection a post arrives once. A `subscribe` without `since` continues after the newest post the connection already sent, so it repeats nothing.
* A `subscribe` with `since` replays everything after that cursor, posts this connection already delivered included. Reconnect with the last cursor you processed and nothing repeats; an older cursor repeats posts.
* Deduplicate on `item.id` wherever you merge the stream with REST pages or with a replay from an older cursor.
* The stream sends a post once, when we publish it. Later edits, counts and hides send no frame; read the post from REST for its current state.

## Heartbeats

* The server sends a WebSocket ping every 30 seconds. Browser and Node `ws` clients answer with a pong on their own; a hand-written client must answer itself.
* The stream closes a socket it heard nothing from (no pong, no frame) for 90 seconds with `1011`.
* Watch the other direction too: when neither a ping nor a frame arrives for 75 seconds, the path is dead. Terminate the socket and reconnect.

## Limits

| Limit | Value | Past it |
| - | - | - |
| Inbound frame | 64 KB | Error frame `INVALID_ARGUMENT` and close `1009`; over 1 MiB, close `1009` with no frame. |
| Frames sent before `ready` | 32 frames or 64 KB | Close `1009`. |
| Frames waiting for the stream to handle them | 32 frames or 64 KB | Close `1013`. |
| Frames waiting for you to read them | 8 MB | Close `1013`. |
| `subscribe` and `unsubscribe` | 10 in 60 seconds | Error frame `RATE_LIMITED`; the frame is ignored. |
| Error frames that answer rejected frames | 10 in 10 seconds | Further rejected frames get no answer. |
| Tokens in one filter | 50 | Error frame `INVALID_ARGUMENT`. |
| Replay window | 5 minutes | Error frame `INVALID_ARGUMENT`; no replay. |

## Close codes

| Code | Meaning | What to do |
| - | - | - |
| `1001` | The stream is restarting for a deploy. | Reconnect at once with `since`. |
| `1008` | The key may not continue: the balance is spent, the key is revoked or expired, or it lost the stream scope or plan. An error frame names the code first. | Do not retry in a loop. Fix the key or the balance, then reconnect with `since`. |
| `1009` | A frame larger than 64 KB (larger than 1 MiB closes with no error frame), or more than 32 frames or 64 KB sent before `ready`. | Fix the client first: the same frames close the next socket too. |
| `1011` | Nothing heard from you for 90 seconds, or charging a minute failed on our side 2 times in a row (after an `OVERLOADED` error frame). | Reconnect with backoff and `since`. |
| `1013` | You read too slowly (8 MB wait in the send buffer) or send faster than the stream handles (32 frames or 64 KB wait). | Read faster or narrow the filters, then reconnect with `since`. |

Any other code, or a drop without a close frame (`1006` in most libraries), means the network path broke: reconnect with backoff and `since`.

## Reconnect correctly

1. Store the `cursor` of each post once you have processed it.
2. On close, read the code. `1008`: stop and fix the key or the balance. `1009`: fix the client. Anything else: reconnect.
3. Reconnect at once after `1001`. Otherwise wait 1, 2, 4 and up to 30 seconds between attempts, with jitter. A refused handshake with `429` or `503` names the wait in `Retry-After`; `401`, `402` and `403` need a fix before a retry.
4. After `ready`, send the same `subscribe` with `since` set to the stored cursor.
5. Process the replayed posts, then `replay_done`, then live posts.
6. When the replay window error arrives instead, fill the gap from `GET /v3/posts` and deduplicate on `item.id`.

## Upgrade responses

The stream checks the key before the upgrade and answers a refusal with the error body every REST call uses.

| Status | Meaning |
| - | - |
| `101` | Switching Protocols. The socket is open, the first minute is charged, and `ready` follows. |
| `400` | `INVALID_ARGUMENT`: The WebSocket handshake is not valid: `Sec-WebSocket-Key`, `Sec-WebSocket-Version` or `Sec-WebSocket-Protocol`. |
| `401` | `UNAUTHENTICATED`: No key, or the key is unknown, revoked or expired. |
| `402` | `QUOTA_EXCEEDED`: The balance cannot pay the first minute. |
| `403` | `PERMISSION_DENIED`: The key has no stream scope, or free keys are switched off. `PLAN_REQUIRED`: The plan of the key does not include the stream. |
| `404` | `NOT_FOUND`: Another path, or a request without the WebSocket upgrade headers. |
| `429` | `RATE_LIMITED`: Too many requests from the key or the address. Retry after `Retry-After` seconds. |
| `500` | `INTERNAL`: The handshake failed on our side. |
| `503` | `OVERLOADED`: The stream is restarting, or could not charge the first minute. Retry after `Retry-After` seconds. |


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