Loading...
Loading...
Signed deliveries pushed as devices report, and how to verify them.
Rather than polling, register an https endpoint and we will POST to it as devices report. Add one in Settings → Developer, then use the Send test button to check your receiver before a real device depends on it.
device.stateA device published new state — a relay flipped, a level changed.device.telemetryA device published a telemetry sample.device.onlineA device connected to the broker.device.offlineA device's last-will fired, or it disconnected.plate.readAn ANPR camera read a number plate. Carries the plate, direction, decision and visit — so a receiver never has to re-derive the pairing. Deliberately NOT also delivered as device.telemetry: an integration should not subscribe to every power reading in the fleet to find plate reads.POST /your/endpoint
X-Circuvent-Event: device.state
X-Circuvent-Signature: t=1785312764,v1=8f3c…
{
"id": "evt_9Kd2mQpX7bTz",
"event": "device.state",
"deviceId": "hub-a1b2",
"data": { "power": true, "power2": false },
"at": "2026-08-03T09:12:44.201Z"
}Always verify the signature before trusting the body. Anyone can POST to your URL; the HMAC is what proves it came from us. The timestamp is inside the signed material, so a captured delivery cannot be replayed later with a fresh one.
import crypto from "node:crypto";
import express from "express";
const app = express();
// The signature covers the RAW body. Parsing it to JSON and re-serialising
// changes the bytes (key order, whitespace) and the HMAC will not match.
app.post("/hooks/circuvent", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Circuvent-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const body = req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", process.env.CIRCUVENT_WEBHOOK_SECRET)
.update(`${parts.t}.${body}`)
.digest("hex");
// timingSafeEqual, not ===. A plain comparison returns early on the first
// differing byte, which leaks the correct prefix to anyone who can measure it.
const ok =
parts.v1 &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!ok) return res.status(400).send("bad signature");
// Reject anything older than five minutes so a captured delivery cannot be
// replayed at you later.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
return res.status(400).send("stale");
}
const event = JSON.parse(body);
console.log(event.event, event.deviceId, event.data);
// Answer 2xx quickly. We time out after 5 seconds, and 20 consecutive
// failures disable the webhook.
res.sendStatus(200);
});We wait 5 seconds for a 2xx. Non-2xx responses count as failures, and 20 consecutive failures disable the webhook — a dead endpoint would otherwise burn a socket and five seconds for every device message, forever. Re-enable it in the console once the receiver is healthy; that also resets the counter. Redirects are not followed, and the URL must resolve to a publicly routable address.