Webhooks
Receive every submission at your own URL as signed JSON, with retries when your server is down.
Before you start
A webhook POSTs every accepted submission to a URL you own, as JSON, signed so you can prove it came from us. You need a Pro or Agency plan and an HTTPS endpoint that can read the raw request body.
| Plan | Webhooks per form |
|---|---|
| Free | Not included |
| Pro | 3 |
| Agency | 10 |
- 1
Add a destination
Open the form's Integrations tab. Under Webhooks, enter the destination URL, an optional label, and click Add webhook.
The response shows a signing secret starting with
whsec_. Copy it into your server's environment now: it is shown once and never again. If you lose it, delete the destination and add it again to get a new one.Use HTTPS. Plainhttp://URLs are accepted so you can test against a local tunnel, but the payload is readable in transit. URLs with a username and password in them are refused. - 2
Verify the signature
Every delivery carries a
x-maketheform-signatureheader shaped liket=1789982043,v1=5d41402a….tis the Unix time of signing, andv1is the hex HMAC-SHA256 of the timestamp, a full stop, and the raw body, keyed with your secret. Recompute it, compare in constant time, and reject anything older than 5 minutes.// server.mjs: run with MAKETHEFORM_WEBHOOK_SECRET=whsec_... node server.mjs import { createServer } from 'node:http'; import { createHmac, timingSafeEqual } from 'node:crypto'; const SECRET = process.env.MAKETHEFORM_WEBHOOK_SECRET; function verifySignature(body, header, secret) { if (!header) return false; const parts = Object.fromEntries( header.split(',').map((piece) => piece.trim().split('=')), ); const timestamp = Number(parts.t); const provided = parts.v1; if (!Number.isFinite(timestamp) || !provided) return false; // Reject old deliveries so a captured request cannot be replayed later. const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > 300) return false; const expected = createHmac('sha256', secret) .update(`${timestamp}.${body}`) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(provided); return a.length === b.length && timingSafeEqual(a, b); } createServer((req, res) => { let body = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { if (!verifySignature(body, req.headers['x-maketheform-signature'], SECRET)) { res.writeHead(401).end('invalid signature'); return; } const { submission } = JSON.parse(body); console.log(submission.id, submission.fields); res.writeHead(200).end('ok'); }); }).listen(3000);Sign over the exact bytes you received. Verify before parsing. A body that has been parsed and stringified again, or read through a JSON body parser, will not match the signature even when nothing was tampered with. - 3
Respond with a 2xx
Any
2xxstatus within 10 seconds counts as delivered. Do the slow work after you respond, or in a queue of your own. Redirects are not followed, so give us the final URL.Send a test submission to the form and watch the destination: the panel shows when it last attempted a delivery and the status code your server returned.
The payload
The body is the same shape for every delivery. Fields keep the order they were submitted in, and a name that appears twice appears twice.
{
"event": "submission.created",
"submission": {
"id": "5f0c2b8e-3d1a-4c6e-9b7f-2a8d4e6c1b90",
"form_id": "9a51e0c4-7d2b-4f3e-8c16-0b7e2d9f4a35",
"form_name": "contact",
"received_at": "2026-09-22T09:14:03.512Z",
"fields": [
{ "name": "email", "value": "ada@example.com" },
{ "name": "message", "value": "Do you ship to Norway?" }
],
"files": []
}
}Each delivery also sends these headers:
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | MakeTheForm-Webhook/1.0 |
| x-maketheform-signature | t=<unix>,v1=<hex> |
event is always submission.created today; switch on it so a future event type cannot surprise you. When the submission has file uploads, each entry in files carries field_name, filename, content_type, size_bytes and a url. The URL is signed for one hour and generated fresh on every attempt, so download the file promptly rather than storing the link.
Retries and failures
A delivery that times out, cannot connect, or gets a 5xx, 408 or 429 is retried on this schedule:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 1 minute later |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 12 hours later, then no more |
Any other 4xx is taken as your server rejecting the request, and that delivery stops at once. Retrying a request you have called wrong will not make it right.
- Retries can arrive after later submissions, so do not rely on delivery order. Use
submission.idto ignore a delivery you have already processed. - After 20 failed attempts in a row, counted across all deliveries to that URL, the destination is switched off and shows why. A busy form reaches 20 quickly, so fix the endpoint, then click Re-enable.
- Submissions held for spam review are never sent to webhooks. See spam filtering.
- If you move to a plan with fewer webhooks per form, the oldest destinations keep delivering up to the new limit.