What a form webhook is (and why you want one)
A webhook is the inverse of an API call. With a normal API you ask the server "anything new?" on a schedule and hope you didn't miss anything between polls. A webhook flips that around: the moment a visitor submits your form, the form backend calls you— an HTTP POST lands on a URL you chose, carrying the submission as JSON.
That real-time push is the whole point. There's no polling loop to run, no cron job, no delay. A lead fills out your contact form and a Slack message appears in #salesa few hundred milliseconds later. The same event can create a CRM record, append a row to a database, kick off an onboarding email, and ping Discord — all fired from one submission.
This post covers the how-to: destinations, receiver code, and signature verification. If you just want the dashboard setup, the form to webhook page is the shorter click-by-click version.
Concretely, a form webhook is good for:
- Instant alerts — Slack or Discord messages the second a form is submitted, so nobody refreshes an inbox.
- CRM & database sync — push leads into HubSpot, Airtable, Postgres, or Notion without a middleman.
- Automation — trigger Zapier, Make, or n8n workflows that branch on the submission data.
- Custom logic — run your own scoring, routing, or enrichment on your own server.
The payload shape is stable and predictable. Here's what a splitforms submission event looks like on the wire:
POST https://your-handler.example.com/api/webhook
Content-Type: application/json
X-Splitforms-Signature: sha256=8c7c0b2e9d41...
X-Splitforms-Event: submission.created
User-Agent: splitforms.com-webhook/1.0
{
"event": "submission.created",
"submission": {
"id": "9f2b7c3a-4e2d-4c1a-9d0e-1a2b3c4d5e6f",
"form_name": "Contact form",
"data": {
"name": "Ada Lovelace",
"email": "[email protected]",
"message": "Hello, world."
},
"ip_address": "203.0.113.42",
"referer": "https://example.com/contact",
"created_at": "2026-07-09T14:00:00.000Z"
}
}Two things to note before you write a line of receiver code: your form's fields arrive nested under submission.data, not at the top level; and the X-Splitforms-Signature header is the literal prefix sha256= followed by the hex HMAC-SHA256 of the raw request body. Verifying it is covered in the code section below.
Where you can send form data
Almost every modern service accepts an inbound webhook. The setup differs slightly per destination, but the pattern is identical: the destination hands you a URL, you paste it into your form backend, submissions start flowing. Here's the practical map.
| Destination | Setup | Best for |
|---|---|---|
| Slack | Paste an Incoming Webhook URL | Team notifications in a channel |
| Discord | Paste a channel Webhook URL | Communities & indie projects |
| Zapier | Catch Hook trigger → 6,000+ apps | No-code fan-out, non-technical teams |
| Make | Custom webhook module → scenario | Visual multi-step automation |
| n8n | Webhook trigger node (self-host or cloud) | Self-hosted, code-friendly workflows |
| Pipedream | HTTP source → Node/Python steps | Developers who want inline code |
| Your own endpoint | Any HTTPS route + shared HMAC secret | Full control, custom business logic |
Slack and Discord both give you a plain HTTPS webhook URL from their settings UI — no code, no OAuth. Zapier, Make, n8n, and Pipedreameach expose a "catch this webhook" trigger that becomes the entry point of a larger workflow. And any HTTPS route you write yourself is a valid destination as long as it can verify a signature and return a 2xx.
The friction is that each destination expects a different JSON shape — Slack wants blocks, Discord wants embeds, your CRM wants its own field names. This is where a form backend earns its keep. With splitforms you paste a single HTTPS URL into the integrations dashboard; it recognises Slack, Discord, Microsoft Teams, Telegram, and WhatsApp URLs and formats the message natively for each platform. For everything else it sends the canonical signed JSON payload shown above, and you translate it once on your side.
The code: form in, webhook out
Two pieces of code cover the whole path. First, the form that collects the submission. Second, the endpoint that receives the webhook and verifies it. You only need the second piece if the destination is your own server — Slack, Discord, and the automation tools receive the webhook for you.
1. The HTML form
This is the entire front end. A plain HTML form that POSTs to splitforms, with your access key in a hidden field. No JavaScript, no fetch, no client library:
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<label>
Name
<input type="text" name="name" required />
</label>
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Message
<textarea name="message" rows="5" required></textarea>
</label>
<!-- Honeypot: humans don't see it, bots fill it -->
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>When someone submits this, splitforms stores the submission, emails you, and — because you configured a webhook — POSTs the signed JSON payload to every destination URL you added. The access_keyauthenticates the form; the webhook URLs live in your dashboard, not in the HTML, so they're never exposed to the browser.
2. The receiving endpoint (Next.js route handler)
If your destination is your own server, you must verify that each request genuinely came from splitforms and not from an attacker who guessed your URL. The rule is absolute: read the raw request body, verify the HMAC-SHA256 signature with a constant-time compare, and return a 2xx before the 8-second timeout.Here's a complete App Router route handler:
import crypto from "node:crypto";
const SECRET = process.env.SPLITFORMS_WEBHOOK_SECRET!;
export async function POST(req: Request) {
// 1. Read the RAW body. Never JSON.parse before you have verified it —
// re-serialising the parsed object changes the bytes and breaks the HMAC.
const raw = await req.text();
// 2. Recompute the signature. Header format: sha256=<hex hmac of raw body>
const provided = req.headers.get("x-splitforms-signature") ?? "";
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(raw).digest("hex");
// 3. Compare in constant time so the signature can't be leaked
// byte-by-byte — and guard the lengths first, since
// timingSafeEqual throws on a length mismatch.
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response("bad signature", { status: 401 });
}
// 4. Acknowledge FAST — splitforms aborts the request after 8 seconds
// and there is no retry, so heavy work goes off the request path.
const payload = JSON.parse(raw);
queueMicrotask(() => handleSubmission(payload.submission));
return new Response("ok", { status: 200 });
}Three details make or break this handler:
- Use the raw body for the HMAC — if a JSON middleware parses it first, the re-serialised bytes won't match the signature.
- Compare against the full
sha256=-prefixed header withcrypto.timingSafeEqual, never===. The two most common verification bugs are comparing a bare hex digest against the prefixed header, and using a string equality that can be attacked one byte at a time. - Return a 2xx quickly — splitforms aborts the request after 8 seconds and makes exactly one delivery attempt, so a slow handler doesn't get a second chance.
3. The same receiver in Express
Not on Next.js? The pattern ports to any framework. The one trap in Express is that express.json() parses the body before you can hash it — mount express.raw() on the webhook route instead:
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.SPLITFORMS_WEBHOOK_SECRET!;
// Capture the raw body for signature verification — express.json()
// would re-serialise the bytes and break the HMAC.
app.post(
"/hooks/splitforms",
express.raw({ type: "application/json" }),
(req, res) => {
const provided = req.header("X-Splitforms-Signature") ?? "";
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const payload = JSON.parse(req.body.toString("utf8"));
// ... do your thing with payload.submission.data ...
res.status(200).send("ok");
},
);
app.listen(3000);While developing, a tunnel gets the webhook to your laptop — point ngrok or a Cloudflare Tunnel at localhost:3000 and paste the public URL into the dashboard (splitforms can't reach 127.0.0.1 directly). You can inspect and sanity-check payload shapes with the webhook tester, and the signature format and event types are documented in the webhook docs.
No-code vs code: which path is yours
There are two honest ways to consume a form webhook, and the right one depends on whether you want to write and host code.
No-code: Zapier or Make
Point the webhook at a Zapier Catch Hook or a Make custom webhookmodule and build the rest of the flow by dragging boxes. The tool receives the POST, parses the JSON, and lets you map fields into 6,000+ apps — add a Google Sheets row, create a Trello card, send a Gmail, all without a server. This is the right choice when a non-technical teammate owns the workflow, when the logic changes often, or when you're wiring up apps that don't expose their own webhook endpoints.
The trade-offs: every step adds latency (a Zap can take seconds, not milliseconds), tasks are metered and billed, and you're trusting a third party with the payload. For most teams those costs are worth the zero-maintenance convenience.
Code: a signed webhook to your own endpoint
Point the webhook at an HTTPS route you wrote — the Next.js handler above — and you own the whole pipeline. It's the fastest path (single hop, no intermediary), the cheapest at volume (no per-task billing), and the most flexible (any language, any database, any business rule). The cost is that youare now responsible for verifying signatures and keeping the endpoint up — and since splitforms delivers each webhook exactly once, downtime means catching up from the dashboard rather than waiting for a retry.
A common hybrid: send the webhook to n8n or Pipedream, which gives you a code step anda hosted runtime, so you write the logic but skip the infrastructure. That's the sweet spot for developers who want control without running a server.
Worked example: routing leads with n8n
In n8n, drop a Webhooktrigger node, set its HTTP method to POST, and paste the node's production URL into your form's webhook settings in splitforms. n8n receives the signed JSON payload and can branch into Postgres, Notion, HubSpot, Slack, or 400+ other nodes. A typical lead-routing flow:
Webhook (POST /splitforms-leads)
↓
IF { $json.body.submission.data.team_size === "250+" }
→ Slack: post to #sales-large
→ HubSpot: create Deal
ELSE
→ Slack: post to #sales-smb
→ Postgres: INSERT INTO leads ...Note the field path: your form fields live under submission.data, not at the top level of the payload. This setup also answers the durability question below — n8n acknowledges splitforms' single delivery attempt instantly, then its own error handling and retry settings deal with flaky downstream APIs.
Reliability: one attempt, eight seconds
Here's the part most webhook guides get wrong about splitforms, so let's be precise: each webhook is delivered as a single POST attempt with an 8-second timeout.A 2xx response counts as delivered; a non-2xx, a timeout, or a connection error marks that delivery failed. There are no automatic retries, no backoff schedule, and no dead-letter queue. The dashboard shows each webhook's last-triggered time, last HTTP status, and last error message, so a failing endpoint is visible at a glance.
What a failed delivery costs you (and what it doesn't)
A missed webhook is not a lost submission. The submission is stored in your dashboard and emailed to you regardless of what happens to the webhook, so the webhook is a routing layer, not the system of record. If your endpoint was down for an hour, every lead from that hour is still sitting in the dashboard — re-process from there, or pull them with the read API or a CSV export (paid plans).
Design for the single attempt
- Acknowledge first, work later. Return 200 immediately and queue the CRM write, email send, or LLM call — a slow downstream must never push you past the 8-second abort, because there is no second attempt.
- Keep the endpoint boring. No auth walls (splitforms can't pass cookies or basic auth — the HMAC signature is your authentication), no redirects, no cold-start-prone infrastructure on the hot path.
- Need retries? Own them downstream. Point the webhook at a receiver whose only job is to enqueue and return 200 — n8n, Pipedream, or a queue-backed endpoint — and let that layer retry the real work. The design trade-offs are covered in webhook retry strategy.
Troubleshooting failed deliveries
Four failure modes account for nearly every "my webhook doesn't work":
- Signature mismatch on every event — you're hashing a parsed-and-re-serialised body instead of the raw bytes, or comparing a bare hex digest against the header, which is
sha256=-prefixed. - Deliveries fail with a timeout — your endpoint took longer than 8 seconds to respond. Move heavy work off the request path and return 2xx immediately.
- Nothing ever arrives — the URL points at localhost (use ngrok or a Cloudflare Tunnel) or sits behind authentication splitforms can't satisfy.
- Fields look missing — in the generic payload, form fields are nested under
submission.data, not at the top level.
FAQ
How do I send form data to Slack?
Create a Slack Incoming Webhook (Slack apps → Incoming Webhooks → Add to a channel), copy the https://hooks.slack.com/... URL, and paste it into your form backend. With splitforms you paste that single URL into the dashboard and it auto-detects Slack, formatting each submission as a tidy message with the name, email, and message fields — no payload templating required.
Do I need Zapier to send form submissions to a webhook?
No. A webhook is just an HTTP POST, so any tool that accepts one works — Slack and Discord accept webhooks natively, and your own server can receive them directly. Zapier, Make, n8n, and Pipedream are useful when you want to branch, transform, or fan out to many apps without writing code, but for a straight form-to-endpoint delivery they only add latency and cost.
How do I verify a webhook is genuine and not spoofed?
Check the X-Splitforms-Signature header before trusting the payload. It's the literal prefix sha256= followed by the hex HMAC-SHA256 of the raw request body, computed with your webhook's signing secret. On your server, recompute the same HMAC over the raw bytes — before any JSON parsing — and compare it to the full header value with crypto.timingSafeEqual (constant-time, not ==). Reject anything that doesn't match.
What happens if my webhook endpoint is down?
That delivery fails. splitforms makes exactly one POST attempt per submission with an 8-second timeout — there are no automatic retries, no backoff, and no dead-letter queue. The webhook's row in your dashboard records the last HTTP status and last error so you can see what happened, and the submission itself is still stored and emailed to you, so no data is lost. If you need durable delivery, point the webhook at a receiver that acknowledges instantly and queues the work — n8n, Pipedream, or a small queue-backed endpoint of your own.
Can I send one form submission to multiple destinations?
Yes. Add several webhook URLs to the same form and each fires independently with its own single delivery attempt — a slow or failing Discord webhook never blocks your Slack or CRM delivery. For heavy fan-out (five or more apps, conditional routing, data transforms) point one webhook at n8n or Make and branch from there instead of managing many URLs by hand.
Next steps
- Wire your first webhook — the form-to-webhook guide walks the dashboard setup end to end.
- Pipe submissions into chat step by step — Slack or Discord.
- Build a downstream retry layer with the webhook retry strategy.
- Inspect and validate payloads with the webhook tester.
- Add Slack, Discord, and custom destinations in the integrations dashboard.
- Browse more tutorials at the splitforms blog.