Webhooks

Receive every form submission as a JSON POST on your own endpoint: payload format, setup, security rules and testing.

A webhook sends each submission to a URL you choose, as JSON, in addition to the email. Use it to put submissions in a database, post them to a chat channel, open a ticket, or hand them to an automation tool such as Zapier, Make or n8n.

What your endpoint receives#

Within a few seconds of each submission, SilentForms makes a POST request to your URL with Content-Type: application/json. The body holds the visitor's fields plus a receivedAt timestamp in ISO 8601 format and a submissionId:

{
  "name": "Jane",
  "email": "[email protected]",
  "message": "Hi",
  "receivedAt": "2026-10-25T09:30:00.000Z",
  "submissionId": 1234
}

Details worth knowing when you write the receiver:

  • Special fields are removed. accessKey, subject, from_name, redirect, webhookUrl, the honeypot and CAPTCHA tokens are not in the payload. The visitor's email is.
  • Multi-value fields are arrays. A checkbox group, a select multiple or a name ending in [] arrives as a JSON array, without the brackets in the key: "interests": ["Design", "Hosting"].
  • Values are verbatim. Unlike the email, nothing is HTML-escaped. If you render values in a web page, escape them yourself.
  • Spam is never sent. Submissions caught by the honeypot do not reach the webhook.

Delivery rules#

SituationStatus in the dashboard
Your endpoint answers with any 2xx status within 5 secondssent
It answers 302 or 303 ("processed, see elsewhere")sent
It answers with any other status, including a 301, 307 or 308 redirectfailed, then retried
No answer within 5 seconds, DNS failure, connection refusederror

A delivery that fails is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, six attempts in all. Each submission in the dashboard shows its deliveries with their status and last error, and a Retry now button for one that gave up. A response that says the URL itself is wrong (a private address, for example) is not retried.

Because a delivery is retried when your endpoint does not answer in time, it can occasionally arrive twice, for example if your endpoint saved the submission but answered after 5 seconds. Use submissionId to ignore a repeat.

Redirects are not followed. A 302 or 303 counts as delivered, because it means your endpoint processed the request (Google Apps Script answers this way). A 301, 307 or 308 means the URL has moved and nothing was processed, so it counts as failed: use the final URL.

If you turn email notifications off, the webhook becomes the only delivery. If both the email and the webhook fail, the visitor gets a 500 "Could not deliver your message. Please try again later."

Setting the webhook URL#

There are two ways, and the dashboard wins if both are set.

In the dashboard (recommended). Sign in at the sign-in page, open the form and save the webhook URL. It stays on the server and never appears in your HTML.

As a hidden field. Without an account, add the URL to the form:

<input type="hidden" name="webhookUrl" value="https://hooks.example.com/forms/9f2c41d7b8e64a01">

webhook works as the field name too. Anyone who views the page source can read it, so use this for testing or for endpoints where that does not matter.

The URL must be an absolute http or https URL on a public address. Private networks (such as 192.168.x.x or 10.x.x.x), localhost, link-local and other reserved addresses are refused with a 400, so you cannot point a webhook at your own machine or an internal service directly. Use a public tunnel or a deployed endpoint instead.

Securing your endpoint#

Requests carry no signature header, so your endpoint cannot verify cryptographically that a request came from SilentForms. Protect it with a URL nobody can guess instead: put a long random token in the path or query string and reject requests that do not have it. Generate one with:

openssl rand -hex 16

Keep that URL in the dashboard rather than in a hidden field, since a hidden field would publish the token.

Receiving in Node.js#

A minimal receiver with the built-in node:http module and no dependencies. It checks the secret path, parses the JSON and answers 200 straight away:

// server.mjs — run with: WEBHOOK_TOKEN=your-token node server.mjs
import { createServer } from 'node:http';

const TOKEN = process.env.WEBHOOK_TOKEN;

createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== `/forms/${TOKEN}`) {
    res.writeHead(404).end();
    return;
  }

  let body = '';
  for await (const chunk of req) body += chunk;

  let submission;
  try {
    submission = JSON.parse(body);
  } catch {
    res.writeHead(400).end();
    return;
  }

  res.writeHead(200).end('ok');

  // Do the slow work after answering, so you stay well inside the 5-second limit.
  console.log(`${submission.receivedAt} from ${submission.email}:`, submission);
}).listen(process.env.PORT || 3000);

Answer first, then process. Anything slow, such as calling another API or sending a message, risks the 5-second timeout and an "error" status even though your code ran.

Receiving in a Cloudflare Worker#

A Worker is a quick way to get a public HTTPS endpoint without running a server. Store the token as a secret (for example with npx wrangler secret put WEBHOOK_TOKEN) and read it from env:

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (request.method !== 'POST' || url.pathname !== `/forms/${env.WEBHOOK_TOKEN}`) {
      return new Response('Not found', { status: 404 });
    }

    let submission;
    try {
      submission = await request.json();
    } catch {
      return new Response('Bad JSON', { status: 400 });
    }

    // Keep working after the response has been sent.
    ctx.waitUntil(handle(submission));
    return new Response('ok');
  },
};

async function handle(submission) {
  console.log('New submission', submission);
}

ctx.waitUntil lets the Worker return 200 immediately and finish the work in the background. See the Workers fetch handler docs for the full API.

Testing#

  1. Before writing any code, point the webhook at a request-inspection service (webhook.site is one) that gives you a temporary public URL and shows every request it receives.
  2. Save that URL in the dashboard or the hidden field and send a test submission from your form.
  3. Check the request: method POST, content type application/json, and the body with your fields and receivedAt.
  4. Switch to your real endpoint and send another submission. The dashboard should show "sent" for it.

If a delivery is retrying or gave up, the submission shows the last error: look at what status your endpoint returned, and check that it is not redirecting (for example from http to https, or adding a trailing slash). "Could not be reached" means the endpoint was unreachable or took more than 5 seconds.

Next steps#