The line
The line is one WebSocket. Everything your wisp hears arrives on it, and everything it does is a call on it. It is the same protocol the Woosh apps speak (version 2), with a few additions for wisps.
The token
Section titled “The token”Your wisp’s id and secret buy a token with OAuth2’s client credentials request, which every OAuth2 library already makes. The body is a form:
POST /api/oauth/tokenContent-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19&client_secret=wisp_…The id and secret may go in an HTTP Basic header instead (id:secret in base64, each
form-encoded first, as OAuth2 has it); the body then holds grant_type alone.
{ "access_token": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1laWQiOiJkMmI2ZjRhOCIsIndpc3AiOiIxIn0.c2ln", "token_type": "Bearer", "expires_in": 3600, "line": "wss://api.woosh.no/ws"}| Key | |
|---|---|
access_token |
the token: for your hello, and for uploading pictures (Pictures). It reaches nothing else |
token_type |
Bearer |
expires_in |
seconds until it runs out: 3600, an hour |
line |
the address of the line to open |
When Woosh says no:
| Status | Body | Means |
|---|---|---|
| 400 | {"error":"unsupported_grant_type"} |
grant_type is not client_credentials |
| 401 | {"error":"invalid_client"} |
the id and secret are not a wisp’s |
| 415 | the body is not a form | |
| 429 | more than 120 token requests in a minute from one address |
The token is read once, when a line opens, so a line outlives its token. Buy a new one before
you open a line if the last is near its expires_in. Once the secret is reset, the tokens it
bought open no line (4403, below).
Opening it
Section titled “Opening it”- Buy a token (above). Its answer names
line, the address to open. - Open a WebSocket at
linewith?delivery=seenon it. Always the address the answer names: Woosh may serve wisp lines elsewhere, and a wisp that keeps tolinenever notices. Without?delivery=seenthe upgrade answers 400. A 503 means Woosh cannot take the line there just now: back off and try again. - Send
hellowithin 10 seconds. With no hello by then, or a first message that is not one, the socket is dropped. - Everything you missed is replayed, then
readyarrives. From then on, events come live.
{"t":"hello","v":2,"r":{"61":113},"e":"4b9d0c1e7a2f43e8b5c6d7e8f9a0b1c2","a":"eyJhbGciOiJIUzI1NiJ9.eyJuYW1laWQiOiJkMmI2ZjRhOCIsIndpc3AiOiIxIn0.c2ln","f":1}| Key | ||
|---|---|---|
t |
"hello" |
|
v |
2 |
the version. Any other, or none, is closed with 4426 |
r |
{group: number} | your cursor, only when you are back within the replay window (below). Left out on a first connect |
e |
text | the epoch your cursor was taken in, from the last ready. Only with r |
a |
text | the token. Required. It never goes in the address |
f |
1 |
the feature level. Required |
slice |
number | the slice this line serves, a whole number from 0. Left out: 0 (Slices and scale) |
{ "t": "ready", "v": 2, "id": "w.Lk7Wd2rTq9Ys4XbN0vHc5E", "s": {"61":113}, "e": "4b9d0c1e7a2f43e8b5c6d7e8f9a0b1c2", "replay": 600, "silence": 30, "retry": {"cap":5,"later":60,"after":30}, "media": "https://…/woosh", "slices": 1, "budget": {"calls":100,"burst":500,"serverCalls":50,"channelLines":5,"edits":4}}| Key | ||
|---|---|---|
v |
2 |
the version served |
id |
text | this connection, w. and 22 characters, for your logs |
s |
{group: number} | where your group stands. Your cursor becomes exactly this |
g |
[group] | groups that could not be replayed: catch up with reads on the line (below). Left out when none |
e |
text | the epoch. Keep it with your cursor. It changes only when Woosh’s numbering starts over |
replay |
seconds | how long you may be away and still resume: 600 for a wisp |
silence |
seconds | no frame at all for this long (pings included) and the line is dead: close it and come back |
retry |
{cap, later, after} | your backoff ceiling after a drop, in seconds: cap for the first after seconds, then later |
media |
URL | the base that avatar keys are built on, and that your card pictures’ addresses start with (Pictures) |
gateway |
URL | where to open your next lines, when Woosh names a place: an https address, opened as wss with ?delivery=seen. Follow it only under the domain of line, since your token goes there. Left out: line. If a line there never opens, go back to line |
slices |
number | how many slices your wisp has |
budget |
object | how fast you may act (Budgets and limits) |
Your wisp’s line hears one group, the one for its slice, which shows up in s as a number (61
in these examples). You never follow or leave groups yourself.
Frames
Section titled “Frames”Each WebSocket text message from Woosh is one JSON object, or several joined by \n when the
line has fallen behind. Split on \n and read each.
| Frame | Example | |
|---|---|---|
| numbered event | {"c":61,"n":109,"t":"command","d":{…}} |
c is the group, n its number: your cursor |
| unnumbered event | {"t":…,"d":{…}} |
never logged, never replayed |
| answer | {"r":7} or {"r":7,"v":…} |
to your call 7 |
| error | {"r":7,"e":"refused","w":"Not granted"} |
a code and words for your log |
| limited | {"r":13,"e":"limited","w":"Past the channel's lines or the server's calls a second","wait":200} |
try the same call again after wait ms |
| ping | {"t":"ping"} |
sent after 15 seconds with nothing else to say |
| move | {"t":"move"} |
open a line beside this one (below) |
Woosh also sends WebSocket pings, about every 15 seconds. WebSocket libraries answer them on their own; one left unanswered for 15 seconds ends the line.
And from you, one frame to a text message, at most 64 KB:
| Frame | Example | |
|---|---|---|
| hello | {"t":"hello","v":2,"a":"…","f":1} |
the first message |
| call | {"i":7,"t":"send","d":{…}} |
i is your own number for it, answered by {"r":7,…} |
| moved | {"t":"moved"} |
the line beside this one is ready (below) |
Woosh reads only the first frame of a message, so never join two. A message that is not a frame
(a JSON object with a text t, and d an object and i a whole number where they are given) ends the line, and so does
one over 64 KB.
The values:
- Ids are lowercase GUIDs, 36 characters with hyphens.
- Times are whole microseconds since 1970, UTC:
at,edited,expires,joined. A JavaScript number holds them exactly;new Date(at / 1000)is the moment. - A key left out is its default: no value,
false, an empty list, an empty text, or 0. A flag istrueor absent. - Ignore what you do not know: an event, a frame or a key a newer Woosh adds. Take an error
code you do not know for
refused.
Calls and answers
Section titled “Calls and answers”A call is {"i":…,"t":…,"d":…}. i is your own number for it, a whole number from 1 to
2,147,483,647; d is always there, {} when empty. Its answer comes back with r equal to your i: an ack
({"r":7}), a value ({"r":7,"v":…}), or an error. A call sent without i still runs, and
nothing answers it.
e |
Means | What to do |
|---|---|---|
refused |
Woosh said no: not granted, not your line, too late, a payload it cannot read, a call it does not know. w says why, for your log |
Do not retry. Fix the call |
limited |
Past a budget. Nothing was done | Wait wait milliseconds, then send the same call again |
failed |
It may or may not have run: Woosh could not finish it, or no answer came within 10 seconds | Retry what is safe to repeat: a send under the same sendId never posts twice, and a respond sent again is told what the first made |
32 in flight
Section titled “32 in flight”A wisp’s line may have up to 32 calls waiting for their answers at once, and they are answered in any order. Past 32, Woosh stops reading your socket until an answer frees a place, so keep your own count:
const MOST = 32;let running = 0;const queued = []; // calls waiting for a place
async function place() { if (running < MOST) return void running++; await new Promise((go) => queued.push(go));}
function done() { const go = queued.shift(); if (go) go(); // hand the place on else running--;}Keeping one channel’s lines in order
Section titled “Keeping one channel’s lines in order”Two calls in flight may finish in either order, so two lines sent at once to one channel can land swapped. Chain each channel’s calls behind one another; calls to different channels never wait for each other:
const lanes = new Map(); // channel id -> the last call queued there
function inOrder(channel, work) { const before = lanes.get(channel) ?? Promise.resolve(); const mine = before.then(work, work); lanes.set(channel, mine.catch(() => {})); return mine;}
// inOrder(d.channel, () => call("send", { channel: d.channel, text: "Round two!", sendId }))limited, and waiting it out
Section titled “limited, and waiting it out”A call past a budget answers limited with wait, the milliseconds before the same call can
succeed. Nothing was done, so send it again after the wait:
async function call(t, d) { for (;;) { try { return await once(t, d); // one {i,t,d} out, its answer back } catch (err) { if (err.code !== "limited") throw err; await new Promise((r) => setTimeout(r, err.wait ?? 1000)); } }}Waiting is the fallback, not the plan. A wisp that paces itself from ready’s budget rarely
sees limited at all: Budgets and limits shows how.
Keeping your place
Section titled “Keeping your place”Your cursor is where your group stands: s and e from ready, moved on by each numbered
event you handle. Keep it, and keep when you last heard any frame:
const place = { epoch: null, heads: {}, heardAt: 0 };
function onFrame(frame) { place.heardAt = Date.now(); if (frame.t === "ready") { place.heads = { ...frame.s }; place.epoch = frame.e ?? null; return; } if (frame.c === undefined) return handle(frame); // unnumbered const head = place.heads[frame.c] ?? 0; if (frame.n <= head) return; // had it already if (head && frame.n > head + 1) catchUp(); // one went missing while live place.heads[frame.c] = frame.n; handle(frame);}Coming back (a drop, a restart, a deploy), put the cursor in your hello if your clock says at
most replay seconds (600) went by since the last frame you heard:
const resume = Date.now() - place.heardAt <= 600_000 && Object.keys(place.heads).length > 0;const hello = { t: "hello", v: 2, a: token, f: 1, ...(resume ? { r: place.heads, e: place.epoch } : {}) };Everything your wisp missed is replayed before ready, oldest first, so nothing is lost within
the 10 minutes. Save the cursor somewhere that survives a restart (a file, your database), and
a deploy of your wisp misses nothing either. Save it often, but not on every event: after a
crash, the events handled since the last save come again, so make handling one twice harmless
(Woosh never lets a run take two answers). A command or act replayed past its expires is
too late to answer; skip it.
Closes, and coming back
Section titled “Closes, and coming back”| Code | Reason | When | What to do |
|---|---|---|---|
| 4426 | version |
the hello’s v is not 2 |
stop: your code speaks a version Woosh does not |
| 4401 | unauthorized |
no token, or the token was refused (run out, or not one Woosh gave) | buy a new token and try again at once; a second 4401 in a row, back off |
| 4403 | revoked |
the wisp’s secret was reset: sent to every line the old secret’s tokens opened, and to a hello with one of those tokens | stop. Buy a token with the new secret |
| 4429 | retry=<seconds> |
too many lines: at most twice as many as your wisp has slices (retry=30) |
wait the seconds it names, give or take a fifth |
| 1013 | retry=<seconds> |
Woosh is letting many lines in at once; it names 1 to 30 seconds | wait that long, give or take a fifth |
| 1012 | restart |
Woosh handing lines back after a fault, or a move not done within 2 minutes |
come back at once, on your backoff |
| 1011 | connect failed |
the connect failed, or the hello named a slice that is not one of yours | back off and try again; check your slice |
| 1000 | moved |
the answer to your moved |
nothing: the line beside it is your line now |
Some ends come with no close code: no hello within 10 seconds, a message that is not a frame or is over 64 KB, a WebSocket ping left unanswered, or a line more than 8 MB behind on reading. Come back with your cursor.
Any other close, or a socket that drops without one: back off with full jitter, starting from
half a second and doubling up to retry.cap for the first retry.after seconds of the drop,
then up to retry.later, for as long as it takes.
Moving
Section titled “Moving”Woosh never closes a working line to deploy itself. It asks the line to move, and it does the same once a line is 5 to 6 days old:
- Your line hears
{"t":"move"}. It keeps working as before. - Open a second line beside it and say hello with your cursor, as a resume. Both deliver until the old one closes: handle an event from whichever brings it first, and drop anything at or under your cursor.
- At the new line’s
ready, send new calls on it. In the same epoch each cursor becomes the higher ofsand your own; in another epoch, exactlys. Then send{"t":"moved"}on the old line. - The old line answers the calls it still owed, then closes with 1000
moved. A call sent on it aftermovedis not run and gets no answer.
If the new line cannot open, keep trying on your backoff while the old line serves. Past 2
minutes from move, the old line is closed with 1012 and you come back as after any drop.
Hand-overs: deploying your wisp without a gap
Section titled “Hand-overs: deploying your wisp without a gap”The same dance, started by you. A wisp may hold twice as many lines as it has slices, so two for each. To deploy:
- Start the new process. It opens a line for the slice with the cursor the old one saved.
- Both lines hear the group until the old one closes.
- At the new line’s
ready, the old process sendsmovedon its line, finishes its answers, and exits.
Commands and presses are delivered to both lines meanwhile, and Woosh never lets a run take two
answers. Still, let one process answer (for example, the new one from its ready on), so the
two do not race. A line past twice your slices is closed with 4429, retry=30.
Catching up
Section titled “Catching up”Within 10 minutes the line brings back everything. Read what you need instead when you start
with no cursor, when you were away longer, or when ready lists your group in g: more than
16,384 events went by, the replay would be over 4 MB, or Woosh’s numbering started over (a new
epoch). Reads are calls on the line like everything else, each a page that spends one of the
server’s calls:
| Call | d |
Answer v |
Needs |
|---|---|---|---|
history |
channel, before? or after? (a time), limit? (1 to 100) |
the channel’s lines, newest first, each as your wisp hears message. Thread replies are not among them |
messages, in a channel your wisp may see |
members |
server, offset? (0 or more), limit? (1 to 1,000) |
the members in the order the apps draw them, each as your wisp hears member |
members |
member |
server, user |
the member, as your wisp hears member |
members |
Left out, limit is the most. A read your wisp’s grant does not cover answers refused, “Not
granted”. A tally has reads of its own, tally and draw, on Presses and private
cards.
{"i":94,"t":"history","d":{"channel":"6d4c2b3e-9f5a-4b7c-8d6e-1f2a3b4c5d66","after":1791914000000000}}{"r":94,"v":[{"server":"5c3b1a2d-8e4f-4a6b-9c7d-0e1f2a3b4c55","id":"0199de8f-6c10-7a1b-8c3d-4e5f6a7b8c01","channel":"6d4c2b3e-9f5a-4b7c-8d6e-1f2a3b4c5d66","author":"3f1c9a52-6d1e-4c8b-9a51-2b7e0c4d8f11","name":"ana","avatar":"639201477315520417","text":"Bora jogar às 21h? :wave:","at":1791914412304271}]}With after, the answer is the lines right after that moment, newest first: ask again from the
newest one’s at until a page comes back short. With before, it is the lines right before
it; with neither, the channel’s newest. members pages by offset: add each page’s length
until a page comes back short. member for someone not in the server answers refused, “Not a
member of the server.” No read carries anyone’s presence or activity, which a wisp never hears.
No read lists the servers your wisp is in, or a server’s channels, roles and emoji, yet. Your
wisp learns them from what it hears (server, channel, layout, roles, emoji and their
.gone), so keep that map, and save it with your cursor. A first connect replays nothing, so a
server your wisp was added to before its first line opened shows up with the next event your
wisp hears there.
Your state is then as it stands, and the ready you just had says where the line goes on
from: nothing is missed between the two. A line or a member can reach you both ways, read and
heard, so take each by its id.
© 2026 Woosh AS. All rights reserved.