Notifications

Get a signed POST when a node is about to go away.

Configure a webhook per instance. iFrame sends a signed JSON POST with one of three severities — advisory, warning, offline — and four fields. No provider names, no machine types, no region codes ever cross the wire.

Quickstart

From zero to verified delivery in two commands.

  1. Configure. Point an instance at your HTTPS endpoint and pick which severities you care about. iFrame mints a per-instance HMAC secret and prints it once.
  2. Test. Send a synthetic event and confirm your receiver returned HTTP 200.
iframe instances webhook set inst-... \
  --url https://hooks.example.com/iframe \
  --events advisory,warning,offline

# → Webhook configured.
# →   Secret id  : whsec_ab12cd34 (v1)
# →   Secret     : 7f3a1b9c7d5e2f4a6b8c0d3e1f5a7b9c   ← shown once

iframe instances webhook test inst-... --event warning
# → Delivered   : yes
# → HTTP status : 200

Event taxonomy

Three severities. Three fixed messages.

iFrame collapses every node-disruption signal into three severities. That's the whole taxonomy. Subscribe to any subset per instance.

event message (verbatim) Meaning Recommended action
advisory Possible node disruption detected Early signal that the node may become unavailable. Not a guarantee. Checkpoint state. Stop sending new work to this node. Let in-flight work continue.
warning Node disruption imminent The node will become unavailable very shortly. Last actionable signal. Fail over NOW. Persist in-flight state. Hand off requests.
offline Node is no longer available The node is already unreachable. Mark dead. Reschedule. Release downstream allocations.

Events for the same node may arrive out of order (e.g. a delayed advisory after a warning). Treat the highest severity you've seen as ground truth, and use X-IFrame-Event-Id as your idempotency key.

Wire format

Four fields. Nothing else.

Headers

Content-Typeapplication/json (UTF-8)
User-Agentiframe-webhook/1.0
X-IFrame-Event-Idopaque, stable per event — evt_<24-32 base32>. Use as your idempotency key.
X-IFrame-Signaturet=<unix>,v1=<64-hex> — HMAC-SHA256 over ${t}.${raw_body}
X-IFrame-Synthetic1, present only for test events

Body

{
  "event":     "advisory" | "warning" | "offline",
  "ip":        "203.0.113.42",
  "timestamp": "2026-05-21T01:13:33.530Z",
  "message":   "Node disruption imminent"
}

ip is the affected node's public IP or null if it has none. timestamp is iFrame's observation time, ISO-8601 UTC, millisecond precision. No other fields will ever appear on this surface.

Signature

HMAC-SHA256 over ${t}.${raw_body}.

Sign the exact raw bytes of the request body. Never re-serialize the JSON before hashing — key order or whitespace would change the bytes and break verification. Then check |now − t| ≤ 300 s to reject replays.

Node.js

const expected = crypto
  .createHmac("sha256", secret)
  .update(`${ts}.${rawBody.toString("utf8")}`)
  .digest("hex");
const ok = crypto.timingSafeEqual(
  Buffer.from(expected, "hex"),
  Buffer.from(v1, "hex")
);

Python

import hmac, hashlib
expected = hmac.new(
  secret.encode(),
  f"{ts}.{raw_body.decode()}".encode(),
  hashlib.sha256,
).hexdigest()
ok = hmac.compare_digest(expected, v1)

Go

mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.%s", ts, rawBody)
ok := hmac.Equal(
  []byte(hex.EncodeToString(mac.Sum(nil))),
  []byte(v1),
)

Reference receiver

Drop-in Node receiver. Zero dependencies.

Save as iframe-receiver.js, set IFRAME_SECRETS_FILE to a JSONL of {resource_id, secret_id, secret, public_ip} rows, run. The handler verifies the signature against every loaded secret in constant time, identifies which node the event is for by which secret matched, dedupes by X-IFrame-Event-Id, and rejects replays older than 5 minutes.

"use strict";
const fs = require("node:fs");
const http = require("node:http");
const crypto = require("node:crypto");

const PORT = Number(process.env.IFRAME_PORT || 8443);
const URL_PATH = process.env.IFRAME_PATH || "/iframe-events";
const SECRETS_FILE = process.env.IFRAME_SECRETS_FILE;
const MAX_SKEW_SEC = 300;

