# 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**](https://bird.com/dashboard/w/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:

```json
{
  "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](/docs/guides/realtime/client-events).

## Verifying a delivery

Realtime webhooks are signed exactly like every other Bird webhook: [Standard Webhooks](https://www.standardwebhooks.com/), with `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers and your endpoint's own signing secret. One handler covers your whole workspace.

```typescript
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](/docs/guides/webhooks#verify-signatures).

## 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](/docs/guides/realtime/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](/docs/guides/webhooks):

- 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](/docs/guides/realtime/querying-channel-state) is how you check what is true now.

## Next steps

- [Client events](/docs/guides/realtime/client-events) is the group whose event names and payloads you define yourself.
- [Cache channels](/docs/guides/realtime/cache-channels) explains what to do with `realtime.cache_miss`.
- [Webhooks & events](/docs/guides/webhooks) covers endpoint setup, signature verification, and secret rotation for every Bird webhook.