MuviaDevelopers
Guides: Webhooks

Webhooks

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.

Setting up an endpoint

A company administrator registers endpoints in Muvia, under Settings → Company → Webhooks. Each endpoint has:

  • a destination URL, which must be https and reachable on a public address (see Destinations);
  • the event types it subscribes to (see the catalogue);
  • a signing secret, 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 catalogue

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.

The payload

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.

Verifying the signature

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
});

Responding, retries and giving up

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.

Delivery guarantees

  • At least once. A delivery whose answer was lost is sent again, so the same event can arrive twice: deduplicate on X-Muvia-Event-Id.
  • No ordering. Retries can overtake newer events. Treat a payload as a snapshot of one moment, not as a diff.
  • A notification, not a ledger. In rare failures an event may never be queued, and past deliveries cannot be re-sent. A receiver that must not miss a change reconciles against the API.

Destinations

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:

  • any scheme but https, and a URL carrying a username or password;
  • a host name that does not resolve within 5 seconds;
  • a host with any non-public address: loopback, private (RFC 1918), link-local, unique-local, multicast, reserved, unspecified or carrier-grade NAT — including IPv6 forms that embed such an IPv4 address.