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 six event types:

EventWhen it firesChannel types
channel_occupiedFirst subscriber joins a channelAll
channel_vacatedLast subscriber leaves a channelAll
member_addedA user subscribes to a presence channelPresence
member_removedA user unsubscribes from a presence channelPresence
client_eventA client event is triggered by a subscriberPrivate, presence
cache_missA client subscribes to an empty cache channelCache

Setting Up Webhooks

  1. Go to your app in the Dashboard and open the Settings tab.
  2. Under Webhooks, add your endpoint URL (must be HTTPS in production).
  3. Select which event types to send to that endpoint.
  4. 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:

Webhook payload
json
{
  "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

json
{
  "name": "channel_occupied",  // or "channel_vacated"
  "channel": "presence-chat-room-general"
}

member_added / member_removed

json
{
  "name": "member_added",  // or "member_removed"
  "channel": "presence-chat-room-general",
  "user_id": "user-42"
}

client_event

json
{
  "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

json
{
  "name": "cache_miss",
  "channel": "cache-vehicle-V-001"
}

Verifying Webhook Signatures

Every webhook request includes two headers you should validate:

  • X-Pusher-Key Your App Key. Use this to identify which app the webhook is for if you have multiple apps.
  • X-Pusher-Signature HMAC-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

webhook-handler.js
javascript
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

app/api/webhooks/soketify/route.ts
typescript
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)

routes/api.php
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 responds with anything other than a 2xx status, Soketify retries with exponential backoff for up to 5 minutes. After that, the webhook is dropped.

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. If your endpoint takes too long, Soketify may time out and retry, which can cause duplicate processing.

Presence delay

There is a ~3 second delay between a user disconnecting and the member_removed or channel_vacated webhook firing. This grace period prevents a flood of spurious events from momentary network blips where clients immediately reconnect.

Common Patterns

Track online users in your database

track-online-status.js
javascript
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

log-client-events.js
javascript
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

cache-refill.js
javascript
1// cache_miss fires when a client subscribes to an empty cache channel.
2// Soketify deduplicates — 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