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.

KindEventsSent
Channel existencechannel_occupied, channel_vacatedWhen a channel gets its first subscriber, and when it has had none for 5 seconds
Presencemember_added, member_removedWhen a user joins a presence channel, and when their last connection leaves it
Client eventsclient_eventFor every client event. Off unless you choose it
Cache missescache_missWhen 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.

server.ts
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:

app/api/thunderclap/route.ts
export async function POST(request: Request) {
  const { events } = await thunderclap.verifyWebhookRequest(request);
  // ...
  return new Response(null, { status: 200 });
}

Delivery

BehaviourDetail
SignaturesEach request is signed with every active secret, so it verifies during a rotation
ReplaysRequests signed more than 5 minutes earlier are refused by verifyWebhook
TimeoutAnswer with a 2xx status within 10 seconds
RetriesFailed requests are retried with increasing delays, up to 10 times
DuplicatesA retry can deliver an event twice: skip events whose id you've already handled
OrderEvents 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.