Webhooks
Webhooks let your server react to real-time channel activity: when a channel gets its first subscriber, when a presence member joins, when a client event fires. Instead of polling, Soketify pushes the notification to you.
What Webhooks Cover
Soketify can fire a webhook for seven event types:
| Event | When it fires | Channel types |
|---|---|---|
channel_occupied | First subscriber joins a channel | All |
channel_vacated | Last subscriber leaves a channel | All |
member_added | A user subscribes to a presence channel | Presence |
member_removed | A user unsubscribes from a presence channel | Presence |
client_event | A client event is triggered by a subscriber | Private, presence |
cache_miss | A client subscribes to an empty cache channel | Cache |
subscription_count | A client subscribes to or unsubscribes from a channel (throttled to once every 5s above 100 subscribers) | Any |
Setting Up Webhooks
- Go to your app in the Dashboard and open the Settings tab.
- Under Webhooks, add your endpoint URL (must be HTTPS in production).
- Select which event types to send to that endpoint.
- Save. Soketify will immediately start sending POST requests to that URL when the selected events occur.
Info
You can configure multiple webhook endpoints for the same app, each subscribing to different event types. This lets you route, say, cache_miss events to a dedicated refill handler while sending presence events to your analytics service.
Webhook Payload Format
Every webhook is an HTTP POST with a JSON body. The top-level structure is always the same:
{
"time_ms": 1700000000000, // Unix timestamp in milliseconds
"events": [
// One or more event objects
]
}The events array can contain more than one event when batch webhooks are enabled; Soketify groups
Your endpoint must return a 2xx status code. Any other response is treated as a failure and Soketify will retry.
Event Payloads
channel_occupied / channel_vacated
{
"name": "channel_occupied", // or "channel_vacated"
"channel": "presence-chat-room-general"
}member_added / member_removed
{
"name": "member_added", // or "member_removed"
"channel": "presence-chat-room-general",
"user_id": "user-42"
}client_event
{
"name": "client_event",
"channel": "private-chat-42",
"event": "client-typing",
"data": "{"isTyping":true}",
"socket_id": "12345.67890",
"user_id": "user-42" // present on presence channels
}cache_miss
{
"name": "cache_miss",
"channel": "cache-vehicle-V-001"
}Verifying Webhook Signatures
Every webhook request includes two headers you should validate:
X-Pusher-KeyYour App Key. Use this to identify which app the webhook is for if you have multiple apps.X-Pusher-SignatureHMAC-SHA256 hex digest of the raw POST body, signed with your App Secret.
Always verify the signature before processing. This ensures the request actually came from Soketify and was not forged.
Verification: Node.js / Express
1const express = require("express");
2const crypto = require("crypto");
3
4const app = express();
5
6// Use raw body for signature verification
7app.use(
8 "/webhooks/soketify",
9 express.raw({ type: "application/json" }),
10 (req, res) => {
11 const signature = req.headers["x-pusher-signature"];
12 const appKey = req.headers["x-pusher-key"];
13 const rawBody = req.body; // Buffer
14
15 // Compute expected signature
16 const expected = crypto
17 .createHmac("sha256", process.env.SOKETIFY_APP_SECRET)
18 .update(rawBody)
19 .digest("hex");
20
21 if (signature !== expected) {
22 return res.status(401).json({ error: "Invalid signature" });
23 }
24
25 const payload = JSON.parse(rawBody.toString());
26 handleWebhookEvents(payload.events);
27
28 res.sendStatus(200);
29 }
30);
31
32function handleWebhookEvents(events) {
33 for (const event of events) {
34 switch (event.name) {
35 case "channel_occupied":
36 console.log("Channel active:", event.channel);
37 break;
38 case "channel_vacated":
39 console.log("Channel empty:", event.channel);
40 break;
41 case "member_added":
42 console.log("Member joined:", event.user_id, "→", event.channel);
43 break;
44 case "member_removed":
45 console.log("Member left:", event.user_id, "→", event.channel);
46 break;
47 case "client_event":
48 console.log("Client event:", event.event, "from", event.socket_id);
49 break;
50 case "cache_miss":
51 repopulateCache(event.channel);
52 break;
53 }
54 }
55}
Verification: Next.js App Router
1// app/api/webhooks/soketify/route.ts
2import { createHmac } from "crypto";
3import { NextRequest, NextResponse } from "next/server";
4
5export async function POST(req: NextRequest) {
6 const signature = req.headers.get("x-pusher-signature");
7 if (!signature) {
8 return NextResponse.json({ error: "Missing signature" }, { status: 401 });
9 }
10
11 const rawBody = await req.text();
12
13 const expected = createHmac("sha256", process.env.SOKETIFY_APP_SECRET!)
14 .update(rawBody)
15 .digest("hex");
16
17 if (signature !== expected) {
18 return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
19 }
20
21 const { events } = JSON.parse(rawBody);
22
23 for (const event of events) {
24 // Process events asynchronously if needed, but respond 200 first
25 void processEvent(event);
26 }
27
28 return NextResponse.json({ ok: true });
29}
30
31async function processEvent(event: { name: string; channel: string; user_id?: string }) {
32 if (event.name === "cache_miss") {
33 const data = await fetchCurrentState(event.channel);
34 await pusher.trigger(event.channel, "state-update", data);
35 }
36}
Verification: Laravel (PHP)
1// routes/api.php
2Route::post('/webhooks/soketify', function (Request $request) {
3 $signature = $request->header('X-Pusher-Signature');
4 $rawBody = $request->getContent();
5
6 $expected = hash_hmac('sha256', $rawBody, config('broadcasting.connections.pusher.secret'));
7
8 if (!hash_equals($expected, $signature)) {
9 return response()->json(['error' => 'Invalid signature'], 401);
10 }
11
12 $payload = json_decode($rawBody, true);
13
14 foreach ($payload['events'] as $event) {
15 match ($event['name']) {
16 'member_added' => MemberJoined::dispatch($event),
17 'member_removed' => MemberLeft::dispatch($event),
18 'cache_miss' => CacheMissed::dispatch($event),
19 default => null,
20 };
21 }
22
23 return response()->json(['ok' => true]);
24});
Retry Behavior
If your endpoint answers with anything other than a 2xx, or does not answer within 10 seconds, the delivery is retried with exponential backoff: after 5 seconds, then 15, 45, 120 and 180, which spans about six minutes. A delivery that still fails after that is set aside rather than silently dropped. Because a retry can duplicate a webhook your server already processed (it answered late, or answered with an error after doing the work), make your handler idempotent.
Respond fast, process async
Webhook processing should be quick. Return 200 immediately and do the heavy work (database writes, third-party API calls, cache repopulation) asynchronously in a background job or queue. An endpoint that takes longer than 10 seconds is treated as failed and retried, which is exactly how the same event ends up processed twice.
Presence departures wait ~3 seconds
member_removed and channel_vacated are held for about three seconds, and cancelled outright if the same member or channel comes back inside that window. That is what stops a phone changing networks from producing a removed/added pair on every blip. It is a reduction, not a guarantee: a client that reconnects to a different node is announced by that node, which knows nothing about the pending departure, so keep your handler tolerant of the occasional pair.
Common Patterns
Track online users in your database
1// member_added: mark user as online
2case "member_added":
3 await db.users.update({
4 where: { id: event.user_id },
5 data: { isOnline: true, lastSeenAt: new Date() },
6 });
7 break;
8
9// member_removed: mark user as offline
10case "member_removed":
11 await db.users.update({
12 where: { id: event.user_id },
13 data: { isOnline: false, lastSeenAt: new Date() },
14 });
15 break;
Log client events server-side
1// Without client event webhooks, client events bypass your server entirely.
2// With them, you can audit, log, or persist them.
3case "client_event":
4 await db.clientEventLog.create({
5 channel: event.channel,
6 eventName: event.event,
7 data: event.data,
8 socketId: event.socket_id,
9 userId: event.user_id || null,
10 timestamp: new Date(),
11 });
12 break;
Repopulate cache channels on demand
1// cache_miss fires when a client subscribes to an empty cache channel.
2// Soketify deduplicates, so you get one webhook even if 100 clients subscribe
3// simultaneously to an empty channel.
4case "cache_miss":
5 const channelName = event.channel; // e.g., "cache-vehicle-V-001"
6 const vehicleId = channelName.replace("cache-vehicle-", "");
7
8 const location = await db.vehicles.findOne({ id: vehicleId });
9 if (location) {
10 await pusher.trigger(channelName, "location-update", {
11 lat: location.lat,
12 lng: location.lng,
13 updatedAt: location.updatedAt,
14 });
15 }
16 break;
Next Steps
- Channel types : understanding the cache, presence, and private channels behind these events
- Events guide : client events and system events that trigger webhooks
- API Reference : trigger events from your webhook handler to repopulate cache channels