Realtime webhooks
Publishing pushes events out to clients. Webhooks run the other direction: the Realtime edge POSTs a signed event to your own endpoint when something happens on a channel, so your backend learns about activity it never took part in.
That closes a gap the API cannot. A client subscribing, a member closing their last tab, one browser sending another a cursor position: none of it passes through your server. Webhooks are how you find out.
The five event groups
You subscribe to groups, and a group covers a small family of delivered events. Each one answers a different question:
| Group | The question it answers | What it delivers |
|---|---|---|
| realtime.channel_existence | Is anyone listening? | realtime.channel_occupied, realtime.channel_vacated |
| realtime.presence | Who is here? | realtime.member_added, realtime.member_removed |
| realtime.connection_count | How many connections? | realtime.connection_count |
| realtime.cache_channels | Does this channel need data? | realtime.cache_miss |
| realtime.client_events | What are clients sending? | one event per client event, named after it |
channel_existence reports the two edges of a channel's life and nothing in between: channel_occupied when a channel goes from zero connections to one, channel_vacated when its last connection leaves. Subscribers arriving and leaving in the middle produce nothing, which makes this the cheap way to know whether it is worth publishing at all.
connection_count needs the app's connection-counting setting turned on. Without it the edge never computes the count, so the group is subscribed and silent.
Subscribe an endpoint
Realtime groups are configured in the dashboard, on the endpoint itself. Open Developers → Webhooks, create or edit an endpoint, and use the Realtime events block: pick one Realtime app, then tick the groups you want.
The app choice is what makes a Realtime webhook different from a platform one. Platform events (email.delivered and friends) are workspace-wide, while realtime.* events belong to a single app, so the endpoint is scoped to that app when you create it and cannot be moved to another one afterwards. You can change which groups it receives at any time.
The two kinds can share an endpoint. Ticking email.bounced and realtime.presence on the same endpoint is allowed, and both arrive at the same URL with the same signing secret.
Realtime groups are not part of the public webhooks API yet, so POST /v1/webhooks with a realtime.* type in events is rejected as an unknown event type. Use the dashboard until that changes.
What a delivery looks like
The body is Bird's standard webhook envelope, three fields, one event per POST:
Exemple de code
{
"data": { "channel": "presence-room-1", "member_id": "u_42" },
"timestamp": "2026-07-31T09:00:00Z",
"type": "realtime.member_added"
}type names the specific thing that happened. The group you subscribed to never appears in a delivery, so subscribing to realtime.presence gets you realtime.member_added and realtime.member_removed, and your handler switches on those:
| Delivered type | data fields |
|---|---|
| realtime.channel_occupied | channel |
| realtime.channel_vacated | channel |
| realtime.member_added | channel, member_id |
| realtime.member_removed | channel, member_id |
| realtime.connection_count | channel, connection_count |
| realtime.cache_miss | channel |
| realtime.<client event> | channel_name, event, data, connection_id, plus member_id on a presence channel |
Client events are the one group whose type you choose. A client that triggers client-typing produces a delivery of type realtime.client-typing, and event inside data repeats the name. Bind on the prefix rather than an exact match if your clients send several names. See Client events.
Verifying a delivery
Realtime webhooks are signed exactly like every other Bird webhook: Standard Webhooks, with webhook-id, webhook-timestamp, and webhook-signature headers and your endpoint's own signing secret. One handler covers your whole workspace.
Exemple de code
import { BirdClient } from "@messagebird/sdk";
const bird = new BirdClient({
apiKey: process.env.BIRD_API_KEY,
webhooks: { secret: process.env.BIRD_WEBHOOK_SECRET },
});
app.post("/webhooks/bird", express.raw({ type: "*/*" }), (req, res) => {
const event = bird.webhooks.unwrap(req.body, req.headers);
res.sendStatus(200);
switch (event.type) {
case "realtime.channel_vacated":
stopExpensiveWorkFor(event.data.channel);
break;
case "realtime.member_removed":
markAway(event.data.member_id);
break;
}
});Verify against the raw request body. Parsing the JSON and re-serializing it changes the bytes, and the signature is over bytes. An event type your code has never seen still verifies and decodes, so a default branch is enough to keep an older handler working when new types ship. The full contract, including the by-hand recipe, is in Webhooks & events.
Presence events count members
member_added and member_removed follow the identity, so they do not line up one-to-one with connections. Somebody with your app open in three tabs is one member:
| What happens | Webhook |
|---|---|
| First tab subscribes | realtime.member_added |
| Second tab subscribes | none |
| Second tab closes | none |
| Last tab closes | realtime.member_removed |
So member_removed is a reasonable trigger for "this person left the room" and a bad one for "this session ended". If you need connections, that is realtime.connection_count, which counts three for the member above. Presence channels covers the same distinction from the client's side.
Delivery differences worth knowing
Realtime deliveries take a different path than platform events, and the operational details do not all match Webhooks & events:
- Your endpoint has one second to respond. That is the single most important number here: a delivery is counted as failed if the request has not completed in that time, so answer 2xx immediately and do the actual work after responding, never before.
- A failed delivery is retried four times, waiting 10 seconds, then 30 seconds, then 2 minutes, then 5 minutes. After the last attempt the event is dropped.
- There is no replay, and realtime.* deliveries do not appear in the endpoint's delivery-attempts log.
- Pausing the endpoint stops Realtime deliveries along with everything else, and re-enabling it resumes them.
Deliveries are not ordered, and there is no per-event receipt, so treat each one as a hint that state changed rather than as the state itself. A channel_vacated arriving after somebody has already re-subscribed is normal, and Querying channel state is how you check what is true now.
Next steps
- Client events is the group whose event names and payloads you define yourself.
- Cache channels explains what to do with realtime.cache_miss.
- Webhooks & events covers endpoint setup, signature verification, and secret rotation for every Bird webhook.