ChannelService
Managed WebSocket pub/sub channels. Create a named channel, hand out read/write-scoped tokens, and stream events to every connected subscriber in real time — without running your own relay infrastructure, connection pool, or fan-out logic.
A channel is a named stream you own. A token grants read and/or write access to one channel, optionally restricted to specific browser origins. Anyone holding a valid token can publish events (HTTP) or subscribe (WebSocket) to receive them — your server, your users' browsers, or both.
Quickstart
Four calls: create a channel, mint a token, subscribe, publish.
Prefer Python? pip install geniete-channels — open source
at github.com/geniete/clients.
1. Create a channel
curl -X POST https://your-channelservice-host/api/v1/channels \
-H "Authorization: Bearer <your gf-user API key>" \
-H "Content-Type: application/json" \
-d '{"name": "chat-general", "description": "General chat"}'
Response:
{
"id": "chan_abc123",
"owner_id": "usr_xyz789",
"name": "chat-general",
"description": "General chat",
"allowed_origins": [],
"created_at": 1751932800.0,
"updated_at": 1751932800.0
}
Save the id — every other call needs it.
2. Mint a token
curl -X POST https://your-channelservice-host/api/v1/channels/chan_abc123/tokens \
-H "Authorization: Bearer <your gf-user API key>" \
-H "Content-Type: application/json" \
-d '{"label": "browser-client", "can_read": true, "can_write": true, "allowed_origins": ["https://yourapp.com"]}'
Response:
{
"id": "tok_def456",
"label": "browser-client",
"can_read": true,
"can_write": true,
"allowed_origins": ["https://yourapp.com"],
"raw_token": "gfc_9f1c2e8a7b3d4f5061728394a5b6c7d8"
}
raw_token is the usable credential — shown
once. Store it; ChannelService only keeps the hash.
3. Subscribe (receive events)
const ws = new WebSocket(
"wss://your-channelservice-host/ws/subscribe/chan_abc123?token=gfc_9f1c2e8a7b3d4f5061728394a5b6c7d8"
);
ws.onmessage = (e) => console.log("event:", JSON.parse(e.data));
// first message received:
// {"type": "connected", "channel_id": "chan_abc123", "ts": 1751932801.2}
// every publish after that arrives as the raw payload you sent, e.g.:
// {"text": "hello, channel"}
4. Publish (send an event)
curl -X POST https://your-channelservice-host/api/v1/publish/chan_abc123 \
-H "Authorization: Bearer gfc_9f1c2e8a7b3d4f5061728394a5b6c7d8" \
-H "Content-Type: application/json" \
-d '{"text": "hello, channel"}'
Response:
{"receivers": 1}
Every subscriber connected to chan_abc123 received the
payload immediately; receivers is how many actually got
it. Sending a lot of messages? GET /ws/publish/<channel_id>
(see Publish (WebSocket)) does the same
thing over one held-open connection instead of one HTTP call each.
Channels
Named streams, owned by your GenieForge account. Auth: your gf-user API key (Authorization: Bearer <api_key>).
/api/v1/channels
Create a channel. Body:
{name, description?, allowed_origins?}. Example:
{"name": "chat-general", "description": "General chat"}
/api/v1/channelsList channels you own. No body.
/api/v1/channels/<channel_id>Update name, description, or allowed_origins. Example — lock a channel down after creating it open:
{"allowed_origins": ["https://yourapp.com"]}
/api/v1/channels/<channel_id>Delete a channel and every token issued for it. No body.
Tokens
A token is a read/write-scoped credential for one channel. Issue one per client, per app, or per environment — whatever matches how you want to revoke access later. Auth: your gf-user API key.
/api/v1/channels/<channel_id>/tokens
Body: {label?, can_read?, can_write?, allowed_origins?,
expires_in?, idle_timeout_seconds?} (expires_in
is seconds from now; idle_timeout_seconds overrides
how long a connection using this token may sit with no real
activity before being closed — default 1 hour, 5 minutes to
24 hours, see Pricing). Returns the
raw token once — it is never retrievable again, though every
other field is editable afterward via PATCH below.
Examples:
// a browser client that both listens and sends
{"label": "web-chat-client", "can_read": true, "can_write": true,
"allowed_origins": ["https://yourapp.com"]}
// a backend worker that only publishes, never subscribes
{"label": "ingest-worker", "can_write": true}
// a short-lived token for a one-off integration test
{"label": "ci-test", "can_read": true, "expires_in": 3600}
// a long-running monitor that stays connected but rarely sends —
// give it more idle runway than the 1-hour default
{"label": "uptime-monitor", "can_read": true, "idle_timeout_seconds": 86400}
/api/v1/channels/<channel_id>/tokensList tokens for a channel (metadata only — never the raw value). No body.
/api/v1/channels/<channel_id>/tokens/<token_id>
Edit an existing token — same body shape as create, all fields
optional: {can_read?, can_write?, allowed_origins?,
idle_timeout_seconds?, expires_in?}. Only fields present
in the body are changed; omit a field to leave it as-is.
can_read/can_write are rejected with
400 if the result would leave the token with
neither permission — revoke it instead if that's the intent.
expires_in can only extend or shorten a
currently valid token; a revoked or already-expired
one returns 400 rather than being silently brought
back — issue a new token instead. Examples:
// widen scope and extend the runway
{"can_write": true, "expires_in": 2592000}
// tighten origins without touching anything else
{"allowed_origins": ["https://new.example.com"]}
// clear an expiry override entirely (never expires)
{"expires_in": null}
/api/v1/tokens/<token_id>Revoke a token immediately. No body.
allowed_origins on
a token (or the channel) to reject WebSocket/publish requests whose
browser Origin header doesn't match — only browser
requests from your app's origin are accepted. This doesn't extend to
non-browser clients: a request with no Origin header at
all (curl, a server-side script) is let through regardless, so a
copied or exfiltrated token still needs to be treated as sensitive
and revocable on its own — origin restriction isn't a substitute
for that.
Tickets
A ticket is a one-time-use, 30-second-lived credential exchanged for a token — use it to open a WebSocket connection without putting your long-lived raw token in a URL query string (query strings end up in server logs, browser history, and referrer headers; a ticket that expires in 30 seconds and can only be used once is a much smaller thing to leak).
/api/v1/channels/<channel_id>/tickets
Auth: Authorization: Bearer <raw_token>. No body.
A ticket faithfully carries whatever the source token could do —
mint one from a read-scoped token for /ws/subscribe,
a write-scoped token for /ws/publish, or a
read+write token for either. Response:
{"ticket": "tkt_a1b2c3d4e5f6", "expires_in": 30}
Use it as ?ticket=tkt_a1b2c3d4e5f6 on either
WebSocket URL instead of ?token=.
token= or ticket=? Both
are accepted on every WebSocket endpoint — pick whichever fits how
security-conscious this particular connection needs to be. A raw
token in a URL query string can end up in server logs, browser
history, and referrer headers, and it lives as long as the token
itself. A ticket is single-use and expires in 30 seconds, so even
if it leaks, there's a tiny window and one use before it's
worthless — better for anything a browser initiates. A
server-to-server connection that never touches a browser and
never logs its own URLs can reasonably skip the extra round trip
and use token= directly.
Publish (HTTP)
/api/v1/publish/<channel_id>Auth: a write-scoped token. Body: any JSON object, up to 64 KB. Delivered immediately to every currently-connected subscriber. Example request body and response:
// request body
{"type": "message", "from": "alice", "text": "hello, channel"}
// response
{"receivers": 3}
The payload is entirely yours — ChannelService doesn't inspect,
require, or add any fields to it. receivers is how
many connections actually got it.
Subscribe (WebSocket)
/ws/subscribe/<channel_id>?token=… or ?ticket=…Auth: a read-scoped token or ticket. Example message sequence after connecting:
// received immediately on connect
{"type": "connected", "channel_id": "chan_abc123", "ts": 1751932801.2}
// received on every publish to this channel — the raw payload, unwrapped
{"type": "message", "from": "alice", "text": "hello, channel"}
// received roughly every 30s while idle — a keepalive, not a chat message
{"type": "ping", "ts": 1751932831.4}
// received instead of a ping, repeatedly, in the last 2 minutes before an
// idle-timeout close — see the FAQ for what counts as activity
{"type": "reconnect_required", "reason": "idle_timeout", "closing_in_seconds": 60}
Pings keep the connection alive; holding it open is billed on its own cadence — 1 credit per 10 minutes (see Pricing). A connection with no real activity for too long is closed separately — see the FAQ's "Why did my WebSocket connection close on its own?" for exactly what counts.
Publish (WebSocket)
/ws/publish/<channel_id>?token=… or ?ticket=…
Auth: a write-scoped token or ticket. The streaming counterpart
to Publish (HTTP) — same billing per
message, same 64 KB limit, same "your payload, untouched"
behavior, just over one held-open connection instead of one HTTP
call each. Worth it once you're sending frequently enough that
per-call HTTP overhead (a new TLS handshake, auth check, etc. for
every single message) actually matters — for occasional sends,
POST /publish is simpler to integrate and just as
fine. Example message sequence:
// received immediately on connect
{"type": "connected", "channel_id": "chan_abc123", "ts": 1751932801.2}
// you send (raw JSON, not wrapped in an envelope):
{"type": "message", "from": "alice", "text": "hello, channel"}
// you receive back, once it's been delivered:
{"type": "published", "receivers": 3}
// or, if that particular message was rejected:
{"type": "error", "error": "message quota exhausted"}
// — the connection itself stays open; only the connect/keepalive
// charges, or the idle timeout, can close it (see Pricing and the FAQ).
// A rejected message just means try again once there's balance, same
// as a 402 from POST /publish.
// repeated in the last 2 minutes before an idle-timeout close (sending
// still counts as activity — this only fires if nothing's been sent):
{"type": "reconnect_required", "reason": "idle_timeout", "closing_in_seconds": 60}
Pricing
Every account has a credit balance per credit type — a free
monthly allowance (topped up to 200), monthly plan credits, and
one-time credit packs, all managed at
account.geniete.com and
shared across every GenieTé service. Actions cost
credits; a 402 (HTTP) or a closed connection
(WebSocket) means the channel owner's balance ran out. Pricing is
deliberately simple — flat per-action costs, not usage tiers — so
the actual bill is easy to predict from the actions you take.
| Action | Cost | When it's charged |
|---|---|---|
| Publish a message | 1 + 1 per recipient | Per POST /publish call, or per message sent over /ws/publish — 1 base, plus 1 for every connection it actually reaches |
| Open a connection | 1 | Once, right after your ws:///wss:// handshake succeeds — /ws/subscribe and /ws/publish alike |
| Hold a connection open | 1 | Every ~10 minutes the connection stays open — /ws/subscribe and /ws/publish alike |
| Mint a ticket | 1 | Per POST /tickets call |
| Mint a token | 1 | Per POST /tokens call |
| Idle connection timeout | — | A connection with no real activity for 1 hour (default; configurable per token, 5 minutes–24 hours) is closed — reconnect to continue. Doesn't cost anything itself; see below. |
All charges are billed to the channel owner, never the individual token holder — you pay for your channel's usage no matter which of your tokens (or your users') generated it.
Tutorial: build a real-time chat server
A minimal but realistic pattern: one channel per chat room, one origin-restricted token embedded in your frontend, tickets for each browser's WebSocket connection, and plain HTTP for sending. No backend relay required — the browser talks to ChannelService directly.
1. Create the room's channel (once, from your backend)
curl -X POST https://your-channelservice-host/api/v1/channels \
-H "Authorization: Bearer <your gf-user API key>" \
-H "Content-Type: application/json" \
-d '{"name": "chat-general"}'
# → {"id": "chan_abc123", ...}
2. Mint one origin-restricted token for your app (once, from your backend)
curl -X POST https://your-channelservice-host/api/v1/channels/chan_abc123/tokens \
-H "Authorization: Bearer <your gf-user API key>" \
-H "Content-Type: application/json" \
-d '{"label": "web-chat-client", "can_read": true, "can_write": true,
"allowed_origins": ["https://yourapp.com"]}'
# → {"raw_token": "gfc_...", ...} — embed this raw token in your frontend build/config
One shared client token, embedded in your frontend, is the
simplest way to get started. Be clear on what allowed_origins
actually buys you, though: it blocks normal browser requests from
the wrong origin, but a request with no Origin
header at all (curl, a server-side script) is allowed through —
so it's browser-origin defense, not a substitute for keeping the
token itself scoped and revocable. For a real public-facing app,
prefer one token per user (or per session) so a single leaked
credential doesn't expose every user's write access and can be
revoked individually — issue it from your backend after your own
login, then skip straight to step 3 with that token instead.
3. A complete browser chat client
Everything below runs in the browser. It never talks to your backend at all after the token is embedded.
<!doctype html>
<input id="name" placeholder="your name" value="anon">
<div id="log" style="height:240px;overflow-y:auto;border:1px solid #ccc;padding:8px"></div>
<input id="msg" placeholder="say something">
<button id="send">Send</button>
<script>
const HOST = "your-channelservice-host";
const CHANNEL = "chan_abc123";
const TOKEN = "gfc_..."; // the token from step 2
const log = document.getElementById("log");
function append(line) {
const p = document.createElement("div");
p.textContent = line;
log.appendChild(p);
log.scrollTop = log.scrollHeight;
}
async function connect() {
// A ticket is safer than putting TOKEN in the WS URL directly —
// it's single-use and expires in 30s, so it's a much smaller thing
// to leak than a long-lived credential.
const res = await fetch(`https://${HOST}/api/v1/channels/${CHANNEL}/tickets`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}` },
});
const { ticket } = await res.json();
const ws = new WebSocket(`wss://${HOST}/ws/subscribe/${CHANNEL}?ticket=${ticket}`);
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === "connected" || data.type === "ping") return; // protocol frames, not chat messages
append(`${data.from}: ${data.text}`);
};
ws.onclose = () => {
append("(disconnected — reconnecting in 2s)");
setTimeout(connect, 2000);
};
}
connect();
document.getElementById("send").onclick = async () => {
const text = document.getElementById("msg").value.trim();
if (!text) return;
document.getElementById("msg").value = "";
await fetch(`https://${HOST}/api/v1/publish/${CHANNEL}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ from: document.getElementById("name").value || "anon", text }),
});
};
</script>
4. What you get for free
Fan-out to every connected browser, origin-locked access, automatic
reconnect-friendly short-lived tickets, and per-action metering that
makes abuse (spamming publishes, hammering reconnects) cost the
abuser credits instead of costing you an outage. What you still
design yourself: per-user identity in the payload (ChannelService
doesn't attach a sender identity — the from field above
is just part of your message body), message history/persistence
(ChannelService fans out live, it doesn't replay past messages to
new subscribers), and moderation.
This client sends over HTTP (POST /publish, one call
per message) — the simplest thing that works for a chat app where
people type at human speed. If you're instead building something
that sends very frequently (a live cursor/presence feed, a
game-tick stream), swap the send handler for a second
WebSocket opened at /ws/publish/chan_abc123 (see
Publish (WebSocket)) and send each
message over that instead — same token, same billing, one fewer
HTTP round trip per message.
FAQ
Do I need a ticket, or can I just use my token directly in the WebSocket URL?
Either works — ?token= and ?ticket= are both accepted. A ticket is safer for browser clients specifically because it's single-use and expires in 30 seconds, so it's a much smaller thing to leak than a long-lived token. Server-to-server connections that never touch a browser can reasonably use the token directly.
What happens when my balance runs out?
Minting a ticket or a token returns 402 before doing
anything — those are checked and charged upfront, so a denial
means nothing happened. Publishing is different: the pre-check
only rejects if your balance is already at or below zero: if it's
positive but lower than what this particular publish actually
costs (base cost plus the number of connections it reaches), the
message is still delivered — it already went out to your
subscribers by the time the real cost is known — and the exact
cost is charged afterward, which can take your balance negative.
The next publish then correctly gets rejected by the
pre-check — same behavior for a message sent over
/ws/publish, except the response is a
{"type": "error", ...} WebSocket message instead of
an HTTP 402, and the connection stays open (you might
top up and the next message might succeed). An open connection
itself (subscribe or publish) is closed (code 1008)
the next time it can't afford its keepalive charge — existing
connections aren't cut off mid-tick, but they won't survive the
next one. To recover: wait for the monthly allowance or plan
renewal, upgrade, or buy a one-time credit pack on the account
page.
Who pays when a token I issued to someone else is used?
You do — every charge is billed to the channel's owner, never the token holder. This is intentional: it's your channel, your resource cost, and it means you can hand out read-only tokens freely without worrying about per-recipient billing relationships.
Is there a limit on message size?
64 KB per publish call. Larger payloads should go through your own storage (S3, etc.) with the channel event carrying a reference, not the payload itself.
Does ChannelService store message history?
No — delivery is live, fan-out only. A subscriber that connects after a message was published never sees it. If you need history, persist messages yourself (e.g. from a server-side subscriber that logs everything) and replay it to new clients through your own API.
Can I restrict who can connect, beyond having a valid token?
allowed_origins on a token or channel restricts by browser Origin header. For per-user access control, issue one token per user and revoke individually — ChannelService doesn't have a separate identity layer beyond the token itself.
Why did my WebSocket connection close on its own?
A connection is automatically closed after 1 hour with no real
messages going through it — reconnect to continue. What counts: a
message published on /ws/publish, or a message
delivered to you on /ws/subscribe — anything you're
actually sending or receiving through the channel, however sparse
(even one message every few minutes keeps a connection active
indefinitely). What doesn't count: connection bookkeeping like the
initial connect acknowledgment or the periodic keepalive ping —
those don't reset the clock, only real channel traffic does. This
isn't an error or a billing problem; it's a safety limit that keeps
a connection nobody's watching anymore (a forgotten tab, an old
script left running) from quietly draining your balance forever
instead of failing loudly. You'll get several
{"type": "reconnect_required", ...} messages over the
last 2 minutes before the close, so a client that's paying
attention can open a replacement connection first and hand over
with no gap. If your use case is genuinely low-traffic but you
still want a longer runway, idle_timeout_seconds can
be set per token (via POST/PATCH on
/tokens) up to 24 hours. Holding the connection open
at all is billed separately, and unconditionally, whether or not
this timeout ever comes into play — see Pricing.
Can I keep message contents private from ChannelService itself?
Yes — the payload is opaque to ChannelService; it fans messages out without reading, storing, or caring what's inside. Nothing stops you from encrypting the body yourself before publishing (e.g. put ciphertext under a field like {"ct": "...", "iv": "..."}) with keys you generate and distribute between your own clients — ChannelService never sees the plaintext or holds any key. It does mean you're on the hook for key distribution and rotation yourself; there's no built-in key-exchange mechanism, this is bring-your-own-crypto on top of a transport that doesn't care what it's carrying.
How do I get support?
Sign in at account.geniete.com — your account pages show the support email address, plus the plan/balance/ledger details we'll ask about. Billing terms: genieinc.com.
If I revoke a token, change its permissions, or tighten its allowed origins, does that affect connections that are already open?
New connections and requests are affected immediately. An already-open WebSocket connection using that token picks up the change within about two minutes — revoked, expired, no longer holding the permission it's using, or connecting from an origin that's no longer allowed — and it's closed. This is deliberately fast but not instant; there's no way to force an immediate cutoff of a token's active connections today.