Skip to content

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

EventWhen it fires
channel.occupiedThe first subscriber joined a channel that had none
channel.vacatedThe last subscriber left a channel
presence.member-addedA member entered presence on a channel
presence.member-removedA member left presence on a channel

channel.vacated is 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-removed is 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 active flag. 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" } }
  }
}
  • type is the event type, timestamp is when the event happened in ISO-8601.
  • idempotencyKey identifies the event. It is stable across retries.
  • data always carries productId and channel. The presence events add identity, and presence.member-added also carries the member as it was when it entered. The channel events carry nothing beyond the channel.

Every request also carries these headers:

HeaderValue
content-typeapplication/json
user-agentlessly-realtime-webhooks/1
X-Realtime-SignatureHMAC-SHA256 of the raw request body, keyed by the signing secret, hex-encoded
X-Realtime-KeyThe 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:

  1. Rotate. Over MCP, realtime_webhook_secret_rotate. A new secret is returned, once, and signs every delivery from that moment on.
  2. 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.
  3. 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 2xx and do the real work afterwards; slow handlers turn into timeouts and retries.
  • Do not rely on order. Events are delivered independently, so a channel.vacated can arrive before a presence.member-removed for the same channel. Use timestamp when 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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect