curl --request GET \
--url wss://stream.fomoscan.sh/v3 \
--header 'Authorization: Bearer <token>'const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
fetch('wss://stream.fomoscan.sh/v3', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "wss://stream.fomoscan.sh/v3"
headers = {"Authorization": "Bearer <token>"}
response = requests.get(url, headers=headers)
print(response.text){
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}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.
npx wscat -c 'wss://stream.fomoscan.sh/v3' -H "Authorization: Bearer $FOMOSCAN_KEY"
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 withQUOTA_EXCEEDED, and the socket closes with1008. - An unmetered key is counted and never refused. A child key pays from its parent’s balance, as on REST.
GET /v3/meshows the balance: socket minutes count inapi.unitsUsed, andwebsocket.minutesUsedisnull. Your usage lists each minute underGET /v3.
Client frames
Send JSON text frames. The stream takes subscribe and unsubscribe; schema StreamClientFrame.
subscribe
{
"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
subscribereplaces 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
{
"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.
{
"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.
{
"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.
{
"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.
{ "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.
{
"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.
{
"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
postframe carries acursor(s_…). Store the last one you processed and send it back as it is; do not parse it. - A
subscribewithsincesends the posts published after that cursor which match the new filters, oldest first, thenreplay_done, then live posts. Live posts published while the replay loads wait and followreplay_done; a post in both arrives once. - The replay window is the last 5 minutes by publication time. A
sinceolder than that gets the error frameINVALID_ARGUMENT“The cursor is older than the replay window.”, thensubscribed, and noreplay_done: live posts flow, and you fill the gap fromGET /v3/posts, deduplicated onitem.id. - A
sincethat names no post getsINVALID_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 bypostedAtwhen you need the author time order. - On one connection a post arrives once. A
subscribewithoutsincecontinues after the newest post the connection already sent, so it repeats nothing. - A
subscribewithsincereplays 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.idwherever 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
wsclients 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
- Store the
cursorof each post once you have processed it. - On close, read the code.
1008: stop and fix the key or the balance.1009: fix the client. Anything else: reconnect. - Reconnect at once after
1001. Otherwise wait 1, 2, 4 and up to 30 seconds between attempts, with jitter. A refused handshake with429or503names the wait inRetry-After;401,402and403need a fix before a retry. - After
ready, send the samesubscribewithsinceset to the stored cursor. - Process the replayed posts, then
replay_done, then live posts. - When the replay window error arrives instead, fill the gap from
GET /v3/postsand deduplicate onitem.id.
curl --request GET \
--url wss://stream.fomoscan.sh/v3 \
--header 'Authorization: Bearer <token>'const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
fetch('wss://stream.fomoscan.sh/v3', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));import requests
url = "wss://stream.fomoscan.sh/v3"
headers = {"Authorization": "Bearer <token>"}
response = requests.get(url, headers=headers)
print(response.text){
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}{
"error": {
"type": "invalid_request",
"code": "INVALID_ARGUMENT",
"message": "<string>",
"requestId": "<string>"
}
}Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your auth token.
Response
Switching Protocols. The socket is open, the first minute is charged, and ready follows.
