> ## 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.

# Live posts (WebSocket)

> One WebSocket pushes every new post as we publish it, from every platform, narrowed to the platforms and tokens you choose. A `post` frame carries the envelope `GET /v3/posts` returns.

This path lives on the stream host and answers only a WebSocket upgrade, so "Try it out" cannot open it. A plain `GET` gets `404`.

## Connect

URL: `wss://stream.fomoscan.sh/v3`

Send the key on the upgrade request in one of the two headers every REST call takes:

- `Authorization: Bearer <key>`
- `X-Api-Key: <key>`

The URL takes no key parameter. A browser `WebSocket` cannot set headers, so hold the socket on your server and relay frames to your front end. Never ship the key to a browser.

```sh
npx wscat -c 'wss://stream.fomoscan.sh/v3' -H "Authorization: Bearer $FOMOSCAN_KEY"
```

```js
import WebSocket from 'ws';

const socket = new WebSocket('wss://stream.fomoscan.sh/v3', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
socket.on('message', (data) => {
  const frame = JSON.parse(data.toString());
  if (frame.type === 'ready') socket.send(JSON.stringify({ type: 'subscribe', topic: 'posts' }));
  if (frame.type === 'post') handle(frame.item, frame.cursor);
});
```

Frames you send before `ready` wait in a queue of 32 frames or 64 KB and run after it.

## 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 Responses below.

