# 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.

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

```php
$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

```js
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](/docs/laravel) or the [Setup with agent](/docs/agent) prompt.
