# Guide to Pusher Webhooks: Features and Best Practices

Pusher Channels delivers realtime messages to browsers and apps over WebSockets. Most of the action happens between Pusher and your clients, but your server often needs to know what's happening too: whether anyone is subscribed to a channel, who has joined a presence channel, or what clients are sending each other. Pusher's webhooks tell your server about those events.

This guide covers how Pusher Channels webhooks are enabled and signed, how batching changes the payload, the events Pusher sends, and the best practices for running a Pusher webhook receiver in production. Pusher Beams, Pusher's push notification product, is separate and not covered here.

## What are Pusher webhooks?

You turn on webhooks for each Channels app in the Pusher dashboard and give them a URL. When something happens on one of the app's channels, Pusher sends an HTTP `POST` with a JSON body to that URL.

The body always has two keys. `time_ms` is the Unix time in milliseconds when the webhook was created, and `events` is an array of events, each with a `name` and its own fields. Without batch webhooks, the array holds exactly one event. With batch webhooks turned on, it can hold many.

Every request is signed with one of the app's tokens. `X-Pusher-Key` names the token's key, and `X-Pusher-Signature` holds a hex HMAC-SHA256 of the body, computed with that token's secret.

## Pusher webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per app, in the Channels dashboard |
| Body | `{ "time_ms": <ms>, "events": [ ... ] }` |
| Batching | Optional per app. Pusher recommends turning it on |
| Event type | `name` on each element of `events` |
| Key header | `X-Pusher-Key`, the key of the oldest active token |
| Signature header | `X-Pusher-Signature` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The raw request body, which includes `time_ms` |
| Replay window | None stated. `time_ms` is signed, so you can check it yourself |
| Success | Any `2xx` |
| Retries | Non-`2xx` responses are retried with exponential backoff for 5 minutes |
| Disconnect delay | `channel_vacated` and `member_removed` are delayed up to 3 seconds |
| Transport | HTTPS recommended, HTTP supported |

## Pusher webhook events

| Event | Sent when | Fields |
| --- | --- | --- |
| `channel_occupied` | A channel gets its first subscriber | `channel` |
| `channel_vacated` | A channel loses its last subscriber | `channel` |
| `cache_miss` | One or more clients subscribe to an empty cache channel | `channel` |
| `member_added` | A user subscribes to a presence channel | `channel`, `user_id` |
| `member_removed` | A user unsubscribes from a presence channel | `channel`, `user_id` |
| `client_event` | A client event is sent on a private or presence channel | `channel`, `event`, `data`, `socket_id`, and `user_id` on presence channels |
| `subscription_count` | A client subscribes to or unsubscribes from a channel | `channel`, `subscription_count` |

`subscription_count` has to be turned on under **App Settings** for the app, and isn't sent for presence channels. On channels with more than 100 connected clients, Pusher sends it every 5 seconds while there's subscribe or unsubscribe activity, instead of on every change.

`channel_vacated` and `member_removed` are held back for up to three seconds after a client disconnects. If the client reconnects in that time, neither is sent. This stops brief connection drops and page navigations from looking like users leaving, and makes it less likely that events arrive out of order.

Here is a batched request with two events, using the shapes from Pusher's documentation:

```json
{
  "time_ms": 1767268800000,
  "events": [
    { "name": "channel_occupied", "channel": "presence-lobby" },
    { "name": "member_added", "channel": "presence-lobby", "user_id": "user_123" }
  ]
}

```

## Setting up Pusher webhooks

1. In the Pusher dashboard, open your Channels app and go to its webhooks settings.
2. Add your webhook URL. Use an `https://` URL.
3. Turn on batch webhooks once your handler loops over every element of `events`.
4. If you want `subscription_count`, turn it on under **App Settings**.
5. Store the app's key and secret server-side, for example as `PUSHER_APP_KEY` and `PUSHER_APP_SECRET`.

## Securing Pusher webhooks

Read `X-Pusher-Key` and look up the secret for that key. Then compute a hex HMAC-SHA256 of the raw request body with that secret, and compare it with `X-Pusher-Signature` in constant time. Pusher's server libraries include this check, for example `validate_webhook` in the Python library. Pusher's own PHP example compares the signatures with `==`, which isn't constant time, so don't copy that part.

```javascript
const crypto = require('crypto');
const express = require('express');

// Map each app key you accept to its secret. Keep the old pair here while you rotate tokens.
const PUSHER_SECRETS = { [process.env.PUSHER_APP_KEY]: process.env.PUSHER_APP_SECRET };

function verifyPusherSignature(rawBody, keyHeader, signatureHeader) {
  if (!keyHeader || !signatureHeader) return false;
  const secret = PUSHER_SECRETS[keyHeader.trim()];
  if (!secret) return false; // unknown key
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/pusher', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyPusherSignature(req.body, req.get('x-pusher-key'), req.get('x-pusher-signature'))) {
    return res.sendStatus(401);
  }

  const { time_ms, events } = JSON.parse(req.body.toString('utf8'));
  for (const event of events) {
    // event.name: channel_occupied, member_added, client_event ...
  }
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

PUSHER_SECRETS = {os.environ["PUSHER_APP_KEY"]: os.environ["PUSHER_APP_SECRET"]}

def verify_pusher_signature(raw_body: bytes, key_header, signature_header) -> bool:
    if not key_header or not signature_header:
        return False
    secret = PUSHER_SECRETS.get(key_header.strip())
    if not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_pusher_signature(raw_body, request.headers.get("x-pusher-key"),
#                         request.headers.get("x-pusher-signature"))

```

