Webhooks

Receive every submission at your own URL as signed JSON, with retries when your server is down.

10 minPro and Agency plansUpdated Sep 22, 2026

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.

PlanWebhooks per form
FreeNot included
Pro3
Agency10
  1. 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. Plain http:// 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. 2

    Verify the signature

    Every delivery carries a x-maketheform-signature header shaped like t=1789982043,v1=5d41402a…. t is the Unix time of signing, and v1 is 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. 3

    Respond with a 2xx

    Any 2xx status 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.

POST body
{
  "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:

HeaderValue
Content-Typeapplication/json
User-AgentMakeTheForm-Webhook/1.0
x-maketheform-signaturet=<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:

AttemptWhen
1Immediately
21 minute later
35 minutes later
430 minutes later
52 hours later
612 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.id to 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.

Next steps