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
| Header | What it carries |
|---|---|
X-Noonmark-Event | submission.created, submission.accepted (see Gate) or submission.status_changed (see Status). |
X-Noonmark-Delivery | UUID of this delivery. Use it for idempotency. |
X-Noonmark-Timestamp | Epoch in ms, and part of the signature. |
X-Noonmark-Signature | sha256=<hmac hex> |
{
"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:
{
"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.
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
| Guarantee | Detail |
|---|---|
| 3 attempts | Waits 0, 500 and 2000 ms between them. |
| 5 s timeout | Per attempt. |
| 2xx is success | Anything else is retried. |
| After the reply | Ingest 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?
Integrations
What a Form can do beyond storing a Submission, in three families: Sources bring Submissions in, Facets are carried by every Submission, and Steps run when something happens to one.
Status
What the Workspace has done with a Submission. An Integration: per Form, with the Form's own words. A person sets it, with a note saying why; every change goes on the Timeline, fires a webhook, filters the list, leaves with the export, and reads over MCP.