Webhooks

Be told when something happens: the events, the payload, the signature to verify, the retries, and a test delivery.

A webhook endpoint is a URL on your side that Monody posts to when something happens in your workspace. Add one under Settings → Forms and API → Webhooks: the URL, the events it should receive, and the signing secret, shown once and used to verify every delivery. Then use Send test on the endpoint: a signed ping is delivered at once and the page shows the HTTP status and how long it took, so you know you are wired before a real event fires.

Events

Each delivery names its event in the X-Monody-Event header and in the body.

  • lead.created — a lead was created with an API key; data: id, firstName, lastName, email, phone, source, createdAt
  • membership.started — a membership activated for the first time; data: id, personId, planVersionId, from (the state it came from), startedAt
  • membership.cancelled — a membership was cancelled; data: id, personId, planVersionId, from, reason
  • booking.created — a class booking was made; data: id, personId, classInstanceId
  • payment.succeeded and payment.failed — listed on the form and marked: they are sent when payments go live, and cannot be subscribed to until then
  • * subscribes to every event Monody sends, now and later

The payload

JSON, always the same envelope:

JSON
{ "event": "membership.started", "data": { "id": "fb052bba-…", "personId": "…", "…": "…" }, "ts": 1789302662000 }

The signature

Every delivery carries X-MONODY-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of the string <t>.<raw body> with your endpoint's secret. For 24 hours after you rotate the secret the header carries two v1 values, one per secret, so accept the delivery when any v1 matches. Verify over the raw bytes you received (not a re-serialised object), compare in constant time, and reject a t older than five minutes:

JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyMonody(header, rawBody, secret) {
  const parts = header.split(",").map((kv) => kv.split("="));
  const t = parts.find(([k]) => k === "t")?.[1];
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => Buffer.from(v));
  if (!t || signatures.length === 0) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
  const matched = signatures.some((b) => b.length === expected.length && timingSafeEqual(expected, b));
  return matched && Math.abs(Date.now() / 1000 - Number(t)) < 300;
}

Answering

Answer any 2xx once you have stored the delivery, and do your work afterwards. Deliveries are at least once: a retry after a timeout can repeat an event, so treat data.id with the event name as your idempotency key.

Retries

A non-2xx answer, a timeout (15 seconds) or a connection failure is tried again, six attempts in all: after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then the delivery is marked Gave up. The Recent deliveries log shows every attempt with its status, the HTTP status or the connection error, and the next try; the body of your answer is never read or stored. Redirects are not followed: a 3xx counts as a failure, so give the final URL. Filter it to one endpoint from the endpoint's View deliveries. A failed delivery, or one marked Gave up, can be replayed from its row.

A lost secret

A lost secret is replaced, not recovered: Rotate secret on the endpoint asks first, then issues a new one and shows it once. For the next 24 hours every delivery is signed with both the old and the new secret (two v1 values), so a receiver that checks every v1, as the sample above does, keeps verifying while you switch it to the new secret.

Where deliveries can go

An endpoint is a public https:// address. Addresses inside a private network (localhost, 10.x, 172.16–31.x, 192.168.x, link-local and cloud metadata addresses, and their IPv6 equivalents) are refused when you save the endpoint and again at every delivery. To try a receiver on your own machine, expose it through a tunnel that gives it a public https address.

A question these pages do not answer? Write to support@monody.app.