Webhooks
Register an HTTPS endpoint with POST /v1/webhooks, pick the events, and store the
secret from the response — it is shown once and never again. Deliveries are signed,
best-effort, and never block the thing they report: an upload publishes and an alert sends whether or not your
endpoint answers.
The events
| Event | When it fires |
|---|---|
alert.sent
|
An alert became SENT: an immediate send, a draft published by hand, or a scheduled alert sent early. This is the moment the words are on their way to lock screens. |
alert.scheduled
|
Fires when the alert is SCHEDULED — not at its send_at instant. Nothing runs at that minute by design: the alert becomes visible because the inbox query’s send_at <= now() starts including it, which is what lets a server that was down deliver late rather than never. If you need something to happen at send_at, schedule it yourself from this event’s payload. |
alert.cancelled
|
An alert was withdrawn — with the usual caveat: it stops reaching phones that have not polled yet, and phones that already received it keep it. |
subscriber.created
|
Somebody subscribed to one of your channels. Carries the pusher_id and a count — never a device identity; subscribers are pseudonymous by construction. |
subscriber.deleted
|
Somebody unsubscribed. Same shape, same privacy: a pusher_id and a count, nothing device-shaped. |
What is deliberately absent: there is no alert.delivered and no
alert.failed, and there will not be — no per-device delivery row exists anywhere (a
phone's inbox is a query over sent alerts), so an event claiming a delivery would be fiction. An event added to
this list later is safe to subscribe to the day it appears: the 2.x policy adds events and never removes them.
The payload
{
"event": "alert.sent",
"created_at": "2026-08-18T12:00:00.000Z",
"data": { "pusher_id": "psh_7Fk2mQ3a", "...": "..." }
}
data is event-shaped; parse tolerantly — the 1.x policy adds fields and events, never
removes them. Subscriber events carry a pusher_id and a count, never anything
device-shaped.
Verifying a delivery
Every delivery carries X-Webhook-Signature: t=<unix>,v1=<hex> — an
HMAC-SHA256 over `${t}.${rawBody}` with your endpoint's secret. Versioned
(v1=) so the scheme can evolve without every receiver breaking on the same day: read
the v1 you know and ignore anything else.
import crypto from 'node:crypto';
// The signature covers `${timestamp}.${rawBody}` — NOT the body alone.
// Check the timestamp too: a signature over the body alone never expires,
// so anyone who captures one delivery can replay it for ever.
export function verify(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('='))
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Verify against the raw request body, before any JSON parsing touches it — a re-serialised body is a different byte sequence and a valid signature will not match it.
Delivery and failure
- One POST per event per endpoint, with a five-second timeout. No automatic retries — delivery is best-effort per event, and the numbers it reports remain queryable over the API.
- Redirects are not followed: an endpoint that 302s is a misconfiguration, and following it would re-POST a signed body to a URL you did not register.
- Endpoint URLs are validated at registration and re-checked at delivery time — a public hostname later repointed at a private address is refused, and that refusal counts as a failed delivery.
- Twenty consecutive failures disable the endpoint. Any success resets the counter to zero. A disabled endpoint shows as such in the studio with its failure count; remove it and register the corrected URL.