Webhooks push events from Muvia to your system as they happen, so you do not have to poll. Muvia POSTs a signed JSON payload to your HTTPS endpoint for every event type the endpoint subscribes to.
A company administrator registers endpoints in Muvia, under Settings → Company → Webhooks. Each endpoint has:
https and reachable on a public
address (see Destinations);mvwh_ followed by 64 hex characters.The secret is shown once: when the endpoint is created, and again each time it is rotated. Muvia never shows it anywhere else, so a lost secret is rotated, not recovered. A rotation takes effect immediately, with no period in which the old secret still works: update your receiver at the same moment. Until it has the new secret, your receiver rejects every delivery, and one rejected with a 401 is not retried (see retries).
| Event | When | data |
|---|---|---|
nodes.node_offline | A node stopped sending heartbeats for longer than the configured window. Sent once, when the node goes offline — not on every check. | node_id — the node's id, as the nodes endpoints return it. |
post_processing.run_finished | A post-processing flow run reached a final state. | flow_run_id, flow_run_name, state — one of COMPLETED, FAILED, CRASHED, CANCELLED. |
The console offers exactly the event types an endpoint can subscribe to. Notifications about a person's own account — a password change, a sign-in factor removed, sessions ended — are never sent to a webhook.
Every delivery is a POST with a JSON body:
{
"id": "evt_9f2c1b7d4e5a6b8c9d0e1f2a",
"type": "nodes.node_offline",
"created_at": "2026-09-21T10:14:33.912+00:00",
"company_id": "66f0a1b2c3d4e5f607182930",
"project_id": "66f1a2b3c4d5e6f708192a3b",
"data": { "node_id": "node-17" }
}
data holds the event's facts and nothing more: never a recipient, a
username or an address. For the full current state of what the event is
about, ask the API.
And these headers:
| Header | Meaning |
|---|---|
Content-Type | application/json |
User-Agent | Muvia-Webhooks/1.0 |
X-Muvia-Event | The event type, e.g. nodes.node_offline. |
X-Muvia-Event-Id | The event's id: the same on every delivery of one event, across retries and endpoints. |
X-Muvia-Delivery-Id | One per event and endpoint; the same across that delivery's retries. |
X-Muvia-Signature | t=<unix seconds>,v1=<hex> — see below. |
X-Muvia-Signature: t=<unix seconds>,v1=<hex>
hex = HMAC_SHA256(secret, "<t>." + raw_body)
The timestamp is inside the signed material, so a captured request cannot be
re-dated: change t and the digest no longer matches. Reject a signature more
than 300 seconds from your clock — that is the tolerance Muvia publishes;
a longer one is a longer replay window.
Always verify the raw request body, before any JSON parsing: re-serialising changes the bytes. Compare in constant time.
Python:
import hashlib, hmac, time
TOLERANCE_SECONDS = 300 # what Muvia publishes; a longer one is a longer replay window
def verify(secret: str, header: str, raw_body: bytes) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, provided = int(parts["t"]), parts["v1"]
except (ValueError, KeyError):
return False
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(provided, expected) # never ==
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300; // what Muvia publishes
export function verify(secret, header, rawBody) {
const parts = {};
for (const item of String(header ?? "").split(",")) {
const at = item.indexOf("=");
if (at > 0) parts[item.slice(0, at)] = item.slice(at + 1);
}
if (!/^\d+$/.test(parts.t ?? "") || typeof parts.v1 !== "string") return false;
const timestamp = Number(parts.t);
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const provided = Buffer.from(parts.v1);
return provided.length === expected.length && timingSafeEqual(provided, Buffer.from(expected));
}
With Express, read the body raw on the webhook route:
app.post("/muvia-webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(process.env.MUVIA_WEBHOOK_SECRET, req.get("X-Muvia-Signature"), req.body)) {
return res.sendStatus(401);
}
res.sendStatus(204); // answer first, process afterwards
// queue req.body for processing; skip it if X-Muvia-Event-Id was seen before
});
Answer with any 2xx within 10 seconds, and do heavy work afterwards. What happens otherwise depends on the answer:
| Your answer | What Muvia does |
|---|---|
| 2xx | Delivered. |
| Timeout, connection error, 5xx, or 408, 423, 425, 429 | Retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After the sixth failed attempt the delivery is given up. |
| Any other 4xx (400, 401, 403, 404, …) | Given up at once: the same request would get the same answer. |
| 301, 302, 307, 308 | Followed and re-POSTed, up to 3 hops; each hop is checked as a destination. A 303 is not followed. |
An endpoint whose deliveries are given up 5 times in a row is switched off automatically. Administrators see it in the console, with the reason, and switching it back on resets the count.
The console also keeps a delivery log: for each delivery, the event, when it was sent, its status, the number of attempts, your status code and the URL finally posted to. It never stores the payload or your response body.
X-Muvia-Event-Id.Muvia dials your URL from inside its network, so it checks the destination when the endpoint is saved, before every delivery attempt, and at every redirect hop. It refuses:
https, and a URL carrying a username or password;