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.
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-Delivery: 01J2K8XQVM4FH5G6JQ7Y8WN9ZA
X-Splitforms-Signature: t=1720483200,v1=8c7c0b2e...
X-Splitforms-Timestamp: 1720483200
User-Agent: splitforms-webhooks/1
{
"event": "submission.created",
"form_id": "frm_5fK9",
"submitted_at": "2026-07-09T14:00:00.000Z",
"ip": "203.0.113.42",
"fields": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Hello, world."
},
"spam_score": 0.02
}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 detects whether the URL is Slack or Discord and formats the message for you automatically. 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 acknowledge within 10 seconds.Here's a complete App Router route handler:
import crypto from "node:crypto";
const SECRET = process.env.SPLITFORMS_WEBHOOK_SECRET!;
const MAX_SKEW_SECONDS = 300; // reject signatures older than 5 minutes
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();
const sigHeader = req.headers.get("x-splitforms-signature") ?? "";
const deliveryId = req.headers.get("x-splitforms-delivery") ?? "";
// Header format: t=<unix_ts>,v1=<hex_hmac>
const parts = Object.fromEntries(
sigHeader.split(",").map((kv) => kv.split("=")),
);
const timestamp = Number(parts.t);
// 2. Replay protection: reject stale timestamps.
const nowSeconds = Math.floor(Date.now() / 1000);
if (!timestamp || Math.abs(nowSeconds - timestamp) > MAX_SKEW_SECONDS) {
return new Response("stale or missing timestamp", { status: 401 });
}
// 3. Recompute the HMAC over "<timestamp>.<rawBody>" and compare
// in constant time so an attacker can't leak it byte-by-byte.
const signedPayload = parts.t + "." + raw;
const expected = crypto
.createHmac("sha256", SECRET)
.update(signedPayload)
.digest("hex");
const provided = Buffer.from(parts.v1 ?? "", "hex");
const computed = Buffer.from(expected, "hex");
if (
provided.length !== computed.length ||
!crypto.timingSafeEqual(provided, computed)
) {
return new Response("bad signature", { status: 401 });
}
// 4. Idempotency: retries reuse the delivery ID, so dedupe on it.
// Swap this Set for Redis SETEX with a 48h TTL in production.
if (await alreadyProcessed(deliveryId)) {
return new Response("duplicate ignored", { status: 200 });
}
await markProcessed(deliveryId);
// 5. Acknowledge FAST (well under 10s), then do heavy work off the
// request path so retries don't pile up while you're busy.
const payload = JSON.parse(raw);
queueMicrotask(() => handleSubmission(payload));
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.
- Use
crypto.timingSafeEqual, never===, so the comparison can't be attacked one byte at a time — and guard the buffer lengths first, sincetimingSafeEqualthrows on a length mismatch. - Return a 2xx quickly — move any slow work into a background task so you never blow the 10-second budget.
You can generate real signed test events to develop against with the webhook tester, and the full field-by-field walkthrough lives in send form data to a webhook.
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 you are now responsible for verifying signatures, deduplicating retries, handling downtime, and keeping the endpoint up.
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.
Reliability: retries, idempotency, and returning fast
Webhooks travel over the public internet, so delivery will occasionally fail — a deploy restarts your server, a timeout trips, a downstream API blips. A production-grade webhook system handles this for you, and your endpoint has to cooperate.
Retries on any non-2xx
splitforms treats any 2xx as success and everything else as a failure worth retrying, with exponential backoff so a briefly-down endpoint recovers automatically without hammering:
| Attempt | Delay from previous | Cumulative time |
|---|---|---|
| 1 | 0s | 0s |
| 2 | 1s | 1s |
| 3 | 5s | 6s |
| 4 | 30s | 36s |
| 5 | 2m | 2m 36s |
| 6 | 10m | 12m 36s |
| 7 | 1h | 1h 12m |
| 8–N | 1h each | up to 24h |
The full backoff design — jitter, dead-letter handling, and when to give up — is covered in webhook retry strategy.
Idempotency
Retries are a feature, but they mean the same submission can arrive more than once — for example if your endpoint processed the event but crashed before returning 200. Every attempt carries the same X-Splitforms-Delivery ID, so make your handler idempotent: record each ID you've completed (Redis SETEX with a 48-hour TTL works well) and short-circuit any ID you've already seen. Do the dedupe check before the side effect, not after.
Why you must return fast
Delivery counts as successful only when your endpoint returns 2xx within roughly 10 seconds. If you do the slow work — a CRM write, an email send, an LLM call — inline before responding, a slow downstream can push you past the timeout, the delivery is marked failed, and it gets retried even though you already handled it. That's duplicate work and pointless load. The fix is to acknowledge immediately and push heavy work to a queue or background task, exactly as the queueMicrotask line does in the handler above.
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?
Sign every request with HMAC-SHA256 using a shared secret, then verify the X-Splitforms-Signature header on your endpoint before trusting the payload. Recompute the HMAC over the timestamp plus the raw request body, compare it to the header value with crypto.timingSafeEqual (constant-time, not ==), and reject any signature whose timestamp is older than five minutes to stop replays.
What happens if my webhook endpoint is down?
splitforms retries any non-2xx response with exponential backoff — 1s, 5s, 30s, 2m, 10m, 1h, then hourly for up to 24 hours. Every attempt reuses the same X-Splitforms-Delivery ID, so once your endpoint recovers you can safely deduplicate and process the submission exactly once. After 24 hours a delivery is marked failed and you get an email digest.
Can I send one form submission to multiple destinations?
Yes. Add several webhook URLs to the same form and each fires independently — a slow or failing Discord webhook never blocks your Slack or CRM delivery, and each destination retries on its own schedule. 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.
- See the full payload contract and per-field detail in send form data to a webhook.
- Harden delivery against downtime with the webhook retry strategy.
- Fire real signed test events at your endpoint with the webhook tester.
- Add Slack, Discord, and custom destinations in the integrations dashboard.
- Browse more tutorials at the splitforms blog.