Webhooks
Webhooks tell your backend what happens on your channels: when a channel gets its first subscriber or loses its last, when a user joins or leaves a presence channel, when a client sends an event, and when a subscriber finds a cache channel empty. Apps rely on them to mirror presence and occupancy in their own store.
Add an endpoint
In the dashboard's Webhooks tab (Starter plan and up), add up to 10 https:// endpoints per app and choose the kinds of events each one receives. Changes apply within a minute.
| Kind | Events | Sent |
|---|---|---|
| Channel existence | channel_occupied, channel_vacated | When a channel gets its first subscriber, and when it has had none for 5 seconds |
| Presence | member_added, member_removed | When a user joins a presence channel, and when their last connection leaves it |
| Client events | client_event | For every client event. Off unless you choose it |
| Cache misses | cache_miss | When a subscriber finds a cache channel empty, once until the next event |
Every request is a POST of JSON carrying one or more events. See webhook events for each event's fields.
Verify requests
Verify every request with @thunderclapjs/server before trusting it. Pass the raw body, exactly as received, and the Thunderclap-Signature header; you get the events back, typed.
import { ThunderclapWebhookError } from "@thunderclapjs/server";
app.post("/thunderclap/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
try {
const { events } = await thunderclap.verifyWebhook({
body: req.body,
signature: req.get("Thunderclap-Signature"),
});
for (const event of events) {
if (event.type === "member_added") await markOnline({ user: event.userId, room: event.channel });
if (event.type === "member_removed") await markOffline({ user: event.userId, room: event.channel });
}
res.sendStatus(200);
} catch (err) {
res.sendStatus(err instanceof ThunderclapWebhookError ? 401 : 500);
}
});With a standard Request (Next.js route handlers, Cloudflare Workers, Bun, Deno), it's one call:
export async function POST(request: Request) {
const { events } = await thunderclap.verifyWebhookRequest(request);
// ...
return new Response(null, { status: 200 });
}Delivery
| Behaviour | Detail |
|---|---|
| Signatures | Each request is signed with every active secret, so it verifies during a rotation |
| Replays | Requests signed more than 5 minutes earlier are refused by verifyWebhook |
| Timeout | Answer with a 2xx status within 10 seconds |
| Retries | Failed requests are retried with increasing delays, up to 10 times |
| Duplicates | A retry can deliver an event twice: skip events whose id you've already handled |
| Order | Events can arrive out of order: rely on each event's time |
Migrating from Pusher
An endpoint can use Pusher's format instead, so an existing Pusher webhook handler keeps working. See Migrating from Pusher.