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
Authorizationheaders 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:
| Type | Fires when |
|---|---|
delivered | LitePush successfully handed the push to the browser's push gateway. |
clicked | The user clicked the notification (your service worker beaconed it via /v1/events). |
dismissed | The user dismissed without clicking. |
failed | The 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. Soclickedanddismissedpayloads always carrysubscriber_id: nullandexternal_id: null; correlate them bybroadcast_id. Onlydeliveredandfailed(emitted server-side during fan-out) carry the fullsubscriber_id+external_id.
reason | Meaning |
|---|---|
gone | The 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_exhausted | The 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. |
error | A 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.