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.
- 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.
- 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-Type | application/json (UTF-8) |
User-Agent | iframe-webhook/1.0 |
X-IFrame-Event-Id | opaque, stable per event — evt_<24-32 base32>. Use as your idempotency key. |
X-IFrame-Signature | t=<unix>,v1=<64-hex> — HMAC-SHA256 over ${t}.${raw_body} |
X-IFrame-Synthetic | 1, 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 return | iFrame does |
|---|---|
200 / 2xx | ACK. Never retries the same event. |
4xx (≠ 408/425/429) | Hard failure. Never retries. Use for bad signatures or malformed input. |
408, 425, 429 | Transient. Back-off honored; retried. |
5xx | Transient. 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:
- Run
iframe instances webhook rotate <id>. Capture the new secret from the output. - Add the new secret to your receiver's secret store alongside the previous one.
- Reload (the reference receiver hot-reloads on
SIGHUP). - 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 →