Webhooks

LitePush POSTs a JSON payload to a URL on your server every time something happens to one of your notifications. Configure the URL in Project → Settings → Webhooks (Plus, Pro, and Max plans). The signing secret is auto-generated when the project is created and shown there too.

URL requirements

  • HTTPS only. Plain http:// is rejected.
  • Publicly reachable hostname or IP. Loopback (localhost, 127.x), RFC 1918 private ranges (10.x, 172.16–31.x, 192.168.x), link-local (169.254.x), and internal-style suffixes (.internal, .local, .lan, etc.) are rejected to prevent webhooks from being used to probe internal networks.
  • No credentials in the URL. Use Authorization headers on the receiving side instead.
  • No redirects. Your endpoint must respond directly; 3xx responses are treated as a delivery failure.

If your receiver runs behind a firewall, expose it via a tunnel (e.g. Cloudflare Tunnel) or use a hosted endpoint for testing — localhost URLs won't work even in development.

When webhooks fire

Four event types:

TypeFires when
deliveredLitePush successfully handed the push to the browser's push gateway.
clickedThe user clicked the notification (your service worker beaconed it via /v1/events).
dismissedThe user dismissed without clicking.
failedThe push could not be delivered — endpoint gone, gateway error, etc.

Payload shape

{
  "id": "evt_01HXM...",
  "type": "delivered",
  "project_id": "prj_01HXM...",
  "broadcast_id": "bdc_01HXM...",
  "subscriber_id": "sub_01HXM...",
  "external_id": "user_42",
  "meta": null,
  "created_at": 1716762345123
}

external_id is whatever you passed at subscribe time, or null. meta is null on success-style events (delivered, clicked, dismissed). On failed events it carries delivery diagnostics with one of three reason values:

Attribution caveat for clicked / dismissed. These two events are reported by the service worker, which only knows the broadcast — not which subscriber it's running for. So clicked and dismissed payloads always carry subscriber_id: null and external_id: null; correlate them by broadcast_id. Only delivered and failed (emitted server-side during fan-out) carry the full subscriber_id + external_id.

reasonMeaning
goneThe push gateway returned 404/410 — this subscriber's subscription is permanently dead. The subscriber row is auto-flipped to unsubscribed on our side. Customers usually delete or flag the corresponding user in their DB.
transient_exhaustedThe push gateway returned 5xx / 429 / network error, and our bounded inline retries also failed. The customer's subscription is still considered active — the next broadcast will retry from scratch. Treat as a soft failure; common during gateway hiccups.
errorA non-retryable error (e.g. VAPID-mismatch 4xx). The subscription is still active but the push didn't go out. Investigate via statusCode + error fields.

Headers

Every POST carries:

Content-Type:         application/json
User-Agent:           LitePush-Webhook/1
LitePush-Signature:   t=1716762345123,v1=a3f9c2d1...
LitePush-Event-Type:  delivered
LitePush-Event-Id:    evt_01HXM...

The two extra headers (Event-Type, Event-Id) are conveniences for log routing — the source of truth is the body.

Signature verification

The signature is HMAC-SHA256(timestamp + "." + body) using your project's signing secret, lowercase hex.

Verification — TypeScript / Node

import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.LITEPUSH_WEBHOOK_SECRET!;
const TOLERANCE_MS = 5 * 60_000;

function verify(rawBody: string, header: string | null): boolean {
  if (!header) return false;

  // Header format: "t=<ms>,v1=<hex>"
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=") as [string, string])
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Reject replays older than 5 minutes.
  if (Math.abs(Date.now() - t) > TOLERANCE_MS) return false;

  const expected = createHmac("sha256", SECRET)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  // Constant-time compare — NEVER use === here.
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Verification — Cloudflare Workers / Web Crypto

If you're on Cloudflare Workers or another edge runtime without node:crypto:

async function verify(rawBody: string, header: string | null, secret: string) {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=") as [string, string])
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() - t) > 5 * 60_000) return false;

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sigBuf = await crypto.subtle.sign(
    "HMAC",
    key,
    new TextEncoder().encode(`${t}.${rawBody}`),
  );
  const expected = [...new Uint8Array(sigBuf)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  if (expected.length !== v1.length) return false;
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    diff |= expected.charCodeAt(i) ^ v1.charCodeAt(i);
  }
  return diff === 0;
}

Handler best practices

// Hono on Cloudflare Workers
app.post("/webhooks/litepush", async (c) => {
  // CRITICAL: read the raw body, not parsed JSON. The signature was
  // computed over the exact bytes we sent — parsing then re-serialising
  // changes whitespace and breaks the HMAC.
  const rawBody = await c.req.text();
  const sigHeader = c.req.header("LitePush-Signature");

  if (!(await verify(rawBody, sigHeader, c.env.LITEPUSH_WEBHOOK_SECRET))) {
    return c.json({ error: "invalid signature" }, 401);
  }

  const event = JSON.parse(rawBody);

  // Idempotency: retries deliver the same event.id with a NEW signature
  // timestamp. Dedupe by event.id on your side.
  if (await alreadyProcessed(event.id)) {
    return c.json({ ok: true }); // ack so we stop retrying
  }
  await markProcessed(event.id);

  // ... your business logic ...

  // Return 200 fast — heavy work should be enqueued, not awaited inline.
  return c.json({ ok: true });
});

Retries

Non-2xx responses (or timeouts past 10s) get retried up to 5 times with exponential backoff. After 5 attempts the message is dropped — we don't queue indefinitely.

If your endpoint is down for hours, you'll lose any events that fired during the outage. Plan accordingly — for high-stakes signals consider also polling the dashboard's broadcast detail endpoint.

Rotating the signing secret

Project → Settings → Webhooks → Rotate signing secret. Generates a fresh whsec_* and invalidates the old one immediately. In-flight queue messages (a few seconds) still carry the old secret, so brief overlap is expected — rotate during a low-traffic window.