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 Account → Webhooks: the URL, the events it should receive, and the signing secret — shown once, used to verify every delivery. Then use Send test on the endpoint's actions: a signed ping is delivered immediately and the page tells you the HTTP status and the time, 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 captured (from your page, the hosted form, the desk or the API); 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 is JSON, always the same envelope:

{ "event": "lead.created", "data": { "id": "fb052bba-…", "email": "parent@example.com", "…": "…" }, "ts": 1789302662000 }

The signature. Every delivery carries X-MONODY-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of the string <t>.<raw body> with your endpoint's secret. Verify over the raw bytes you received (not a re-serialised object), compare in constant time, and reject a t older than five minutes:

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

export function verifyMonody(header, rawBody, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  if (!t || !v1) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(v1);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
  return Math.abs(Date.now() / 1000 - Number(t)) < 300;
}

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

Retries. A non-2xx answer, a timeout (15 seconds) or a connection failure is retried up to six times: after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then the delivery is marked as given up. The Recent deliveries log on the page shows every attempt with its status, the HTTP status or the connection error, the next try, and the first kilobyte of your response — filter it to one endpoint from the endpoint's View deliveries. A failed or given-up delivery can be replayed from its row.

A lost secret is replaced, not recovered: Rotate secret on the endpoint issues a new one and shows it once; the old one stops verifying immediately, so update your side first, then rotate.