const SECRETS = fs
  .readFileSync(SECRETS_FILE, "utf8")
  .split(/\r?\n/)
  .filter(Boolean)
  .map((l) => JSON.parse(l))
  .filter((r) => r.secret && r.resource_id);

const seen = new Map();
function alreadySeen(id) {
  const ttl = 30 * 60_000;
  const ts = seen.get(id);
  if (ts && Date.now() - ts < ttl) return true;
  seen.set(id, Date.now());
  return false;
}

const SIG_RE = /^t=(\d+),v1=([0-9a-f]{64})$/;
function verify(sig, raw) {
  const m = SIG_RE.exec(sig || "");
  if (!m) return null;
  const ts = Number(m[1]);
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > MAX_SKEW_SEC) return null;
  const provided = Buffer.from(m[2], "hex");
  const signed = `${ts}.${raw.toString("utf8")}`;
  for (const s of SECRETS) {
    const mac = crypto.createHmac("sha256", s.secret).update(signed).digest();
    if (mac.length === provided.length &&
        crypto.timingSafeEqual(mac, provided)) {
      return { resource_id: s.resource_id, public_ip: s.public_ip };
    }
  }
  return null;
}

function readBody(req) {
  return new Promise((res, rej) => {
    let n = 0; const chunks = [];
    req.on("data", (c) => { n += c.length; if (n > 32768) { req.destroy(); rej(new Error("too large")); } else chunks.push(c); });
    req.on("end", () => res(Buffer.concat(chunks)));
    req.on("error", rej);
  });
}

http.createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== URL_PATH) {
    return res.writeHead(404).end();
  }
  let raw;
  try { raw = await readBody(req); }
  catch { return res.writeHead(400).end(); }

  const eventId = String(req.headers["x-iframe-event-id"] || "");
  if (!eventId) return res.writeHead(400).end();

  const v = verify(req.headers["x-iframe-signature"], raw);
  if (!v) return res.writeHead(401).end();
  if (alreadySeen(eventId)) return res.writeHead(200).end('{"ok":true,"deduped":true}');

  const payload = JSON.parse(raw.toString("utf8"));

  // === your business logic here ===
  // payload.event ∈ {advisory, warning, offline}
  // payload.ip, payload.timestamp, payload.message
  // v.resource_id identifies which node this is for
  console.log(`[iframe] ${eventId} ${payload.event} node=${v.resource_id} ip=${payload.ip}`);

  res.writeHead(200, { "content-type": "application/json" });
  res.end('{"ok":true}');
}).listen(PORT, () => console.log(`listening :${PORT}${URL_PATH}`));

Keep the response under 3 seconds — anything slower counts as failure and triggers a retry. The HMAC trial loop is microseconds even with hundreds of secrets, so push the queue write and process the event asynchronously.

Response contract

What iFrame does with your response.

You returniFrame does
200 / 2xxACK. Never retries the same event.
4xx (≠ 408/425/429)Hard failure. Never retries. Use for bad signatures or malformed input.
408, 425, 429Transient. Back-off honored; retried.
5xxTransient. Retried up to 3× for advisory/warning, 5× for offline. Exponential backoff with jitter (200 ms → 60 s).
timeout (> 3 s)Attempt aborted. Counted as failure; retry policy applies.

CLI reference

iframe instances webhook *

Activation is CLI-only and per-instance. The API key needs the instances:webhook scope.

iframe instances webhook set <id>

Configure (or replace) the webhook on one instance. Mints a fresh per-instance HMAC signing secret and prints it once — store it then; it cannot be retrieved later.

iframe instances webhook set <id> \
  --url <https-url> \
  --events advisory,warning,offline

  --url     required; must be https://, public DNS, no credentials in the URL
  --events  required; comma-separated subset of advisory,warning,offline

Calling set again replaces the URL and event set, rotates the secret, and bumps secret_version. Refuses with exit 2 on a non-HTTPS URL, an IP literal, or any private/loopback address.

iframe instances webhook get <id>