To check your verifier, use the secret `pusher-test-secret` and this exact body:

```
{"time_ms":1767268800000,"events":[{"name":"channel_occupied","channel":"my-channel"}]}

```

The signature should be:

```
333d03137c1bfb7b567ff0d21a358a05974df4affdf621afc61fefe45ca85e8e

```

`time_ms` is inside the signed body, so you can reject old webhooks if you want to. Pusher doesn't state a tolerance, and retries go on for 5 minutes, so if you add a check, allow more than that.

In Hookdeck, the Pusher source type verifies `X-Pusher-Signature` with the secret you enter. It uses that one secret and doesn't read `X-Pusher-Key`, so when you rotate tokens, update the secret in Hookdeck at the moment the new token becomes the one Pusher signs with.

> **Make Pusher webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Pusher-Signature` header, stores every webhook as it arrives, and keeps retrying delivery to your service long after Pusher's 5-minute retry window has closed.

## Pusher webhook limitations and pain points

### A 5-minute retry window

**The Problem:** A deploy or short outage longer than a few minutes loses webhooks, and with them your view of who is online.

**Why It Happens:** Pusher retries a non-`2xx` response with exponential backoff, but only for 5 minutes.

**Workarounds:**

* Keep the webhook endpoint up through deploys, for example with rolling restarts.
* Rebuild state from the HTTP API after an outage, such as fetching which channels are occupied or which users are in a presence channel.

**How Hookdeck Can Help:** Hookdeck accepts each webhook at its edge and stores it, so Pusher's retry window only has to cover the hop to Hookdeck. From there, Hookdeck [retries](/docs/retries) delivery to your service on your schedule, and you can replay anything that failed.

### Batches mean many events per request

**The Problem:** A handler written for one event per request silently ignores most events after batching is turned on, or takes so long on a large batch that Pusher times out and retries the whole thing.

**Why It Happens:** With batch webhooks, one request can carry many events. Pusher warns that a slow handler risks timing out, though it doesn't say how long the timeout is.

**Workarounds:**

* Always loop over `events`. Never read only `events[0]`.
* Queue the events and return `2xx` straight away, as Pusher suggests for apps with many events per webhook.

**How Hookdeck Can Help:** Hookdeck acknowledges each request immediately. You can use a [transformation](/docs/transformations) to inspect the batch, and set a [maximum delivery rate](/docs/destinations#set-a-max-delivery-rate) so bursts don't overload your service.

### State can arrive out of order

**The Problem:** Your server thinks a channel is empty when it isn't, or a user is online when they've left.

**Why It Happens:** Retries and batching can deliver events in a different order from the one they happened in. Pusher's 3-second delay on `channel_vacated` and `member_removed` reduces this but doesn't remove it.

**Workarounds:**

* Use `time_ms` to ignore an update that is older than the state you already have.
* Treat presence and occupancy from webhooks as hints, and confirm through the HTTP API when it matters.

**How Hookdeck Can Help:** Hookdeck's event log records when each webhook arrived, which helps when tracing a state that went wrong.

### Token rotation and the single-secret check

**The Problem:** After you add or remove a token, signatures stop matching.

**Why It Happens:** An app can have several tokens, and Pusher signs with the oldest active one. Which secret applies depends on which tokens are active, and `X-Pusher-Key` is the only clue.

**Workarounds:**

* Keep a map of accepted keys to secrets, and verify with the secret for the key in the request.
* Add the new pair to your map before revoking the old token, and remove the old pair afterwards.

**How Hookdeck Can Help:** Hookdeck reports verification failures on the [source](/docs/sources), so a rotation that hasn't been reflected in the source's secret shows up quickly.

## Best practices

### Verify against the raw body

Mount `express.raw()` on the webhook route and parse only after verification. In FastAPI, read `await request.body()`. Re-serialised JSON won't match the signature.

### Turn on batch webhooks

Pusher recommends it for everyone once your handler copes with several events per request. It means fewer requests to your server.

### Handle each event idempotently

Pusher documents no event ID. Presence and occupancy events describe state, so apply them as state changes that are safe to repeat, such as setting a user online rather than incrementing a counter. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat `client_event` data as untrusted

`client_event` carries whatever a client sent. The signature proves Pusher delivered it, not that the content is safe, so validate `data` before acting on it.

### Acknowledge fast and process asynchronously

Verify, queue the events, return `2xx`, and process afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Pusher Channels signs each webhook with a hex HMAC-SHA256 of the body in `X-Pusher-Signature`, using the secret of the token named in `X-Pusher-Key`. The things to plan for are batches of several events, a retry window of only 5 minutes, and presence state that can arrive out of order.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every signature, stores every webhook, and delivers it to your service with retries you control.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Pusher webhooks reliably in minutes.