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.
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 atype; 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.
subscribed
After every acceptedsubscribe. 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.
unsubscribed
After everyunsubscribe. replaced is the subscription it cleared, or null.
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.
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.
error
A frame the stream rejected, or the moment before the stream closes the socket. Act oncode; message is for people. requestId is the connection id.
Error frames
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
Close codes
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.
