Receive channel events on your backend
Receive signed Realtime events and handle retries without duplicate effects.
Register a webhook to receive signed notifications when a channel gains or loses its subscribers, or when presence membership changes. Realtime sends each event to your backend as an HTTP POST, so you do not need to poll the roster or hold a socket open.
The chat example uses chat:room-1 and https://api.example.com/webhooks/realtime. Replace both with your own values.
Before you start
- A publicly reachable HTTPS endpoint that can respond within about ten seconds.
- A backend secret store for its signing secret.
- A registered namespace and an active channel to generate events. Enable presence to receive member events.
- Owner or Admin access to register the endpoint.
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.vacatedfires after about three seconds with no subscribers. A reconnect during that interval cancels it, avoiding a vacated/occupied pair for a brief refresh. Abrupt disconnections use a similar delay forpresence.member-removed.
Presence webhooks report the same membership changes as the browser’s presence callbacks.
Step 1 — Register the endpoint
Add an endpoint in Realtime → Webhooks. Agents can call realtime_webhook_create; REST callers can use the Realtime API reference.
Configure the destination and the events your handler needs:
url: the POST destination, up to 2048 characters; use HTTPS in production.events: one or more of the four event types above. You can use one endpoint for several types or separate endpoints.description: an optional label, up to 500 characters.active: whether the endpoint receives deliveries.
Subscribe only to events you will process. Each selected event produces a request your endpoint must acknowledge within the delivery timeout.
Step 2 — Read what a delivery carries
Read the event from the JSON body of the POST. For example:
{
"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": "ext:user-42",
"member": { "identity": "ext:user-42", "connections": 1, "info": { "name": "Ada" } }
}
}typenames the event andtimestamprecords its time in ISO-8601 format.idempotencyKeyidentifies the event and stays the same on retries.dataincludesproductIdandchannel. Presence events also includeidentity;presence.member-addedincludes the member snapshot from entry. Channel events add no member fields.
Match the returned roster identity, including its prefix. An end-user token issued for user-42 produces ext:user-42 both here and in presence.
Use these headers to identify and verify the request:
Header | Value |
|---|---|
content-type | application/json |
user-agent | lessly-realtime-webhooks/1 — the identifier is unchanged by the rename, so a receiver matching on it keeps working |
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 |
Step 3 — Verify every signature
Before parsing JSON, compute an HMAC-SHA256 digest of the raw request bytes using the full signing secret. Compare its hex encoding with X-Realtime-Signature in constant time. Parsing and re-serializing can change the bytes and invalidate the check.
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))
}Store secrets by prefix and select one using X-Realtime-Key. During rotation, this lets the handler accept requests signed with either the current or the still-valid previous secret.
Verify it worked
Test arrival, acknowledgement, rejection and duplicate handling separately:
- Subscribe a tab to an empty
chat:room-1. Confirm your endpoint receiveschannel.occupied. - Open its delivery log in Realtime → Webhooks, or use
realtime_webhook_deliveries_list. Confirmsucceededand your2xxresponse. If it failed without a matching receiver log, check public DNS, reachability, TLS, service availability and whether rejected requests are logged. - Send the same body to your receiver with a changed signature byte. Confirm rejection and no event processing.
- Send a valid event twice with the same
idempotencyKey. Confirm the second request creates no duplicate records or other effects.
Close the tab and confirm channel.vacated arrives after the settling period.
Rotate the secret
Keep both secrets during the rotation overlap:
- Call
realtime_webhook_secret_rotate. Save the new secret returned once; it signs subsequent deliveries. - Add it to your verifier while retaining the previous secret. The response gives the previous secret’s expiry, by default 48 hours after rotation.
- Remove the old secret after that expiry.
Your verifier needs to support multiple secrets during this interval. You can inspect each secret’s prefix, expiry and creation time later, but cannot retrieve its value again.
When a delivery fails
Any 2xx response acknowledges delivery. A 4xx, 5xx, connection error or failure to respond within about ten seconds counts as a failed attempt.
Realtime retries with increasing delays, for at most five total attempts. After the fifth failure it stops and marks the delivery failed. Deleting or deactivating the webhook before an attempt also makes the delivery fail.
Design your receiver for retries and independent delivery:
- Deduplicate on
idempotencyKey. Delivery is at least once, so a lost response or timeout can cause the same processed event to arrive again. - Acknowledge promptly with
2xxand process the queued work afterward. - Do not assume arrival order. For example,
channel.vacatedmay arrive beforepresence.member-removedfor the same channel. Usetimestampwhen order matters.
Use Realtime → Webhooks or realtime_webhook_deliveries_list to inspect recent delivery status, attempt count, response code, last error and last-attempt time.
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.