| 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`.

## Price

Each open connection costs **20,000 credits per started minute**, taken from the same credit balance as REST calls.

- The stream charges the first minute on attach, before the upgrade completes, and one more every 60 seconds while the socket is open. A socket without a subscription pays the same.
- The price is per socket: one token, fifty tokens or every post cost the same. One socket carries every platform; each socket you open is charged on its own.
- An upgrade needs 20,000 credits left, or it gets `402 QUOTA_EXCEEDED`. A key with too few credits for the next minute gets an error frame with `QUOTA_EXCEEDED`, and the socket closes with `1008`.
- An unmetered key is counted and never refused. A child key pays from its parent's balance, as on REST.
- `GET /v3/me` shows the balance: socket minutes count in `api.unitsUsed`, and `websocket.minutesUsed` is `null`. Your usage lists each minute under `GET /v3`.

## Client frames

Send JSON text frames. The stream takes `subscribe` and `unsubscribe`; schema `StreamClientFrame`.

### subscribe

```json
{
  "type": "subscribe",
  "topic": "posts",
  "platforms": [
    "fomo",
    "pump"
  ],
  "tokens": [
    "tok_solana_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
    "tok_base_0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
  ],
  "since": "s_eyJzZXEiOjEwNDIxN30"
}
```

| Field | Rule |
| --- | --- |
| `topic` | Required: `posts`. |
| `platforms` | Optional. One or more of `fomo`, `pump`, `hyperliquid`, `markets`, `polymarket`, `legend`, `risex`; posts come from `fomo` and `pump` today. Omit the field for every platform. An empty list, `null` or an unknown name is rejected. |
| `tokens` | Optional. 1 to 50 token ids, `tok_<network>_<address>`. Omit the field for every token. A post without a token never matches a token filter. An empty list, `null` or a value that is not a token id is rejected. |
| `since` | Optional. The `cursor` of a `post` or `replay_done` frame you received. The stream replays what you missed first; see Replay. |

- A post must match both filters.
- A connection holds one subscription. A new `subscribe` replaces it in full, so send the whole token list each time.
- Nothing arrives before the first accepted `subscribe`.
- A field the frame type does not take is rejected.
- A rejected frame changes nothing and keeps the socket open.

### unsubscribe

```json
{
  "type": "unsubscribe"
}
```

Clears the subscription. The socket stays open and keeps being charged.

## 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
{
  "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
{
  "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
{
  "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
{ "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
{
  "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
{
  "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 above; `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`.



## OpenAPI

````yaml https://api.fomoscan.sh/v3/openapi.json get /v3
openapi: 3.1.0
info:
  title: fomoscan API
  version: 3.0.0
  description: >-
    Coming from v2 (`https://api.fomoscan.sh/v2`)? The section Moving from v2,
    at the end of this introduction, maps each v2 route to its v3 operation and
    price and lists what changed.


    Social graph, post and wallet data for trading products, in one shape for
    every platform. Every price is in credit units.


    ## Objects


    Every object is an envelope:


    ```json

    { "kind": "trader", "id": "tdr_01jbst8pvcfp79y0938nkrkayd", "canonical": { …
    }, "sources": { "<platform>": { "profile": { … } } } }

    ```


    | Field | Meaning |

    | --- | --- |

    | `kind` | `trader`, `wallet`, `post`, `trade`, `token`, `group`. The only
    field to switch on. Skip kinds you do not know. |

    | `id` | Our id of the object. Store it; see Ids. |

    | `canonical` | Fields that mean the same on every platform. |

    | `sources` | Capability blocks keyed by platform. A block has the same
    shape on every platform that provides it and carries its own `observedAt`. |


    What `sources` tells you:


    | On the wire | Meaning |

    | --- | --- |

    | `sources.<platform>` absent | The object has no side on that platform. |

    | `sources.<platform>.<block>` absent | The platform cannot provide the
    block (`GET /v3/capabilities` lists which can), or we hold no reading of it
    yet. |

    | A block field is `null` | The platform left it empty at `observedAt`, or
    never fills it (`unfilled` in the capability table). |

    | `{ "status": "unavailable", "retryable": true }` in place of a block | We
    could not read the block for this call. The rest of the object is current. |


    PnL, boards, rankings, follow lists, the key and the capability table are
    documents about a subject. A document holds envelopes wherever a subject
    appears.


    ## Ids


    | Kind | Id |

    | --- | --- |

    | `trader` | `tdr_` and 26 lowercase base32 characters |

    | `wallet` | `wal_<family>_<address>`, family `svm` or `evm` |

    | `post` | `pst_` and 26 lowercase base32 characters |

    | `trade` | `trd_` and 26 lowercase base32 characters |

    | `token` | `tok_<network>_<address>` |

    | `group` | `grp_` and 26 lowercase base32 characters |


    - A malformed id or a wrong prefix answers 400 `INVALID_ARGUMENT` before any
    read.

    - A platform's own ids never appear in a response. Find a trader once, by
    handle (`GET /v3/traders/lookup`) or by wallet (`GET
    /v3/wallets/{family}/{address}`), then keep our id.

    - An id merged into another trader answers 200 with the trader that absorbed
    it, whose `canonical.mergedFrom` lists the old id.

    - List cursors start with `c_`, stream cursors with `s_`. Neither works in
    the place of the other.


    ## Money and time


    - Money and token quantities are `Amount` objects, never JSON numbers. `{
    "value": "12345678", "decimals": 6, "currency": "USD" }` is 12.345678 USD.

    - `value` is an integer in base units, as a string, and negative below zero.
    Read it with a decimal or big integer type: a float loses digits.

    - `currency` is `USD`, or the token id of a token quantity
    (`tok_solana_<mint>`).

    - `priceAsOf` is present when we converted a value with a price, and holds
    the time of that price.

    - Ratios (`returnPct`, `multiple`) and counts are JSON numbers.

    - Times in bodies are RFC3339 in UTC with milliseconds. A time in a query
    parameter (`at`) is RFC3339 with `Z` or an offset.


    ## Paging


    A list answers one page:


    ```json

    { "items": [ … ], "hasMore": true, "nextCursor": "c_…" }

    ```


    - Send `nextCursor` back as `cursor` for the next page. Stop when `hasMore`
    is `false`.

    - A cursor is opaque and belongs to the query that produced it. Sent with
    other parameters, it answers 400 `INVALID_ARGUMENT`.

    - `limit` defaults to 50, at most 200. Post feeds, follow lists and trade
    lists default to 20, at most 100.

    - A page boundary inside a tie of the order neither drops nor repeats a row.

    - The entries of one board or ranking arrive whole, without paging.


    ## Errors


    Every error answers the same body:


    ```json

    { "error": { "type": "invalid_request", "code": "NOT_FOUND", "message":
    "Trader not found.", "requestId": "req_…" } }

    ```


    | HTTP | `type` | `code` | When |

    | --- | --- | --- | --- |

    | 400 | `invalid_request` | `INVALID_ARGUMENT` | A parameter, id, cursor or
    body is malformed, or the operation does not take it. |

    | 401 | `authentication` | `UNAUTHENTICATED` | No key, or the key is
    unknown, revoked or expired. |

    | 402 | `quota` | `QUOTA_EXCEEDED` | The credit balance cannot pay for the
    call. |

    | 402 | `quota` | `REVEAL_LIMIT_REACHED` | The answer needs a reveal and the
    key has used every reveal this month. |

    | 403 | `permission` | `PERMISSION_DENIED` | The key may not make the call:
    it has no REST scope, its plan is switched off, or a free key asked for a
    reveal. |

    | 403 | `permission` | `PLAN_REQUIRED` | The plan of the key does not
    include the stream. REST calls never answer it. |

    | 404 | `invalid_request` | `NOT_FOUND` | The subject of the call does not
    exist, or no route has this method and path. |

    | 429 | `rate_limit` | `RATE_LIMITED` | Too many requests from the key or
    the address. Wait `Retry-After` seconds. |

    | 500 | `internal` | `INTERNAL` | The call failed on our side. Retry with
    backoff, and quote `requestId` if it repeats. |

    | 503 | `overloaded` | `OVERLOADED` | The API holds too many requests at
    once. Wait `Retry-After` seconds. |


    - Act on `code`. `message` is for people and can change.

    - 429 and 503 carry `Retry-After` in seconds.

    - A query parameter the operation does not list answers 400.


    ## Requests


    - Send the key in `Authorization: Bearer <key>` or in `X-Api-Key: <key>`. A
    key in the query string does not count.

    - Every response carries `X-Request-Id`, the id of the request in our logs.
    Quote it when you write to us.

    - Every response carries `Cache-Control: private, no-store`.

    - A request body is JSON sent with `Content-Type: application/json`. A body
    with another content type, or with none, answers 400.

    - `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` report the
    per-minute window of the key. A key without a limit gets none of them.


    ## Compatibility


    - Inside v3 every change is additive: a new optional field, kind, platform
    key or block. Skip what you do not know.

    - Removing, renaming or retyping a field needs a new major version.


    ## Servers


    REST calls go to `https://api.fomoscan.sh`. The stream lives on its own
    host; see the WebSocket section.


    ## Credits and reveals


    - A call that answers takes the credits of the table below from the key's
    monthly balance; a `free` operation takes none. `GET /v3/me` shows the
    balance.

    - A refusal (400, 401, 402, 403, 429, 503) costs nothing. A 404 costs what
    the operation's Price table says.

    - A reveal is one unit of the key's monthly reveal allowance. Operations
    that show who owns a wallet or what a person earned take them, as the
    Reveals column says.

    - A metered key that cannot pay for an answer gets 402 `QUOTA_EXCEEDED` in
    its place. It is never charged past its balance.

    - Each operation lists its prices in full under Price.


    | Operation | Credits | Reveals |

    | --- | --- | --- |

    | `GET /v3/traders/{traderId}` | 100 hit, 200 miss | none |

    | `GET /v3/traders/lookup` | 100 hit, 200 miss | none |

    | `GET /v3/wallets/{family}/{address}` | 30,000 hit, 200 miss, 10 flat | one
    per hit |

    | `GET /v3/traders/{traderId}/wallets` | 100 hit, 200 miss | one when the
    page lists a wallet |

    | `GET /v3/traders/{traderId}/pnl` | 30,000 hit, 200 miss | one per hit |

    | `POST /v3/traders/pnl` | 30,000 per `ready` item, 200 per other item | one
    per `ready` item |

    | `GET /v3/posts` | 200 per started 20 rows of `limit`; 10 flat with
    `platform=pump` | none |

    | `GET /v3/tokens/{network}/{address}/posts` | 200 per started 20 rows of
    `limit`; 10 flat with `platform=pump` | none |

    | `GET /v3/traders/{traderId}/posts` | 200 per started 20 rows of `limit`;
    10 flat with `platform=pump` | none |

    | `GET /v3/tokens/{network}/{address}` | 100 hit, 100 miss | none |

    | `GET /v3/leaderboards` | free | none |

    | `GET /v3/leaderboards/{platform}/{board}` | 500 hit, 200 miss, 10 flat |
    none |

    | `GET /v3/rankings` | free | none |

    | `GET /v3/rankings/{key}` | 500 hit, 200 miss | none |

    | `GET /v3/me` | free | none |

    | `GET /v3/capabilities` | free | none |

    | `GET /v3` (WebSocket) | 20,000 per started minute per connection | none |

    | `GET /v3/traders/{traderId}/followers` | 200 per started 20 rows of
    `limit` | none |

    | `GET /v3/traders/{traderId}/following` | 200 per started 20 rows of
    `limit` | none |

    | `GET /v3/trades` | 200 per started 20 rows of `limit`, plus 100 per trader
    shown | one per trader shown |

    | `GET /v3/traders/{traderId}/trades` | 200 per started 20 rows of `limit`,
    plus 100 per trader shown | one per trader shown |

    | `GET /v3/wallets/{family}/{address}/trades` | 200 per started 20 rows of
    `limit` | none |

    | `GET /v3/wallets/{family}/{address}/pnl` | 500 hit, 200 miss | none |

    | `POST /v3/wallets/pnl` | 500 per `ready` item, 200 per other item | none |


    ## Rate limits


    | Limit | Value | Past it |

    | --- | --- | --- |

    | Per key | The key's `ratePerMinute` (`GET /v3/me`), reported in the
    `RateLimit-*` headers. | 429 `RATE_LIMITED` with `Retry-After`. |

    | Per address, before the key is checked | 2,400 requests per minute. | 429
    `RATE_LIMITED` with `Retry-After`. |

    | Per address and operation | Requests per minute: 2,000 on most operations;
    120 on `POST /v3/traders/pnl`; 120 on `GET /v3/me`; 120 on `POST
    /v3/wallets/pnl`; 600 on `GET /v3/leaderboards/{platform}/{board}`; 600 on
    `GET /v3/rankings/{key}`. | 429 `RATE_LIMITED` with `Retry-After`. |

    | Requests in flight at once on one API instance, across every key | 30
    requests. | 503 `OVERLOADED` with `Retry-After: 1`, before the key is
    checked. |

    | Requests in flight at once on one API instance, for one key and the keys
    under it | 15 requests. | 429 `RATE_LIMITED` with `Retry-After: 1`, after
    the key is checked. |


    A refused request costs nothing. Retry after the seconds in `Retry-After`.


    ## Live posts


    Live posts arrive over one WebSocket on the stream host. The WebSocket
    section at the end of this page says how to connect, what it costs and how
    to behave on it.


    ## Moving from v2


    v2 (`https://api.fomoscan.sh/v2`) and v3 take the same key and spend from
    the same balance. Most v3 operations cost what the v2 route they replace
    costs; the table under Routes and prices lists both.


    ### At a glance


    |  | v2 | v3 |

    | --- | --- | --- |

    | REST | `https://api.fomoscan.sh/v2/…` | `https://api.fomoscan.sh/v3/…` |

    | Reference | `https://api.fomoscan.sh/docs`, `https://docs.fomoscan.sh` |
    `https://api.fomoscan.sh/v3/docs`, `https://api.fomoscan.sh/v3/openapi.json`
    |

    | Stream | `wss://api.fomoscan.sh/v2/ws`, `wss://api.fomoscan.sh/v2/ws/pump`
    | `wss://stream.fomoscan.sh/v3`, one socket for every platform |

    | Key | `fsk_live_…`, `fsk_test_…` | the same key |

    | Headers | `Authorization: Bearer <key>` or `X-Api-Key: <key>` | the same;
    a key in the query string does not count |

    | Answers | flat JSON records, USD as numbers, epoch milliseconds |
    envelopes and documents, money as integer strings, RFC3339 times |


    ### Keys, plans and credits


    - Your v2 key works on v3. A v3 call spends from the monthly credits, top-up
    and reveal cap a v2 call spends from, so `GET /v2/me` and `GET /v3/me`
    report one balance.

    - Scopes are the v2 scopes: `basic` covers REST and the stream, `http:read`
    REST only and `live:read` the stream only.

    - The stream takes the plans it takes on v2; the WebSocket section lists
    them.

    - A key on a free plan (`pilot` or `explore`) without reveals gets 403
    `PERMISSION_DENIED` for every answer that takes a reveal. v2 let such keys
    reveal a fixed sample of handles and wallets; v3 has no sample set.

    - A refusal (400, 401, 402, 403, 429, 503) costs nothing. A 404 costs the
    operation's miss price, as on v2.


    ### Routes and prices


    Credits per call. A reveal is one unit of the key's monthly reveal cap. Each
    v3 operation lists every answer and its price under Price.


    | v2 | v2 credits | v3 | v3 credits |

    | --- | --- | --- | --- |

    | `GET /v2/user/handle/{handle}` | 100 and a reveal when a wallet is
    returned; 200 not found | `GET /v3/traders/lookup?handle=`, then `GET
    /v3/traders/{traderId}/wallets` | 100, 200 with no match; then 100, and a
    reveal when the page lists a wallet |

    | `GET /v2/user/handle/{handle}/profile` | 100; 200 not found | `GET
    /v3/traders/lookup?handle=` | 100; 200 with no match |

    | `GET /v2/user/id/{id}`, `GET /v2/user/id/{id}/profile` | 100; 200 not
    found | not in v3 yet |  |

    | `GET /v2/user/wallet/{address}`, `GET /v2/pump/user/wallet/{wallet}` |
    30,000 and a reveal, 200 not found; the `/v2/pump/` route 10 | `GET
    /v3/wallets/{family}/{address}` | 30,000 and a reveal; 10 when every owner
    holds tier `account`; 200 without an owner |

    | `POST /v2/user/handle/{handle}/resolve` | 30,000 and a reveal; 200 on a
    202 or 404 | `GET /v3/traders/{traderId}/wallets`, stored wallets only |
    100, and a reveal when the page lists a wallet; 200 for an unknown trader |

    | `GET /v2/user/handle/{handle}/pnl` | 30,000 and a reveal when `ready`; 200
    otherwise | `GET /v3/traders/{traderId}/pnl` | 30,000 and a reveal when
    `ready`; 200 otherwise |

    | `POST /v2/user/handles/pnl` | per handle: 30,000 and a reveal when
    `ready`, 200 otherwise | `POST /v3/traders/pnl` | per id: 30,000 and a
    reveal when `ready`, 200 otherwise |

    | `GET /v2/pump/user/wallet/{wallet}/pnl` | 500 when `ready`; 200 otherwise
    | `GET /v3/wallets/svm/{address}/pnl` | 500 when `ready`; 200 otherwise |

    | `POST /v2/pump/user/wallets/pnl` | per wallet: 500 when `ready`, 200
    otherwise | `POST /v3/wallets/pnl` | per wallet: 500 when `ready`, 200
    otherwise |

    | `GET /v2/user/handle/{handle}/holdings`, `POST /v2/user/handles/holdings`
    | 2,500; 1,250 per handle; allow-listed keys only | allow-listed keys only;
    v3 keeps its own list | 2,500; 1,250 per trader |

    | `GET /v2/pump/user/wallet/{wallet}/holdings`, `POST
    /v2/pump/user/wallets/holdings` | 500 per wallet; allow-listed keys only |
    not in v3 yet |  |

    | `GET /v2/thesis` | 200 per started 20 rows of `limit` | `GET /v3/posts` |
    200 per started 20 rows of `limit` |

    | `GET /v2/thesis/token/{tokenAddress}` | 200 per started 20 rows of `limit`
    | `GET /v3/tokens/{network}/{address}/posts` | 200 per started 20 rows of
    `limit` |

    | `GET /v2/thesis/user/{id}`, `GET
    /v2/thesis/user/{id}/token/{tokenAddress}` | 200 per started 20 rows of
    `limit` | `GET /v3/traders/{traderId}/posts`, with `token=tok_…` for one
    token | 200 per started 20 rows of `limit`; 200 for an unknown trader |

    | `GET /v2/pump/thesis` | 10 for a page of 25 | `GET
    /v3/posts?platform=pump` | 10 for a page of up to 100 |

    | `GET /v2/pump/thesis/token/{mint}` | 10 for a page of 25 | `GET
    /v3/tokens/solana/{address}/posts?platform=pump` | 10 for a page of up to
    100 |

    | `GET /v2/pump/thesis/wallet/{wallet}` | 10 for a page of 25 | `GET
    /v3/wallets/{family}/{address}` once, then `GET
    /v3/traders/{traderId}/posts?platform=pump` | the wallet lookup once, then
    10 for a page of up to 100 |

    | `GET /v2/token/{address}` | 100; 200 not found | `GET
    /v3/tokens/{network}/{address}` | 100; 100 not found |

    | `GET /v2/leaderboard/traders`, `GET /v2/leaderboard/clans` | 500 | `GET
    /v3/leaderboards/fomo/traders`, `GET /v3/leaderboards/fomo/clans` | 500; 200
    without a capture |

    | `GET /v2/leaderboard/tokens/most-held`, `GET
    /v2/leaderboard/tokens/trending`, `GET /v2/leaderboard/tokens/graduated` |
    10 | `GET /v3/leaderboards/fomo/tokens_most_held`, `GET
    /v3/leaderboards/fomo/tokens_trending`, `GET
    /v3/leaderboards/fomo/tokens_graduated` | 10 |

    | `GET /v2/pump/leaderboard/traders` | 10 | `GET
    /v3/leaderboards/pump/traders` | 10 |

    | `GET /v2/leaderboard/traders-fomoscan` | 500 | `GET
    /v3/rankings/traders_net_flow_<window>` | 500; 200 without a snapshot |

    | `GET /v2/me` | free | `GET /v3/me` | free |

    | `GET /v2/ws`, `GET /v2/ws/pump` | 20,000 per minute per socket |
    `wss://stream.fomoscan.sh/v3` | 20,000 per started minute per connection |

    | `POST /v2/user/handle/{handle}/reveal`, `GET /v2/stats/…` | used by the v2
    MCP server | not in v3 yet |  |

    | `GET /feed/trades` | not metered | `GET /v3/trades` | 200 per started 20
    rows of `limit`, plus 100 and a reveal per trader the page shows |

    | `GET /feed/trader/{handle}` | not metered | `GET
    /v3/traders/lookup?handle=`, then `GET /v3/traders/{traderId}`, `GET
    /v3/traders/{traderId}/wallets` and `GET /v3/traders/{traderId}/trades` |
    each at its own price |

    | `GET /get-followers/{handle}`, `GET /get-following/{handle}` | not metered
    | `GET /v3/traders/{traderId}/followers`, `GET
    /v3/traders/{traderId}/following` | 200 per started 20 rows of `limit` |

    | `GET /get-user/{handle}`, `GET /get-user-by-id/{id}`, `GET /search` | not
    metered | `GET /v3/traders/lookup?handle=` for a whole handle; nothing for
    an id or a prefix | 100; 200 with no match |


    New in v3, with no v2 route:


    - `GET /v3/traders/{traderId}`: 100; 200 for an unknown id.

    - `GET /v3/leaderboards`: free.

    - `GET /v3/rankings`: free.

    - `GET /v3/capabilities`: free.

    - `GET /v3/traders/{traderId}/trades`: 200 per started 20 rows of `limit`,
    plus 100 and a reveal per trader the page shows.

    - `GET /v3/wallets/{family}/{address}/trades`: 200 per started 20 rows of
    `limit`.


    ### Ids and lookups


    - v3 never returns a platform's own id, such as v2's `id` and `authorId`.
    Every object carries our id, in the forms the Ids section at the top lists.

    - v3 has no lookup by a v2 `id`. Find each trader once by handle (`GET
    /v3/traders/lookup`) or by wallet (`GET /v3/wallets/{family}/{address}`),
    store the `tdr_` id, and call by it from then on:


    ```bash

    curl -s -H "Authorization: Bearer $KEY"
    "https://api.fomoscan.sh/v3/traders/lookup?handle=tradername"

    curl -s -H "Authorization: Bearer $KEY"
    "https://api.fomoscan.sh/v3/traders/tdr_01jbst8pvcfp79y0938nkrkayd/wallets"

    ```


    ```json

    { "items": [ { "kind": "trader", "id": "tdr_01jbst8pvcfp79y0938nkrkayd",
    "canonical": { … }, "sources": { … } } ], "hasMore": false, "nextCursor":
    null }

    ```


    - A handle can match traders on several platforms. The lookup lists current
    handles first, then handles an account held in the last 30 days; `platform=`
    narrows it to one platform.

    - A trader merged into another answers 200 with the trader that absorbed it,
    and `canonical.mergedFrom` lists the old id. Store the id you get back.

    - Wallet paths name the address family: `/v2/user/wallet/{address}` is
    `/v3/wallets/svm/{address}` or `/v3/wallets/evm/{address}`. EVM addresses
    come back lowercase.

    - Token paths and ids name the network: `/v3/tokens/{network}/{address}`,
    `tok_<network>_<address>`, with `network` one of `solana`, `ethereum`,
    `base`, `bsc`, `arbitrum`, `optimism`, `polygon`, `robinhood`, `hyperevm`,
    `monad`, `arc`, `xlayer`, `ink` or `hyperliquid`. v2 inferred the chain from
    the address.


    ### Answers


    - Every object is an envelope (`kind`, `id`, `canonical`, `sources`); the
    Objects section at the top explains it. PnL, boards, rankings and the key
    are documents that hold envelopes.

    - Money: v2 sent USD as JSON numbers (`"netUsd": 1430.12`). v3 sends an
    `Amount`: `{ "value": "1430120000", "decimals": 6, "currency": "USD" }`.
    Read `value` as a big integer or decimal and shift it by `decimals`; a float
    loses digits. Token prices carry 18 decimals.

    - Time: v2 sent and took epoch milliseconds (`fomoCreatedAt`, `updatedAt`,
    `capturedAt`, `?at=`). v3 sends RFC3339 in UTC with milliseconds
    (`2026-11-02T09:30:00.000Z`) and takes RFC3339 in `at`; epoch milliseconds
    in `at` answer 400.

    - Nulls: v2 used `null` for a missing value. v3 tells four cases apart: no
    `sources.<platform>` key (no side on that platform), no block (the platform
    cannot provide it, or we hold no reading), a `null` field (empty when
    observed), and `{ "status": "unavailable", "retryable": true }` in place of
    a block (we could not read it for this call).

    - Paging: v2 pages took `before=<id of the last item>` and answered `count`,
    `hasMore`, `nextBefore`. v3 lists answer `{ "items", "hasMore", "nextCursor"
    }`; send `nextCursor` back as `cursor`. A cursor works only with the
    parameters that produced it. Post feeds keep `limit` from 1 to 100, 20 by
    default.

    - v2 ignored a query parameter it did not know. v3 answers 400
    `INVALID_ARGUMENT`. `HEAD` answers 404.


    ### Errors


    v2: `{ "error": { "code", "message", "retryAfter" } }`. v3: `{ "error": {
    "type", "code", "message", "requestId" } }`, with `X-Request-Id` on every
    response and `Retry-After` on 429 and 503.


    | v2 | v3 |

    | --- | --- |

    | 400 `VALIDATION_ERROR` | `INVALID_ARGUMENT` |

    | 401 `UNAUTHORIZED` | `UNAUTHENTICATED` |

    | 402 `QUOTA_EXCEEDED`, `REVEAL_LIMIT_REACHED` | the same codes |

    | 403 `FORBIDDEN_SCOPE`, `FREE_TIER_DISABLED`, `SAMPLE_SET_ONLY`,
    `FORBIDDEN` | `PERMISSION_DENIED` |

    | 403 `WEBSOCKET_PLAN_REQUIRED` | `PLAN_REQUIRED` |

    | 404 `NOT_FOUND` | `NOT_FOUND` |

    | 429 `RATE_LIMITED`, `retryAfter` in the body | `RATE_LIMITED`,
    `Retry-After` header |

    | 500 `INTERNAL_ERROR` | `INTERNAL` |

    | 503 `OVERLOADED`, `B2B_DISABLED`, `DATA_UNAVAILABLE`; 504 `TIMEOUT` | 503
    `OVERLOADED`, `Retry-After` header |

    | 202 `{ "pending": true }` from resolve | none |


    ### Fields


    #### User record


    - `id` (the platform user id): none. Use the trader `id` (`tdr_`).

    - `handle`, `name`, `bio`, `profilePicture`: `handle`, `name`, `bio`,
    `avatarUrl` of the `profile` block in `sources.<platform>`.
    `canonical.display` repeats the newest of them.

    - `twitter` (a URL): `profile.xHandle`, the handle without `@`.

    - `followers`, `following`: the `followGraph` block.

    - `solanaAddress`, `evmAddress`: `GET /v3/traders/{traderId}/wallets`,
    wallet envelopes with `canonical.family`, `canonical.address` and the `tier`
    of each owner.

    - Handle suggestions: a missed lookup answers `suggestions`; `match=closest`
    answers the closest trader. Prefixes match through suggestions; there is no
    `/search`.

    - `walletAvailable`: `canonical.walletAvailable` on `GET
    /v3/traders/{traderId}` and `GET /v3/traders/lookup`, at no extra price and
    without a reveal.

    - `banner`, `isPumpUser`, `groupBadges`: none.

    - Wallet lookup: v2 answered one user record with the wallet on the other
    chain. `GET /v3/wallets/{family}/{address}` answers the wallet with every
    owner and a `tier` each (`proven`, `account`, `partial`, `weak`,
    `unverified`); `GET /v3/traders/{traderId}` reads an owner.


    #### PnL


    - The path takes the `tdr_` id in place of the handle. The batch body takes
    `traderIds` in place of `handles` and answers `{ "items": [ { "trader",
    "found", "pnl" } ] }` in request order, in place of `count`, `missing` and
    `entries`.

    - `status` `no-wallet` is `no_wallet`.

    - `wallet`, `evmWallet`: `wallets`, a list of `wal_` ids, filled only when
    `status` is `ready`.

    - `handle`, `displayName`, `avatarUrl`, `twitter`, `followers`: none. Read
    the trader.

    - In each window: `netUsd` is `realized`, `volumeUsd` is `volume`,
    `buyVolumeUsd` and `sellVolumeUsd` are `buyVolume` and `sellVolume`,
    `trades` is `tradeCount`, `updatedAt` is `asOf`, and `rank` is `{
    "position", "of", "ranking" }`. `origin` says where the figures come from:
    `provider` on `24h`, `7d` and `30d`, `computed` on `all`.

    - `unrealized.unrealizedPnlUsd` and `holdingsUsd` are `unrealized.value` and
    `holdingsValue`.

    - Per window, `unrealizedPnlUsd` and `totalPnlUsd` are `unrealized` and
    `total`; `approximate`, `stale` and `nextUpdateAt` keep their v2 names.

    - Wallet PnL (`GET /v3/wallets/{family}/{address}/pnl`) lists the paired
    wallet of the other family in `wallets`; `boards` replaces `pumpBoard`.
    `POST /v3/wallets/pnl` takes up to 100 wallets.

    - `all` holds the net flow and rank we compute from our trades. v2 mixed
    cash flow with whole-wallet figures since 1 January 2025.

    - `window` (new) narrows `windows` to one key at the same price.


    #### Posts


    Theses, callouts and stream items are one kind, `post`:


    - `thesis`: `canonical.body`. `segments`: `canonical.segments`, each `{
    "text", "url" }`.

    - `tokenAddress` and `tokenNetwork`, callout `coinMint` and `chain`:
    `canonical.token.id` (`tok_<network>_<address>`). `tokenSymbol`,
    `coinSymbol`: `canonical.token.symbol`.

    - `authorId` and `authorHandle`, callout `wallet` and `userName`:
    `canonical.author.trader` and `canonical.author.handle`.

    - `fomoCreatedAt`, callout `createdAt`: `canonical.postedAt`. `firstSeenAt`
    (new) is when we first read the post.

    - `likeCount`, `likes`: `canonical.likeCount`. `commentCount`, `replyCount`,
    `viewCount`: the `engagement` block.

    - `holdingsUsd`, `tokenAmount`, `authorTradeUsd`, `realizedPnlUsd`,
    `unrealizedPnlUsd`, `pnl`, `closedAt`, callout `position`: the `position`
    block (`value`, `quantity`, `invested`, `realizedPnl`, `unrealizedPnl`,
    `totalPnl`, `closed`, `closedAt`).

    - `calledOutAtMcap`, `multiple`, `maxMultiplier`, `mediaUrl`: the
    `callPerformance` block (`marketCapAtCall`, `multiple`, `maxMultiple`,
    `mediaUrl`).

    - Thesis `priceUsdAtPost` and `marketCapAtPost`:
    `callPerformance.priceAtCall` and `callPerformance.marketCapAtCall`.

    - `thesisHash`: none. Key on `id`.


    #### Tokens


    - `GET /v2/token/{address}`: `GET /v3/tokens/{network}/{address}`. The
    `market` block per platform carries `price`, `marketCap`, `liquidity`,
    `volume24h`, `holders` and `priceChange24h` (percent). A token without
    platform market data is read live, as on v2, into `canonical.market`.


    #### Boards and rankings


    - `/v2/leaderboard/<board>` and `/v2/pump/leaderboard/traders` are
    `/v3/leaderboards/{platform}/{board}`; `GET /v3/leaderboards` lists every
    board with its windows.

    - `window` stays. `period` `daily`, `weekly`, `monthly` is `window` `24h`,
    `7d`, `30d`. `at` takes RFC3339 in place of epoch milliseconds.

    - `count` is `entryCount`. An entry is `{ "rank", "subject", "measures" }`:
    `subject` is the trader, token or group envelope in place of `id`, `handle`,
    `label` and `avatarUrl`. `measures` holds the figures under snake-case keys,
    money as amounts: `marketCap` is `market_cap`, `numTrades` is `trade_count`,
    `memberCount` is `members`.

    - `/v2/leaderboard/traders-fomoscan?window=&order=` is
    `/v3/rankings/traders_net_flow_<window>?order=`. Tied traders share a rank,
    and `order=worst` answers the bottom entries with their own ranks, where v2
    numbered them from 1.


    #### Account


    - `GET /v3/me` moves `entitlement` and `usage` into `api`: `monthlyUnits`,
    `additionalUnits`, `unitsUsed`, `unitsRemaining`, `ratePerMinute`,
    `monthlyReveals`, `revealsUsed`, `revealsRemaining`, `unmetered`. Stream
    minutes count in `api.unitsUsed`.


    ### Limits and refusals


    |  | v2 | v3 |

    | --- | --- | --- |

    | Per key | `ratePerMinute`, a fixed minute | the same `ratePerMinute` |

    | Per address, before the key | 2,400 per minute | 2,400 per minute |

    | Per address and route, per minute | 2,000 by default; 600 on boards; 120
    on `/v2/me` and batches; 10 on resolve | 2,000 by default; 120 on `POST
    /v3/traders/pnl`, `GET /v3/me` and `POST /v3/wallets/pnl`; 600 on `GET
    /v3/leaderboards/{platform}/{board}` and `GET /v3/rankings/{key}` |

    | Headers | `x-ratelimit-*` for the route limit | `RateLimit-Limit`,
    `RateLimit-Remaining`, `RateLimit-Reset` for the key |

    | 429 | `retryAfter` in the body | `Retry-After` header |

    | Load | 503 `OVERLOADED` | 503 `OVERLOADED` with `Retry-After: 1` past 30
    requests in flight on one API instance, before the key is checked |

    | Requests in flight per key | none | 429 `RATE_LIMITED` with `Retry-After:
    1` past 15 requests in flight for one key and its child keys on one API
    instance |


    - A 429 or a 503 costs nothing. Retry after the seconds in `Retry-After`.

    - A reveal that waits more than 2 seconds on the reveal lock of the key
    answers 429 with `Retry-After: 1`, as on v2.


    ### Stream


    |  | v2 | v3 |

    | --- | --- | --- |

    | URL | `wss://api.fomoscan.sh/v2/ws` (theses),
    `wss://api.fomoscan.sh/v2/ws/pump` (callouts) |
    `wss://stream.fomoscan.sh/v3`, every platform on one socket |

    | Key | `Authorization: Bearer` or `X-Api-Key` on the upgrade | the same; a
    refusal is an HTTP status: 401, 402, 403 `PERMISSION_DENIED` (scope), 403
    `PLAN_REQUIRED`, 429 |

    | Subscribe | `{ "type": "subscribe", "subscription": { "type": "all" } }` |
    `{ "type": "subscribe", "topic": "posts" }` |

    | Tokens | `"subscription": { "type": "tokens", "tokens": ["<address>"] }`,
    up to 50 | `"tokens": ["tok_solana_<mint>"]`, up to 50 |

    | Platforms | one socket per feed | `"platforms": [ … ]`, every platform
    when left out |

    | Acknowledgement | `replaced`, then `subscription` | one `subscribed` frame
    that carries `subscription` and `replaced` |

    | Items | `thesis` with `data`, `pump_callout` with `callout` | `post` with
    `cursor` and `item`, the envelope `GET /v3/posts` returns |

    | Errors | `error` with a string | `error` with `{ "type", "code",
    "message", "requestId" }` |

    | Replay | none | `"since": "s_…"` replays the last 5 minutes, then
    `replay_done` |

    | Heartbeat | ping every 30 s, close 1011 after 90 s of silence | the same |

    | Subscribe rate | no limit | 10 `subscribe` or `unsubscribe` frames per 60
    s |

    | Price | 20,000 credits per minute, per socket | 20,000 credits per started
    minute per connection, charged on connect and every 60 s, subscribed or not
    |


    ```json

    {"type":"subscribe","topic":"posts","platforms":["fomo","pump"],"tokens":["tok_solana_<mint>"],"since":"s_…"}

    ```


    - Close codes: 1001 we restart; 1008 no credit for the next minute, or the
    key lost access; 1009 a frame over 64 KB; 1011 no pong, or billing failed
    twice in a row; 1013 8 MB of frames wait unread, or frames arrive faster
    than we handle them.

    - One socket carries every platform, so a client of both v2 sockets pays one
    minute where v2 charged two.

    - A v2 frame sent to v3 answers an `INVALID_ARGUMENT` error frame and
    changes nothing.

    - v3 sends each post once, when it first becomes visible to us. `postedAt`
    gives its age.

    - Reconnect with `since` set to the last `cursor` you handled. The WebSocket
    section at the end of this page holds every frame, error code and a
    reconnect recipe.


    ### Not in v3 yet


    - Lookup by a v2 `id`: `GET /v2/user/id/{id}`, `GET
    /v2/user/id/{id}/profile`, and author feeds keyed on that id.

    - Reads on demand: v2 fetched the theses of a token it had not read during
    the call. `GET /v3/tokens/{network}/{address}/posts` answers an empty page.

    - Author name, author avatar and token image on each post (`authorName`,
    `authorAvatarUrl`, `tokenImageUrl`, callout `profileImage`). Read the author
    and the token by id.

    - Percentages and cost basis of the author's position:
    `percentageRealizedPnl`, `percentageUnrealizedPnl`, callout `pnlPercentage`,
    `avgEntryPriceUsd`, `avgEntryMcapUsd`, `costBasisAmount`, `amountBought`.

    - Wallet holdings with cost basis from the buy history: `GET
    /v2/pump/user/wallet/{wallet}/holdings`, `POST
    /v2/pump/user/wallets/holdings`.

    - Live resolve: `POST /v2/user/handle/{handle}/resolve` and its 202. v3
    answers from stored data.

    - The stats and open holdings of the `GET /feed/trader/{handle}` card.

    - MCP: the v2 MCP server, its `GET /v2/stats/…` reads and `POST
    /v2/user/handle/{handle}/reveal`.
