Realtime
Receive channel events on your backend
Register a webhook, verify its signature, and rotate the secret without dropping deliveries.
Webhooks tell your backend what is happening on your channels without it holding a connection open or polling. Realtime POSTs a signed JSON body to a URL you own whenever one of the events below occurs.
Which events reach you
| Event | When it fires |
|---|---|
channel.occupied | The first subscriber joined a channel that had none |
channel.vacated | The last subscriber left a channel |
presence.member-added | A member entered presence on a channel |
presence.member-removed | A member left presence on a channel |
channel.vacatedis delayed on purpose. It fires only after the channel has stayed empty for a short settling period of about three seconds, and somebody reconnecting inside that period cancels it — so an ordinary refresh does not produce a vacated/occupied pair.presence.member-removedis delayed the same way for connections that drop without saying goodbye.
The presence events mirror the join and leave deltas your browser clients see. See presence.
Register a webhook
Register a webhook from the platform, the same place you manage namespaces, grants and API keys — over MCP, realtime_webhook_create; over REST, on the Realtime API reference.
A webhook has:
- a
url— the endpoint that receives the POSTs, up to 2048 characters, HTTPS in production; events— at least one of the four types above, so one endpoint can take everything or you can split them across endpoints;- an optional
description, up to 500 characters; - an
activeflag. An inactive webhook receives no deliveries.
Creating the webhook returns the signing secret once, at creation, and never again. Store it immediately; if you lose it, rotate to get a new one.
The delivery
Each delivery is a POST with a JSON body of this shape:
{
"type": "presence.member-added",
"timestamp": "2026-08-02T10:15:30.000Z",
"idempotencyKey": "realtime.presence.member-added.v1:0f0a…",
"data": {
"productId": "prod_…",
"channel": "chat:room-1",
"identity": "user-42",
"member": { "identity": "user-42", "connections": 1, "info": { "name": "Ada" } }
}
}typeis the event type,timestampis when the event happened in ISO-8601.idempotencyKeyidentifies the event. It is stable across retries.dataalways carriesproductIdandchannel. The presence events addidentity, andpresence.member-addedalso carries thememberas it was when it entered. The channel events carry nothing beyond the channel.
Every request also carries these headers:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | lessly-realtime-webhooks/1 |
X-Realtime-Signature | HMAC-SHA256 of the raw request body, keyed by the signing secret, hex-encoded |
X-Realtime-Key | The visible prefix of the secret that signed this request |
Verify the signature
Compute HMAC-SHA256 over the raw request body, exactly as received, keyed by the full secret string, and compare the hex digest against X-Realtime-Signature. Do this before parsing the JSON — re-serializing the body changes the bytes and the signature will not match. Compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody, headers, secrets) {
const secret = secrets[headers['x-realtime-key']]
if (!secret) return false
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const got = headers['x-realtime-signature'] ?? ''
return expected.length === got.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(got))
}X-Realtime-Key tells you which secret signed the request, so keep your secrets keyed by their prefix and look up the one named in the header.
Reject anything that does not verify. Do not fall back to trusting the body.
Rotate the secret
Rotation overlaps on purpose, so a receiver can be updated without dropping deliveries:
- Rotate. Over MCP,
realtime_webhook_secret_rotate. A new secret is returned, once, and signs every delivery from that moment on. - Add the new secret to your verifier, keeping the old one. The previous secret keeps verifying until its expiry — by default 48 hours after the rotation — and the response tells you exactly when that is.
- Drop the old secret after its expiry has passed.
Your endpoint should always be able to verify against more than one secret. The current and recently rotated secrets can be inspected at any time, but only their prefixes, expiry and creation time — the secret itself is never shown again.
When a delivery fails
A delivery counts as delivered on any 2xx response. Anything else — a 4xx, a 5xx, a connection error, or no answer within the per-attempt timeout of about ten seconds — counts as a failure.
Failures are retried with an increasing delay between attempts, up to five attempts in total. After the fifth the delivery is marked failed and is not tried again. A webhook that was deleted or deactivated between the event and the attempt also ends as a failed delivery.
What this means for your endpoint:
- Deliveries are at least once. Deduplicate on
idempotencyKey. A retry after a response your side actually processed, or a timeout on a request that arrived, will hand you the same event again with the same key. - Answer quickly. Acknowledge with a
2xxand do the real work afterwards; slow handlers turn into timeouts and retries. - Do not rely on order. Events are delivered independently, so a
channel.vacatedcan arrive before apresence.member-removedfor the same channel. Usetimestampwhen order matters.
Recent attempts for a webhook are visible from the platform — status, attempt count, the response code you returned, the last error, and the time of the last attempt. Over MCP that is realtime_webhook_deliveries_list, and it is the first place to look when your endpoint stops receiving events.
Next steps
- Show who is on a channel: the roster the presence events mirror.
- Call Realtime from your backend: the webhook resource shapes and what a delivery record carries.
- Authenticate your backend and your users: the other secret your backend holds, and how it is stored.
- Look up a limit or an error: the surrounding quotas and status codes.