Adding Realtime Notifications to a Web App

A notification bell that lights up without a refresh needs exactly three pieces: an endpoint on your server that mints a short-lived, channel-scoped token; a single WebSocket in the browser that subscribes with it; and an ordinary HTTP POST from your backend whenever something happens. With altengine's Channel API that is POST /tokens, GET /subscribe, and POST /publish — no socket tier of your own, and nothing to pay for while nobody is connected.

Decide the channel names first

Everything else follows from this. A channel is just a name, and a token names the channels a client may use, so your naming scheme is your authorization model. For notifications the useful split is:

  • user-<id> — one private channel per signed-in user. Only that user's token ever names it, so nobody can subscribe to someone else's feed.
  • announce — one shared channel for things everyone should see (maintenance notices, a shipped feature).

One socket carries both, so this costs one connection, not two. Names must be printable ASCII up to 200 bytes, may not start with !, and may not use the reserved __*__ form. A token may name up to 100 channels, and a channel holds up to 10,000 subscribers.

1. A token endpoint on your server

Your API key stays on the server, always. The browser calls an endpoint of yours, you check the session you already have, and you mint a token scoped to that user. Minting a subscribe-only token needs a read grant — nothing more, which is a good reason to use a key that cannot publish here.

// POST /api/notifications/token  (your app, not altengine)
app.post("/api/notifications/token", async (req, res) => {
  const user = await requireSession(req);        // your existing auth

  const r = await fetch("https://api.altengine.net/v1/channel/live/tokens", {
    method: "POST",
    headers: {
      "Authorization": "Bearer " + process.env.ALTENGINE_KEY,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      channels: ["user-" + user.id, "announce"],
      ttl_seconds: 3600,
      publish: false
    })
  });

  const token = await r.json();
  res.json({ ws_url: token.ws_url, expires_at: token.expires_at });
});

The response hands back a ready-to-use WebSocket URL with the token already in it, so the browser never has to assemble one:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
  "expires_at": 1758585600,
  "channels": ["user-1042", "announce"],
  "publish": false,
  "ws_url": "wss://api.altengine.net/v1/channel/live/subscribe?token=eyJhbG…"
}

ttl_seconds defaults to 3600 and maxes at 14400 (four hours). Keep publish: false for a notification feed: a client that can publish to announce can notify your entire user base, and nothing about a bell icon needs that.

2. One WebSocket in the browser

Open the returned URL. The connection subscribes to every channel the token authorized, so there is no second step for the common case. Each incoming frame carries the channel, your payload, and a server timestamp in milliseconds:

const { ws_url, expires_at } = await (await fetch("/api/notifications/token")).json();

const ws = new WebSocket(ws_url);
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.channel === "announce") showBanner(msg.data);
  else addToBell(msg.data, msg.ts);
};

Two things the client owns, and they are the real work in this feature.

Token expiry is scheduled, not surprising

When the token expires the server closes the socket with WebSocket close code 4401. You know expires_at in advance, so refresh ahead of it rather than reacting to the close — fetch a fresh token a minute or two early and reconnect on your own schedule:

const refreshIn = (expires_at * 1000) - Date.now() - 120_000;
setTimeout(reconnect, Math.max(refreshIn, 10_000));

Reconnects are yours, with jitter

Networks drop, laptops sleep, tabs go to background. Treat onclose as normal and reconnect with exponential backoff plus randomness, so a brief outage does not return as every client of yours re-authenticating in the same 50 ms. Mint a new token on each reconnect — the old one may be expired, and minting is cheap.

If a page later needs a channel the token already authorized, you do not need a new socket: send a control frame and the server acknowledges with the connection's current set.

ws.send(JSON.stringify({ type: "subscribe", channels: ["order-9182"] }));
// → { "type": "subscribed", "channels": ["user-1042", "announce", "order-9182"] }

3. Publish from your backend when something happens

Wherever the event already occurs in your code — an order shipped, a comment landed, an invite was accepted — add one POST. It needs a write grant, and the response tells you how many open subscribers received it:

curl -X POST https://api.altengine.net/v1/channel/live/publish \
  -H "Authorization: Bearer $ALTENGINE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "user-1042",
        "data": { "kind": "comment", "title": "Ada replied to your post", "url": "/posts/57" } }'

# → { "delivered": 2 }

Subscribers see it as { "channel": "user-1042", "data": { … }, "ts": 1758585600000 }. Keep the payload small — 32 KiB is the ceiling per message, and a notification only needs enough to render a row and a link. If the real content is large, publish an identifier and let the client fetch it.

{ "delivered": 0 } is not an error. It means that user has no tab open, which brings us to the part that matters most.

The channel is transport, not storage

Delivery is at-most-once. A message reaches only the subscribers connected at publish time — there is no backlog, no replay for a client that was offline or mid-reconnect. A notification system built on the socket alone silently loses every notification sent while a user was away, which is most of them.

So write the notification down first, then publish it. Persisting it is one call to the Datastore API, in a collection keyed per user:

POST /v1/datastore/app/ns/_default/col/notifications/documents
{
  "documents": [
    { "data": { "user": "1042", "kind": "comment", "title": "Ada replied to your post",
                "url": "/posts/57", "read": false } }
  ]
}
# → { "keys": ["3f1c…"] }

Then, on page load, the bell hydrates from a query instead of from thin air:

POST /v1/datastore/app/ns/_default/col/notifications/query
{
  "where": [{ "field": "user", "op": "=", "value": "1042" },
            { "field": "read", "op": "=", "value": false }],
  "order": [{ "field": "__created__", "dir": "desc" }],
  "limit": 25
}

That gives you the division of labour worth internalising: Datastore is the truth, the channel is the live delta. Load renders state; the socket only saves the user from refreshing. A missed frame costs a moment of staleness, not a lost notification — and a reconnect can simply re-run the query. It also means your unread badge, mark-as-read, and notification history all work identically for a user who never had a socket open at all.

What this costs

Channel meters two things: message deliveries at $2.50 per million, and connection time at $0.02 per million connection-seconds — roughly five cents a month for one connection held open all month. There is no cluster and no reserved capacity, so an instance with nobody connected bills zero.

A concrete month: 60 subscribers connected on average across 30 days, and a million deliveries.

  • Connection time: 60 × 2,592,000 ≈ 155.5M connection-seconds → $3.11
  • Deliveries: 1,000,000 → $2.50

That is $5.61, and since the first $3.00 of usage each month is free for every organization — one pay-as-you-go plan, no card to start — you would pay about $2.61. The lever to watch is the delivery, because it is metered per subscriber: a user with three tabs open counts as three deliveries, and a publish to announce with 2,000 people connected meters 2,000. Notifications are naturally frugal here; it is per-keystroke traffic that moves this bill. Full rates are on the pricing page.

Getting started

Create an organization in the console, provision a channel instance, and mint a key with a write grant. Then it is the three calls above. The control frames, the publish-capable token variants, and every limit are in the Channel API reference; if you are weighing this against running your own socket tier, we walked through that trade-off in managed pub/sub without running a server.

Start free in the console

← Back to the blog