Webhooks
Configure one or more webhook URLs on the project page. Every enabled webhook receives a JSON POST signed with HMAC-SHA256 for each event. Failed deliveries (5xx / 408 / 429 / network errors) are retried with exponential backoff (30s → 2m → 10m → 1h, 5 attempts max). The X-Nesh-Attempt header counts the attempt number when retried.
Each webhook can be filtered to a subset of event types from the project page. The default (all checkboxes selected) subscribes the webhook to every event.
Events
notification.sent | After a notification finishes sending (dashboard / REST / cron). |
subscription.created | A brand-new endpoint subscribed (re-subscribes do not refire). |
subscription.removed | A subscription was removed — by the client (DELETE) or because the push service returned 404/410. |
notification.sent
POST <your-webhook-url>
Content-Type: application/json
X-Nesh-Event: notification.sent
X-Nesh-Signature: sha256=<hex hmac of body>
{
"type": "notification.sent",
"notification": {
"id": "uuid",
"project_id": "uuid",
"title": "Hello",
"body": "From Nesh",
"url": "https://example.com",
"delivered": 11,
"removed": 1,
"failed": 0,
"target_user_ids": ["alice"],
"sent_at": "2026-05-05T18:00:00.000Z"
}
}subscription.created
X-Nesh-Event: subscription.created
{
"type": "subscription.created",
"subscription": {
"id": "uuid",
"project_id": "uuid",
"endpoint": "https://fcm.googleapis.com/...",
"external_user_id": "alice",
"created_at": "2026-05-05T18:00:00.000Z"
}
}subscription.removed
reason: "client" means the SDK / dashboard called DELETE; reason: "expired" means the push service returned 404/410 during a send and Nesh dropped the dead subscription automatically.
X-Nesh-Event: subscription.removed
{
"type": "subscription.removed",
"subscription": {
"project_id": "uuid",
"endpoint": "https://fcm.googleapis.com/...",
"external_user_id": "alice",
"reason": "client",
"removed_at": "2026-05-05T18:00:00.000Z"
}
}Verifying the signature (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(body, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && timingSafeEqual(a, b);
}Always compare bodies as raw bytes (read the request body before parsing JSON). Webhook delivery has a 5-second timeout; non-2xx responses are recorded as the last delivery error and shown on the dashboard.