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_ |
| 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:
{
"time_ms": 1767268800000,
"events": [
{ "name": "channel_occupied", "channel": "presence-lobby" },
{ "name": "member_added", "channel": "presence-lobby", "user_id": "user_123" }
]
}
Setting up Pusher webhooks
- In the Pusher dashboard, open your Channels app and go to its webhooks settings.
- Add your webhook URL. Use an
https://URL. - Turn on batch webhooks once your handler loops over every element of
events. - If you want
subscription_count, turn it on under App Settings. - Store the app's key and secret server-side, for example as
PUSHER_APP_KEYandPUSHER_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.
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:
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 verifies the
X-Pusher-Signatureheader, 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 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 onlyevents[0]. - Queue the events and return
2xxstraight 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 to inspect the batch, and set a maximum 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_msto 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, 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.
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.
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 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 for free and handle Pusher webhooks reliably in minutes.