servers:
  - url: https://api.fomoscan.sh
    description: The API host
security:
  - bearerAuth: []
  - apiKeyHeader: []
tags:
  - name: Traders
  - name: Wallets
  - name: Posts
  - name: Tokens
  - name: Leaderboards
  - name: Rankings
  - name: Account
  - name: Trades
  - name: WebSocket
    description: >-
      The live stream: one socket for every platform, platform and token
      filters, and a replay of the last 5 minutes.
paths:
  /v3:
    servers:
      - url: wss://stream.fomoscan.sh
        description: The stream host
    get:
      tags:
        - WebSocket
      summary: Live posts (WebSocket)
      description: >-
        One WebSocket pushes every new post as we publish it, from every
        platform, narrowed to the platforms and tokens you choose. A `post`
        frame carries the envelope `GET /v3/posts` returns.


        This path lives on the stream host and answers only a WebSocket upgrade,
        so "Try it out" cannot open it. A plain `GET` gets `404`.


        ## Connect


        URL: `wss://stream.fomoscan.sh/v3`


        Send the key on the upgrade request in one of the two headers every REST
        call takes:


        - `Authorization: Bearer <key>`

        - `X-Api-Key: <key>`


        The URL takes no key parameter. A browser `WebSocket` cannot set
        headers, so hold the socket on your server and relay frames to your
        front end. Never ship the key to a browser.


        ```sh

        npx wscat -c 'wss://stream.fomoscan.sh/v3' -H "Authorization: Bearer
        $FOMOSCAN_KEY"

        ```


        ```js

        import WebSocket from 'ws';


        const socket = new WebSocket('wss://stream.fomoscan.sh/v3', {
          headers: { Authorization: `Bearer ${apiKey}` },
        });

        socket.on('message', (data) => {
          const frame = JSON.parse(data.toString());
          if (frame.type === 'ready') socket.send(JSON.stringify({ type: 'subscribe', topic: 'posts' }));
          if (frame.type === 'post') handle(frame.item, frame.cursor);
        });

        ```


        Frames you send before `ready` wait in a queue of 32 frames or 64 KB and
        run after it.


        ## 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 Responses
        below.


        | 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`.


        ## Price


        Each open connection costs **20,000 credits per started minute**, taken
        from the same credit balance as REST calls.


        - The stream charges the first minute on attach, before the upgrade
        completes, and one more every 60 seconds while the socket is open. A
        socket without a subscription pays the same.

        - The price is per socket: one token, fifty tokens or every post cost
        the same. One socket carries every platform; each socket you open is
        charged on its own.

        - An upgrade needs 20,000 credits left, or it gets `402 QUOTA_EXCEEDED`.
        A key with too few credits for the next minute gets an error frame with
        `QUOTA_EXCEEDED`, and the socket closes with `1008`.

        - An unmetered key is counted and never refused. A child key pays from
        its parent's balance, as on REST.

        - `GET /v3/me` shows the balance: socket minutes count in
        `api.unitsUsed`, and `websocket.minutesUsed` is `null`. Your usage lists
        each minute under `GET /v3`.


        ## Client frames


        Send JSON text frames. The stream takes `subscribe` and `unsubscribe`;
        schema `StreamClientFrame`.


        ### subscribe


        ```json

        {
          "type": "subscribe",
          "topic": "posts",
          "platforms": [
            "fomo",
            "pump"
          ],
          "tokens": [
            "tok_solana_EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
            "tok_base_0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
          ],
          "since": "s_eyJzZXEiOjEwNDIxN30"
        }

        ```


        | Field | Rule |

        | --- | --- |

        | `topic` | Required: `posts`. |

        | `platforms` | Optional. One or more of `fomo`, `pump`, `hyperliquid`,
        `markets`, `polymarket`, `legend`, `risex`; posts come from `fomo` and
        `pump` today. Omit the field for every platform. An empty list, `null`
        or an unknown name is rejected. |

        | `tokens` | Optional. 1 to 50 token ids, `tok_<network>_<address>`.
        Omit the field for every token. A post without a token never matches a
        token filter. An empty list, `null` or a value that is not a token id is
        rejected. |

        | `since` | Optional. The `cursor` of a `post` or `replay_done` frame
        you received. The stream replays what you missed first; see Replay. |


        - A post must match both filters.

        - A connection holds one subscription. A new `subscribe` replaces it in
        full, so send the whole token list each time.

        - Nothing arrives before the first accepted `subscribe`.

        - A field the frame type does not take is rejected.

        - A rejected frame changes nothing and keeps the socket open.


        ### unsubscribe


        ```json

        {
          "type": "unsubscribe"
        }

        ```


        Clears the subscription. The socket stays open and keeps being charged.


        ## 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

        {
          "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

        {
          "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

        {
          "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

        { "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

        {
          "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

        {
          "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 above; `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`.
      operationId: stream
      responses:
        '101':
          description: >-
            Switching Protocols. The socket is open, the first minute is
            charged, and `ready` follows.
        '400':
          description: >-
            `INVALID_ARGUMENT`: The WebSocket handshake is not valid:
            `Sec-WebSocket-Key`, `Sec-WebSocket-Version` or
            `Sec-WebSocket-Protocol`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '401':
          description: >-
            `UNAUTHENTICATED`: No key, or the key is unknown, revoked or
            expired.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '402':
          description: '`QUOTA_EXCEEDED`: The balance cannot pay the first minute.'
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '403':
          description: >-
            `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.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '404':
          description: >-
            `NOT_FOUND`: Another path, or a request without the WebSocket
            upgrade headers.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '429':
          description: >-
            `RATE_LIMITED`: Too many requests from the key or the address. Retry
            after `Retry-After` seconds.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '500':
          description: '`INTERNAL`: The handshake failed on our side.'
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
        '503':
          description: >-
            `OVERLOADED`: The stream is restarting, or could not charge the
            first minute. Retry after `Retry-After` seconds.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
components:
  headers:
    X-Request-Id:
      description: The id of this request, as in our logs.
      schema:
        type: string
  schemas:
    ErrorBody:
      type: object
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              enum:
                - invalid_request
                - authentication
                - quota
                - permission
                - rate_limit
                - internal
                - overloaded
              description: The group of the error. Each `code` belongs to one type.
            code:
              type: string
              enum:
                - INVALID_ARGUMENT
                - UNAUTHENTICATED
                - QUOTA_EXCEEDED
                - REVEAL_LIMIT_REACHED
                - PERMISSION_DENIED
                - PLAN_REQUIRED
                - NOT_FOUND
                - RATE_LIMITED
                - INTERNAL
                - OVERLOADED
              description: What went wrong, from a closed list. Act on this field.
            message:
              type: string
              description: A sentence for people. It can change; never parse it.
            requestId:
              type: string
              description: >-
                The id of the request, equal to `X-Request-Id`. Quote it when
                you write to us.
          required:
            - type
            - code
            - message
            - requestId
          description: The error.
      required:
        - error
      description: The body of every error answer.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key

````

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