Show the current configuration: URL, subscribed events, secret id and version, last success / failure timestamps, kill-switch state. The secret itself is never echoed.

iframe instances webhook get inst-...
# Webhook       : enabled
# URL           : https://hooks.example.com/iframe
# Events        : advisory, warning, offline
# Secret id     : whsec_ab12cd34 (v2)
# Last success  : 2026-05-21T01:13:33.139Z

iframe instances webhook rotate <id>

Rotate the signing secret. The new secret is printed once. After rotation the previous secret is no longer valid — deploy the new one to your verifier first if you can't tolerate a verification gap.

iframe instances webhook rotate inst-...

iframe instances webhook test <id>

Send a synthetic event to the configured URL. The wire format is identical to a real delivery; the only difference is the X-IFrame-Synthetic: 1 header.

iframe instances webhook test <id> [--event advisory|warning|offline]

  --event   defaults to advisory
# Test event  : warning
# Event id    : evt_pf7m9ny86zre84whc7n7w29fjmf8c
# Delivered   : yes
# Attempts    : 1
# HTTP status : 200

iframe instances webhook deliveries <id>

Recent delivery attempts (newest first). One row per attempt, so retries appear as separate rows under the same event_id.

iframe instances webhook deliveries inst-... [--limit N]

# 2026-05-21T01:13:33.530Z  warning   attempt 1  ok  HTTP 200  3ms
# 2026-05-21T01:13:32.019Z  advisory  attempt 1  ok  HTTP 200  6ms
# 2026-05-21T01:13:29.442Z  warning   attempt 1  ok  HTTP 200  29ms  [synthetic]

iframe instances webhook remove <id>

Remove the webhook configuration entirely. The instance stops receiving events immediately; the signing secret is destroyed.

iframe instances webhook remove inst-...

Rotation

Zero-downtime secret rotation.

Each instance has its own HMAC secret. The shipped receiver tries every loaded secret per request and matches in microseconds, so the standard rotation procedure is:

  1. Run iframe instances webhook rotate <id>. Capture the new secret from the output.
  2. Add the new secret to your receiver's secret store alongside the previous one.
  3. Reload (the reference receiver hot-reloads on SIGHUP).
  4. After a comfortable window, drop the previous secret.

If your verifier can't load multiple secrets, rotate during a quiet period — there's no in-flight signature window on iFrame's side.

Observability

What's recorded, what's queryable.

Delivery log

Every attempt — including retries — is recorded with timestamp, event id, HTTP status, latency, and reason on failure. Inspect from the CLI:

iframe instances webhook deliveries inst-... --limit 100

Rows are retained for 30 days. Synthetic deliveries are flagged with [synthetic].

Per-instance counters

webhook get surfaces last_success_at, last_failure_at, last_failure_reason, and last_failure_status. Useful as a single-shot health check from monitoring.

A 60-deliveries-per-minute per-instance rate limit protects customer endpoints from accidental bursts; over-limit attempts are dropped with reason: rate_limited and visible in the delivery log.

Security

What the payload will never contain.

Per-instance secrets

Every instance has its own HMAC-SHA256 signing secret. Compromising one secret cannot forge events from any other instance. Rotation is one CLI command.

Provider-agnostic payload

No provider names, no service names, no region codes, no instance-id prefixes, no lifecycle vocabulary. Enforced by a runtime guard and a build-time CI lint over the entire webhook source tree.

URL hygiene

iFrame only accepts https:// URLs with a public DNS hostname. Loopback, RFC1918, link-local, and IP literals are rejected at webhook set time.

Replay protection

The signature carries a unix timestamp. Verifiers should reject anything outside a ±5-minute window. Combined with X-IFrame-Event-Id dedupe, the same logical event is processed at most once.

Opaque idempotency key

X-IFrame-Event-Id is a salted HMAC of an internal handle — preimage-resistant, so the id carries zero information about iFrame's internals. Same input always produces the same id (stable for dedupe).

Kill switch

Operators can suspend deliveries to a misbehaving customer endpoint without changing the configuration. Suspension is auditable; restoration is one privileged call.

One CLI command. Signed in microseconds.

Configure a webhook on any instance and let iFrame tell you when to move.

Issue an API key →