docs

Webhooks

One signed POST per event. Three events: created (always), accepted (after the gate), and status_changed (when a person moves a Submission).

Set up

On a Form's Integrations tab. Up to five per Form. The secret is masked on screen but stays available through the Copy secret button — while the Studio has no login, treat anyone who can reach the Studio as someone who can reach the secrets.

What arrives

HeaderWhat it carries
X-Noonmark-Eventsubmission.created, submission.accepted (see Gate) or submission.status_changed (see Status).
X-Noonmark-DeliveryUUID of this delivery. Use it for idempotency.
X-Noonmark-TimestampEpoch in ms, and part of the signature.
X-Noonmark-Signaturesha256=<hmac hex>
body
{
  "event": "submission.created",
  "submission": {
    "id": "7e583034-1a0a-47d5-ab72-74d4541f8486",
    "formId": "form_survey_demo",
    "receivedAt": 1788182240254,
    "origin": "https://app.acme.com",
    "userAgent": "Mozilla/5.0 …",
    "source": "api",
    "sender": null,
    "sourceRef": null,
    "payload": { "satisfaction": 5, "plan": "pro" }
  }
}

payload is what ingest stored: what arrived, with the Form's defaults and locked values written in. source, sender and sourceRef are the Submission's provenance — which door it came through, who sent it, and a link back to the original.

submission.status_changed

Sent when a person moves a Submission to another status word. change sits beside submission:

body
{
  "event": "submission.status_changed",
  "change": {
    "entryId": "0d3c…",
    "from": "new",
    "to": "planned",
    "note": "AMP-7501",
    "by": "ana@acme.com",
    "at": 1788182240254
  },
  "submission": { "id": "7e58…", "formId": "…", "source": "slack", "…": "…" }
}

entryId is the change's entry on the Submission's Timeline. Every change is one delivery. A note edited without moving the word goes on the Timeline but sends nothing.

Verify the signature

HMAC-SHA256 over `${timestamp}.${body}`, using the raw body — before any parsing. The timestamp is part of the signed string so a captured request cannot be replayed with a fresh time.

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

export function verify(req, secret) {
  const ts = req.headers["x-noonmark-timestamp"];
  const sent = req.headers["x-noonmark-signature"];

  // Reject anything older than five minutes.
  if (Math.abs(Date.now() - Number(ts)) > 300_000) return false;

  const mac = createHmac("sha256", secret)
    .update(`${ts}.${req.rawBody}`)
    .digest("hex");

  const expected = Buffer.from(`sha256=${mac}`);
  const actual = Buffer.from(sent);
  return expected.length === actual.length
    && timingSafeEqual(expected, actual);
}

Compare with timingSafeEqual, never with ===. And reject old timestamps — without that, the signature stays valid forever.

Delivery

GuaranteeDetail
3 attemptsWaits 0, 500 and 2000 ms between them.
5 s timeoutPer attempt.
2xx is successAnything else is retried.
After the replyIngest never waits for your URL.

Each delivery is recorded once, after its last attempt, with its status, code, attempt count, duration and error. The log is visible on the Form's Integrations tab and on the Workspace's Integrations page. There is one row per webhook per Submission for created and accepted, and one per status change for status_changed.

Delivery is best effort: there is no queue. If the process dies midway, the delivery is lost, and no row is written for it. Build nothing on top of this that cannot be reconciled from ingest.

What did you think of this content?

On this page