Webhooks

Vask sends webhooks so your backend can react to realtime activity in your app: subscriptions starting and ending, cache misses, subscription count changes, presence members joining and leaving, and client-published events on private and presence channels.

The webhook body and signing scheme are Pusher-compatible, so existing Pusher webhook integrations work with Vask with no changes beyond your endpoint URL and credentials.

#Dashboard setup

  1. Open your app in the Vask dashboard.
  2. Choose the Webhooks tab.
  3. Add a single endpoint URL for the app. One endpoint per app is supported on every plan.
  4. New endpoints enable high-signal event types by default. subscription_count is supported but opt-in because busy channels can generate frequent deliveries.
  5. Use the Send test button to deliver a synthetic event and confirm your endpoint accepts the payload and verifies the signature.

You can update the URL, toggle events, disable, or delete the endpoint at any time. Deletes take effect immediately and stop further deliveries.

#Events

The following event types are supported. All are enabled by default on new endpoints except subscription_count, which is opt-in:

  • channel_occupied — a channel transitions from zero to one or more subscribers.
  • channel_vacated — the last subscriber leaves a channel.
  • cache_miss — a client subscribes to a cache channel with no cached value.
  • member_added — a member joins a presence channel.
  • member_removed — a member leaves a presence channel.
  • client_event — a connected client publishes a client event.
  • subscription_count — a channel's subscription count changes.

client_event webhooks fire only for private and presence channels, where client events are permitted. They are never sent for public channels, because public channels do not support client events.

subscription_count events include the current subscriber count in their subscription_count field. Enable them only when you need per-channel count changes delivered to your server.

#Payload shape

Vask delivers a JSON body with a millisecond timestamp and an array of events. The shape mirrors Pusher's webhook format.

{
    "time_ms": 1736937600000,
    "events": [
        {
            "name": "channel_occupied",
            "channel": "presence-room.42"
        },
        {
            "name": "member_added",
            "channel": "presence-room.42",
            "user_id": "user-123"
        },
        {
            "name": "cache_miss",
            "channel": "cache-room.42"
        },
        {
            "name": "client_event",
            "channel": "private-room.42",
            "event": "client-typing",
            "data": "{\"is_typing\":true}",
            "socket_id": "12345.6789",
            "user_id": "user-123"
        },
        {
            "name": "subscription_count",
            "channel": "public-room.42",
            "subscription_count": 12
        }
    ]
}

Notes:

  • time_ms is the time Vask emitted the batch, in Unix milliseconds.
  • events is always an array. Endpoints should iterate it; do not assume a single event per request.
  • data on client_event is a JSON-encoded string, matching Pusher behavior. Decode it before use.
  • user_id is present on presence-channel events and on client_event payloads originating from authenticated presence clients.
  • subscription_count is present on subscription_count events and contains the current subscriber count for the channel.

#Signing headers

Each delivery includes two headers your endpoint should verify before trusting the body:

  • X-Pusher-Key — the app key the request is associated with.
  • X-Pusher-Signature — a lowercase hex HMAC-SHA256 of the raw request body, using your app secret as the HMAC key.

Verify by recomputing the HMAC over the exact bytes you received and comparing in constant time. Do not re-serialize the JSON before verifying — the signature is over the raw body.

The app secret never leaves Vask in plain text after creation. Store it as an environment variable in your backend, alongside your other Vask credentials.

#Verify in PHP / Laravel

$rawBody = $request->getContent();
$secret = (string) config('broadcasting.connections.pusher.secret');
$expected = hash_hmac('sha256', $rawBody, $secret);
$received = $request->header('X-Pusher-Signature', '');

if (! hash_equals($expected, $received)) {
    abort(401);
}

$payload = json_decode($rawBody, true);
foreach ($payload['events'] ?? [] as $event) {
    // Dispatch on $event['name']
}

return response()->noContent();

#Verify in Node

import crypto from 'node:crypto';

export function verify(rawBody, signatureHeader, secret) {
    const expected = crypto
        .createHmac('sha256', secret)
        .update(rawBody)
        .digest('hex');

    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(signatureHeader, 'hex');

    return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Capture the raw request body before any JSON parsing middleware mutates it, otherwise the signature will not match.

#Delivery guarantees and retries

  • At-least-once delivery. Your endpoint may receive the same event more than once. Make handlers idempotent (e.g. dedupe on time_ms plus event name and channel).
  • 2xx is success. Any 2xx response status, including 204 No Content, is treated as a successful delivery.
  • Non-2xx, network errors, and timeouts trigger retries with backoff for a bounded window. After the retry window is exhausted the attempt is recorded as failed in the dashboard delivery log.
  • No exactly-once semantics and no strict ordering. Events for the same channel may arrive out of order, especially around retries.
  • Bounded delivery logs. The dashboard surfaces recent attempts (status, latency, attempt number, next retry, final failure) so you can spot misbehaving endpoints.

Respond quickly. Do the minimum work needed to enqueue the payload and return 2xx; defer downstream processing to a queue or background worker.

#Endpoint URL policy

Webhook endpoints must use a public https:// URL.

Vask rejects plain HTTP, localhost/loopback URLs, private or link-local IPs, non-HTTP(S) protocols, and URLs containing credentials.

#Troubleshooting

  • Signature mismatch: confirm you are signing the raw body, not the re-encoded JSON, and that you are using the app secret matching X-Pusher-Key.
  • Repeated retries: check that your endpoint returns within the retry window and responds with 2xx on success. Long-running synchronous work is the most common cause.
  • Missing client_event deliveries: verify the channel is private or presence, that client events are enabled in your client SDK, and that the client_event event type is enabled on the endpoint.
  • No deliveries at all: confirm the endpoint is enabled, the URL passes URL policy validation, and check the dashboard delivery log for the most recent attempt and its error.

For a full client-side walkthrough see the Laravel integration guide or the Setup with agent prompt.

Prefer raw markdown? View this page as markdown.