Skip to content
NanoFlags
Start free

Reference

Webhooks

NanoFlags sends a signed HTTPS 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.

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 whenbody.action
publish.completedA revision was published (dashboard or admin key).revision.published
approval.requestedA change was submitted for approval.change.awaiting_approval
approval.decidedAn approver approved or rejected a change.approval.approved, approval.rejected
key.lifecycleA key was created or revoked.key.created, key.revoked
flag.lifecycleA flag was created, archived or restored.flag.created, flag.archived, flag.restored
schedule.executedA scheduled publish ran and published its pinned draft (detail: schedule_id, run_at, revision, delay_ms).schedule.executed
schedule.failedA 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

json
{
  "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, environment and actor are display names (the actor's name or e-mail address); environment and actor can be null (for example flag events, which are project-wide, or actions taken with a key).
  • detail depends on the action. Before/after snapshots are never included.
  • Events are not ordered: use occurred_at, not arrival order.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentNanoFlags-Webhooks/1
NanoFlags-EventEvent name, e.g. publish.completed.
NanoFlags-DeliveryThe event id (evt_…, equal to body.id). Identical on every retry of the same event.
NanoFlags-TimestampUnix seconds at which this attempt was signed.
NanoFlags-Signaturev1=<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.

webhook-receiver.mjs
// 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 2xx answered 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 3xx is 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.

Publishes, approval requests and decisions, and key events can also be sent by e-mail; each member chooses which ones in their notification preferences.