Webhooks

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

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