Channel API
The Channel API is realtime pub/sub over WebSockets. Servers publish messages over HTTP; clients subscribe over a single WebSocket that can carry many channels. Clients authenticate with short-lived, channel-scoped tokens, so a browser subscribes without ever holding your API key. A channel with no open connections costs nothing.
How it fits together
- Your server mints a subscriber token for the channels a client may use (
POST /tokens). - The client opens a WebSocket with that token and receives messages (
GET /subscribe). - Your server (or a publish-capable client) publishes a message, which fans out to every open subscriber (
POST /publish).
Authentication and base URL
Send requests to https://api.altengine.net. The /tokens, /publish and /presence endpoints authenticate with an organization API key (bearer token); /tokens and /presence also accept an end user's id_token from an Auth instance (see Tokens for end users). /subscribe authenticates with a subscriber token instead. Every path is scoped to a channel instance you provision in the console: /v1/channel/{instance}/….
| Method & path | Auth | Purpose |
|---|---|---|
POST /v1/channel/{instance}/tokens | API key (read; write for publish tokens) or an end user's id_token | Mint a subscriber token. |
POST /v1/channel/{instance}/publish | API key (write) or a publish token | Fan a message out to a channel. |
GET /v1/channel/{instance}/subscribe | Subscriber token | Open a WebSocket to receive messages. |
GET /v1/channel/{instance}/presence | API key (read) or an end user's id_token | Read a channel's presence roster. |
Channel requests are rate-limited per instance, with separate budgets. HTTP publishes (and presence reads) get a sustained 300 per second (18,000 per minute) by default; token mints and new subscribe connections share a 200 per second (12,000 per minute) budget with a large burst to absorb reconnect storms. (WebSocket publishes are governed by their own per-connection cap instead.) Both are separate from the Search API's budget. You can set a lower limit per instance for each of these two budgets in the dashboard (Channels → your app → Rate limit); a configured value can only reduce the rate below the plan maximum, never raise it. Fan-out to subscribers is not rate-limited — only the publish call is — so a single publish reaching thousands of subscribers counts once. Over a limit, a request returns 429 with a RATE_LIMITED code; back off and retry.
Mint a subscriber token
Mint a token server-side and hand it to your client. A token declares the channels it may use and expires after ttl_seconds (default 3600). A value above 14400 is clamped to 14400, not rejected; zero, a negative number, or a non-number returns 400. Minting a subscribe-only token needs a read grant; minting a publish-capable token requires a write grant. The publish field controls which transport(s) the token may publish over:
publish | Token may publish |
|---|---|
false (or omitted) | Not at all — subscribe only. |
"http" | Over POST /publish only. Best for server-side publishing. |
"ws" | Over its WebSocket only (see Publish over WebSocket). Best for browser clients — it is rate-capped per connection. |
"all" | Both transports. |
true | Alias for "all". |
curl -X POST https://api.altengine.net/v1/channel/live/tokens \
-H "Authorization: Bearer $ALTENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "channels": ["room-42"], "ttl_seconds": 3600, "publish": false }'{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
"expires_at": 1752345600,
"channels": ["room-42"],
"publish": false,
"ws_endpoint": "wss://api.altengine.net/v1/channel/live/subscribe",
"protocols": ["altengine.v1", "altengine.token.eyJhbG…"],
"ws_url": "wss://api.altengine.net/v1/channel/live/subscribe?token=eyJhbG…"
}| Field | Meaning |
|---|---|
token | The subscriber token. Safe to hand to a browser. |
expires_at | Expiry, in seconds since the epoch. |
channels | The channels the token may use. |
publish | Where it may publish, or false. |
ws_endpoint | The WebSocket URL, without the token. |
protocols | The subprotocols to open it with; the second carries the token. See Token auth. |
ws_url | Deprecated. The WebSocket URL with the token in its query string. |
presence_id | The presence identity, when one was set. |
Pass presence_id (printable ASCII, up to 128 bytes) to name this subscriber on the channel's presence roster, for example "user:alice"; the response echoes it. Omitted or empty, the subscriber is anonymous.
Tokens for end users
A browser signed in to an Auth instance mints its own subscriber token: call POST /tokens with the user's id_token as the bearer token instead of an API key. The auth instance's access rules for this channel instance decide which channels it may ask for.
| Rule | Behavior |
|---|---|
| Channels | Required, and each must match the user's channel patterns. Otherwise 403 PERMISSION_DENIED. |
| Publish | Subscribe-only. Any publish value returns 403. |
| Presence identity | Always the user's uid; a presence_id in the request is ignored. |
| Expiry | expires_at is never later than the id_token's own expiry, whatever ttl_seconds asks for. |
| Revocation | Once the account is disabled, deleted, has its password reset, or is signed out everywhere (a function's env.auth.revokeSessions), /subscribe refuses the token with 401 (within about a minute). A socket already open stays open until expires_at. |
| Sockets | At most 20 per user per channel. See Subscribe over WebSocket. |
Publish over HTTP
Publish a JSON data payload to one channel. Authenticate with an API key that has a write grant, or with a subscriber token minted with publish of "http" or "all" for that channel. The response reports how many open subscribers received it. (Browser clients can also publish over their socket — see Publish over WebSocket.)
curl -X POST https://api.altengine.net/v1/channel/live/publish \
-H "Authorization: Bearer $ALTENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "channel": "room-42", "data": { "text": "hello" } }'
# → { "delivered": 3 }Each subscriber receives the message as a JSON frame carrying the channel, your payload, and a server timestamp (milliseconds since the epoch):
{ "channel": "room-42", "data": { "text": "hello" }, "ts": 1752345600000 }The 32 KiB message limit applies to this whole frame as UTF-8 JSON — channel and ts included, not just data. Over it, POST /publish returns 400.
Delivery is at-most-once: a message reaches only the subscribers connected at publish time — there is no backlog or replay for clients that are offline or reconnecting. Publishing to a channel with no open connections returns { "delivered": 0 }.
Subscribe over WebSocket
Open a WebSocket on ws_endpoint with the token's protocols:
const r = await (await fetch("/api/channel-token")).json(); // your backend's mint
const ws = new WebSocket(r.ws_endpoint, r.protocols);The connection subscribes to all of the token's channels by default; add ?channel= (repeatable) or ?channels=a,b to ws_endpoint to start with a subset. Requesting /subscribe without a WebSocket upgrade returns HTTP 426.
Token auth
/subscribe takes the subscriber token in one of these, checked in this order:
| Send | Use from |
|---|---|
Sec-WebSocket-Protocol: altengine.v1, altengine.token.<token> | Browsers: new WebSocket(ws_endpoint, protocols). The server answers with altengine.v1. Offering altengine.token.… without altengine.v1 returns 400. |
?token=<token> | Deprecated; it may stop working after 31 January 2027. Responses carry Deprecation: true, Sunset: Sun, 31 Jan 2027 00:00:00 GMT and a Link to this section. ws_url is this form. |
Authorization: Bearer <token> | Servers and native clients that can set headers on the upgrade. |
While an instance's clients still use ?token=, its page in the console says so, with when it was last seen.
One socket carries many channels. Incoming frames are the message objects shown above, plus presence events when presence is on. You can adjust the live subscription — within the channels the token authorized — by sending control frames:
| Send | Effect |
|---|---|
{ "type": "subscribe", "channels": ["room-7"] } | Add channels to this connection. |
{ "type": "unsubscribe", "channels": ["room-7"] } | Remove channels from this connection. |
ping (raw string) | Keepalive; the server replies pong. Once a connection has sent ping, send it at least every 15 minutes. |
After a subscribe or unsubscribe, the server acknowledges with the connection's current set: { "type": "subscribed", "channels": [ … ] }. A channel a subscribe asked for and did not get — the token does not authorize it, it already has 10,000 subscribers, or, for an end user's token, that user already holds 20 sockets on it — is listed in "refused": [ … ] on the same ack. A ?channel= the token does not authorize fails the upgrade with 403; one refused for capacity at open is reported in a subscribed frame with refused, sent as soon as the socket opens. With nothing refused at open, no frame is sent. The socket is closed automatically when the token expires — with WebSocket close code 4401 — so refresh the token and reconnect before then.
A connection that has sent ping and then goes 15 minutes without one is closed with code 4408; reconnect. A connection that never pings is not affected. The JS SDK pings every 30 seconds.
Publish over WebSocket
A token minted with publish of "ws" or "all" can publish up its own socket instead of calling POST /publish, with no per-message HTTP round-trip. It is rate-capped per connection rather than against the instance's publish budget. Send a publish frame naming a channel the token is authorized for:
ws.send(JSON.stringify({ type: "publish", channel: "room-42", data: { text: "hello" } }));The message fans out to that channel's subscribers as the same frame an HTTP publish produces ({ "channel", "data", "ts" }), and the server acknowledges to the sender with the delivery count:
{ "type": "published", "channel": "room-42", "delivered": 3 }If the frame is rejected — the token lacks WS publish, the channel is not authorized, the framed message is over 32 KiB, or the per-connection rate cap (about 10 publishes per second) is exceeded — the server replies with an error frame instead, and the message is not delivered:
{ "type": "error", "error": "publish rate exceeded — slow down" }WebSocket publish is ephemeral. Like all channel delivery it is at-most-once and never touches your backend, so there is no server-side record of the message. If you need message history, moderation, or validation, publish server-side instead: have the client send to your backend, which records the message and then calls HTTP publish. Reserve WebSocket publish for low-latency ephemeral traffic — chat where history is optional, typing indicators, cursors, and live reactions.
End-to-end example
// Server: mint a token (never expose the API key to the browser)
const res = await fetch("https://api.altengine.net/v1/channel/live/tokens", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.ALTENGINE_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ channels: ["room-42"], ttl_seconds: 3600 })
});
const r = await res.json();
// Browser: open ws_endpoint with the token's protocols
const ws = new WebSocket(r.ws_endpoint, r.protocols);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
console.log(msg.channel, msg.data, msg.ts);
};
// Adjust channels live (within the token's authorized set)
ws.send(JSON.stringify({ type: "subscribe", channels: ["room-7"] }));Presence
Presence is off by default. Turn it on per instance in the dashboard (Channels → your app → Settings → Presence). While it is off, presence_id is ignored, GET /presence returns 403 PERMISSION_DENIED, and no presence events are sent. A socket opened while it was off stays anonymous until it reconnects.
curl "https://api.altengine.net/v1/channel/live/presence?channel=room-42" \
-H "Authorization: Bearer $ALTENGINE_API_KEY"
# → {
# "channel": "room-42",
# "occupancy": 5,
# "member_count": 2,
# "members": [ { "id": "user:alice", "connections": 3 }, { "id": "user:bob", "connections": 1 } ],
# "truncated": false
# }| Field | Meaning |
|---|---|
occupancy | Open sockets on the channel, anonymous ones included. |
member_count | Distinct presence identities. |
members | One entry per identity, with how many sockets it holds. At most 1,000 entries; there is no paging. |
truncated | true when member_count is larger than members. |
channel is required. An end user's id_token may read presence only for channels its access rules let it subscribe to; anything else is 403. A socket that dropped without a clean close is counted until the next publish to the channel finds it gone.
Subscribers also receive an event when a member's first socket joins the channel and when its last one leaves. Anonymous sockets never produce one, and the joining socket does not receive its own join:
{ "type": "presence", "event": "join", "channel": "room-42", "id": "user:alice", "ts": 1752345600000 }
{ "type": "presence", "event": "leave", "channel": "room-42", "id": "user:alice", "ts": 1752345660000 }Region
By default a channel runs near whoever first publishes or subscribes to it. To pin it, set the instance's region in the dashboard (Channels → your app → Settings → Region). New sockets and newly created channels follow it; a channel that is already live is not moved.
Limits
| Limit | Value |
|---|---|
| Channels per token | 100 |
| Subscribers per channel | 10,000 |
| Sockets per end user per channel | 20 |
| Presence identity length | 128 bytes |
| Members in a presence response | 1,000 |
| Channel name length | 200 bytes |
| Message size | 32 KiB, measured on the delivered JSON frame |
| WebSocket publishes per connection | ~10 per second |
| Token TTL (default) | 3600 seconds (1 hour) |
| Token TTL (max) | 14400 seconds (4 hours); longer requests are clamped |
Channel names must be printable ASCII, may not start with !, and may not use the reserved __*__ form.