Analysing
Signed deliveries, and an email that says nothing.
A webhook posts each new response to an https URL you choose. Every
delivery carries an X-NumoForms-Signature header:
sha256= followed by an HMAC-SHA256 of the exact request
body, computed with a secret unique to that webhook, so your endpoint can
prove the payload came from us. Separately, chosen email addresses can be
notified when a response arrives — and those emails deliberately contain
no answers.
What arrives at your endpoint
A single JSON POST per response. The body holds five fields and nothing else, so you can write a parser against it without guessing.
| Field | What it holds |
|---|---|
event | response.created |
survey_id | The survey the response belongs to. |
response_id | The unique id of this response. |
created_at | When the response was recorded. |
answers | The answers, keyed on question id. |
| Header | What it is for |
|---|---|
X-NumoForms-Event | The event name. Today there is one: response.created. |
X-NumoForms-Signature | sha256= followed by the HMAC-SHA256 of the exact request body, in hex. |
X-NumoForms-Delivery | A unique id for this delivery attempt, so you can spot a duplicate. |
Delivery is fired by a database trigger on the responses table, not by a particular screen in the app. That matters more than it sounds: a response submitted through an embedded form, a hosted link, or a partial resumed days later through save & continue all take the same path, so there is no route we forgot to wire up.
The webhook fires when the response arrives. If you have turned on approvals, it does not wait for the approve or reject decision, and no second delivery is sent when that decision is made. Treat an incoming payload as "a response exists", not as "a response has been signed off".
Why the signature is not optional
A webhook URL is a secret that leaks. It ends up in a ticket, a Slack message, a screenshot in a handover document, a browser history on a shared laptop. Without a signature, anyone who has seen the URL can post whatever they like to it, and your systems will file it as a genuine consultation response. For a council counting objections to a planning application, that is not a theoretical problem.
So every delivery is signed. Each webhook gets its own randomly generated secret, shown in the builder behind a "show signing secret" control. We compute an HMAC-SHA256 over the exact bytes of the request body using that secret; you recompute it with your copy and compare. A forged request fails, because the forger does not have the secret.
The URL must be https. That is enforced by a database
constraint, not a hint in the form, because a signing secret travelling
over an unencrypted connection is not a secret.
Verifying a delivery in Node.js
Two details do most of the damage in practice. Use the raw request body, not a parsed and re-serialised object — whitespace differences change the hash. And compare in constant time, so an attacker cannot learn the correct signature one byte at a time from response timings.
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.NOISSIME_WEBHOOK_SECRET;
// rawBody must be the exact bytes received, before any JSON parsing.
// Re-serialising a parsed object changes the whitespace and the check fails.
function isFromNoissime(rawBody, header) {
if (typeof header !== 'string') return false;
const expected =
'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header);
// timingSafeEqual throws on a length mismatch, so compare lengths first.
return a.length === b.length && timingSafeEqual(a, b);
}
Call that before you parse anything. If it returns false, answer 401 and discard the request. If it returns true, you have a response that can only have come from your survey.
The delivery log
A webhook that silently stopped working looks exactly like one that was never called: nothing happens, and nothing tells you. So every attempt is written to a delivery log, and the last HTTP status is shown against the webhook in the builder — green for a 2xx, red for anything else.
Delivery is asynchronous, so the status is reconciled after the fact rather than guessed at the moment of sending. What you see is the status your server actually returned. Each webhook can be paused and resumed without deleting it, which is the usual thing you want while the receiving system is being redeployed.
One deliberate limit: there is no retry with exponential backoff. A delivery is attempted once, the outcome is recorded, and that is the end of it. Building a retry queue that respects ordering and idempotency is real work, and claiming one we have not built would be worse than saying this. In the meantime the response is never lost — it is in the database, visible in analytics and reporting, and exportable as CSV.
A failing webhook also cannot break collection. If delivery throws, the error is logged and the response is still recorded. The respondent's answer is the thing that matters; delivery is best effort.
Email notifications, without the answers
Add the addresses that should hear about new responses and they are emailed when one arrives. The message is branded HTML with a plain-text alternative, because text-only clients and spam filters both read the plain part. It names the survey, gives the running response total, and links to the results.
What it does not contain is the answers. That is a decision, not an omission. A notification email is forwarded to a colleague, auto-filed into a shared mailbox, synced to a phone and retained in a backup for years. Putting free-text consultation answers into that chain scatters personal data across systems nobody has assessed, and quietly undoes the access controls described on the security page. Respondent data stays in the database, and the email tells you to go and look.
Like webhooks, notifications are fired by a database trigger, so they follow the response rather than a particular submission route. If no addresses are configured, nothing is sent and nothing is attempted.
What this does not do
- No Zapier app, and no Google Sheets connector.
- No retry with backoff — one attempt per response, then the log.
- No outbound events other than
response.created. Approvals, edits and deletions do not fire webhooks. - No inbound API for creating surveys or reading responses programmatically.
- No IP allowlist for deliveries. The signature is the authentication.
Common questions
How do I verify the X-NumoForms-Signature header?
Compute an HMAC-SHA256 of the raw request body using that webhook’s signing secret, hex-encode it, prefix it with "sha256=", and compare it with the header using a constant-time comparison. Verify before parsing the JSON, and reject anything that does not match with a 401.
What happens if my endpoint is down when a response arrives?
The delivery is attempted once and the outcome is recorded in the delivery log with the HTTP status. There is no automatic retry with backoff. The response itself is never at risk: a webhook failure cannot stop a response being saved, and you can always recover the data from the results screen or a CSV export.
Does a webhook fire for a response submitted from an embedded form?
Yes. Delivery is fired by a database trigger on the responses table rather than by a particular page of the app, so it happens wherever a response comes from — a hosted link, an embedded form, or a resumed partial that has just been completed.
Can I send a webhook to an http:// URL?
No. A database check constraint requires the URL to start with https://, and the builder refuses anything else. A signing secret sent over an unencrypted connection is not a secret.
Do notification emails contain the respondent’s answers?
No, deliberately. The email says a response arrived, names the survey, gives the running total and links to the results. Notifications get forwarded and sit in mailbox backups, so respondent data stays in the database where the access controls are.
Is there a Zapier app or a Google Sheets integration?
No. Webhooks are the only outbound integration today. There is no Zapier app and no Google Sheets connector, and neither is on a published date.
Wire a survey into your own systems.
Point a webhook at an https endpoint and every response arrives there signed, so your receiver can prove it came from us rather than from whoever found the URL. Add the addresses that should be emailed when a response lands, and the answers stay in the database where the access controls are.
- HMAC-SHA256 signature on every delivery
- Delivery log showing the last HTTP status
- Fired by a database trigger, whatever the route in