Reference
Webhooks
POST to your endpoint when something happens in a project: a revision is published, an approval is requested or decided, a key or a flag changes.On this page
Set up an endpoint
Admins add webhooks under Settings → Notifications: an https:// URL and the events to receive (none selected = all events). The signing secret is shown once, when the webhook is created; store it in your secret manager. Webhooks are part of the Team plan and above; endpoints added earlier keep delivering after a downgrade until you disable or delete them. Every delivery attempt is listed under the webhook's deliveries in the dashboard.
Events
| Event (NanoFlags-Event, body.type) | Sent when | body.action |
|---|---|---|
publish.completed | A revision was published (dashboard or admin key). | revision.published |
approval.requested | A change was submitted for approval. | change.awaiting_approval |
approval.decided | An approver approved or rejected a change. | approval.approved, approval.rejected |
key.lifecycle | A key was created or revoked. | key.created, key.revoked |
flag.lifecycle | A flag was created, archived or restored. | flag.created, flag.archived, flag.restored |
schedule.executed | A scheduled publish ran and published its pinned draft (detail: schedule_id, run_at, revision, delay_ms). | schedule.executed |
schedule.failed | A scheduled publish did not publish: the draft or the destination changed, approvals or scenario checks were not met, or the scheduler lost access (detail: schedule_id, code, message). Nothing was published. | schedule.failed |
Payload
{
"id": "evt_48213",
"type": "publish.completed",
"action": "revision.published",
"occurred_at": "2026-11-10T09:30:00.123456+00:00",
"project": "Commerce",
"environment": "Production",
"actor": "Mona Haddad",
"object": { "type": "revision", "id": "13", "label": "r13" },
"summary": "published r13 to Production",
"detail": { "revision": 13, "checksum": "9c41…", "change_id": "2b7e…" }
}project,environmentandactorare display names (the actor's name or e-mail address);environmentandactorcan benull(for example flag events, which are project-wide, or actions taken with a key).detaildepends on the action. Before/after snapshots are never included.- Events are not ordered: use
occurred_at, not arrival order.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | NanoFlags-Webhooks/1 |
NanoFlags-Event | Event name, e.g. publish.completed. |
NanoFlags-Delivery | The event id (evt_…, equal to body.id). Identical on every retry of the same event. |
NanoFlags-Timestamp | Unix seconds at which this attempt was signed. |
NanoFlags-Signature | v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")> |
Verify the signature
Compute the HMAC over the raw request bytes (not re-serialized JSON), compare in constant time, and reject timestamps more than five minutes from your clock to stop replays.
// Node 18+, no dependencies
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.NANOFLAGS_WEBHOOK_SECRET; // shown once when the webhook is created
const seen = new Set(); // use your database or a cache with a TTL of a few days in production
function verify(rawBody, timestamp, signature, toleranceSeconds = 300) {
if (!timestamp || !signature || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = Buffer.from("v1=" + createHmac("sha256", SECRET).update(`${timestamp}.${rawBody}`).digest("hex"));
const given = Buffer.from(signature);
return given.length === expected.length && timingSafeEqual(given, expected);
}
createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8");
if (!verify(raw, req.headers["nanoflags-timestamp"], req.headers["nanoflags-signature"])) {
res.writeHead(401).end();
return;
}
const delivery = req.headers["nanoflags-delivery"];
if (!seen.has(delivery)) {
seen.add(delivery);
const event = JSON.parse(raw);
// handle event.type / event.action here, or enqueue it and return quickly
}
res.writeHead(204).end(); // any 2xx acknowledges, also for a duplicate
});
}).listen(8080);In a Cloudflare Worker, Deno, Bun or Node with WebCrypto, compute the same HMAC with crypto.subtle (importKey with { name: "HMAC", hash: "SHA-256" }, then sign) and compare the hex digest in constant time.
Retries and at-least-once delivery
- Success is any
2xxanswered within 10 seconds. Everything else (other status codes, timeouts, connection errors) is a failed attempt. Acknowledge first and do slow work asynchronously. - Redirects are not followed. A
3xxis a failed attempt; register the final URL. - Retries: up to 6 attempts. After a failed attempt the next one is scheduled 1, 2, 4, 8 and 16 minutes later (about 31 minutes in total, plus up to a minute of scheduling granularity each). After the sixth failure the event is dropped.
- At-least-once: an event can arrive more than once (a retry after a response that did not arrive in time, or an interrupted delivery), and events are not ordered.
- A delivery whose webhook has been deleted in the meantime is dropped without a request.
De-duplication
De-duplicate on NanoFlags-Delivery (equal to body.id): it stays the same across attempts, while NanoFlags-Timestamp and NanoFlags-Signature change. Keep seen ids for a few days, and answer 2xx for a duplicate so it is not retried.
Allowed targets
Targets must be https:// on a public host. Loopback, private, link-local and other reserved addresses (IPv4 and IPv6 literals, including IPv4-mapped forms), localhost, single-label names, .local / .internal style suffixes, URLs with credentials, and privileged ports other than 443 are refused; such a delivery fails without retry.