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>).

POST /api/v1/channels

Create a channel. Body: {name, description?, allowed_origins?}. Example:

{"name": "chat-general", "description": "General chat"}
GET /api/v1/channels

List channels you own. No body.

PATCH /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"]}
DELETE /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.

POST /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}
GET /api/v1/channels/<channel_id>/tokens

List tokens for a channel (metadata only — never the raw value). No body.

PATCH /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}
DELETE /api/v1/tokens/<token_id>

Revoke a token immediately. No body.

Origin restriction. Set 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).

POST /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)

POST /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)

GET /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)

GET /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.

ActionCostWhen it's charged
Publish a message1 + 1 per recipientPer POST /publish call, or per message sent over /ws/publish — 1 base, plus 1 for every connection it actually reaches
Open a connection1Once, right after your ws:///wss:// handshake succeeds — /ws/subscribe and /ws/publish alike
Hold a connection open1Every ~10 minutes the connection stays open — /ws/subscribe and /ws/publish alike
Mint a ticket1Per POST /tickets call
Mint a token1Per POST /tokens call
Idle connection timeoutA 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.

Simple mental model: holding a connection open costs a flat, predictable rate (currently 1 credit per 10 minutes — 6/hour) — plus whatever you actually send or receive. The idle timeout doesn't change that rate or reduce it; it just protects you from paying it forever on a connection nobody's using.

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.