Skip to content

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.vacated fires 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 for presence.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" } }
  }
}
  • type names the event and timestamp records its time in ISO-8601 format.
  • idempotencyKey identifies the event and stays the same on retries.
  • data includes productId and channel. Presence events also include identity; presence.member-added includes 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:

  1. Subscribe a tab to an empty chat:room-1. Confirm your endpoint receives channel.occupied.
  2. Open its delivery log in Realtime → Webhooks, or use realtime_webhook_deliveries_list. Confirm succeeded and your 2xx response. If it failed without a matching receiver log, check public DNS, reachability, TLS, service availability and whether rejected requests are logged.
  3. Send the same body to your receiver with a changed signature byte. Confirm rejection and no event processing.
  4. 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:

  1. Call realtime_webhook_secret_rotate. Save the new secret returned once; it signs subsequent deliveries.
  2. Add it to your verifier while retaining the previous secret. The response gives the previous secret’s expiry, by default 48 hours after rotation.
  3. 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 2xx and process the queued work afterward.
  • Do not assume arrival order. For example, channel.vacated may arrive before presence.member-removed for the same channel. Use timestamp when 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

Was this page helpful?
Esc

Start typing to search the docs.

↑↓navigate↵select