Webhooks8 min read
Receive form submissions on your own server
Verified Ikiform webhook handlers for a Next.js route handler, Express, a Cloudflare Worker, FastAPI and Flask, plus raw body pitfalls, duplicates and local testing.
Sometimes the right destination is your own code: a database, a CRM sync, a welcome email. This guide shows complete handlers for the common stacks. Each one reads the raw body, checks Ikiform's signature, skips repeats and answers fast.
- A visitor submits your form, and it posts to Ikiform.
- Ikiform stores the submission and posts it to your endpoint, signed with your endpoint's secret.
- Your handler verifies the signature against the raw body, skips delivery ids it has seen, and answers 2xx within 15 seconds.
- Slow work, like notifications, runs after the answer.
Before you start
- An Ikiform form that already receives submissions.
- A server reachable on a public
https://URL. For local work, see Testing locally. @ikiform/sdkfor the TypeScript and JavaScript handlers. It uses Web Crypto only, so it runs in Node 20 and later, Workers, Deno and Bun.
What Ikiform sends
POST /ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792756800
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=Every delivery is a POST with a JSON body. The body has the same shape for every endpoint, and you can't change it. A submission looks like this:
{
"id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
"type": "submission.created",
"timestamp": "2026-10-23T12:00:00.000Z",
"api_version": "2026-10-17",
"data": {
"submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
"form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
"fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
"meta": {
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"origin": "https://example.com",
"referrer": "https://example.com/contact",
"country": "GB",
"subject": null,
"reply_to": "ada@example.com"
},
"created_at": "2026-10-23T12:00:00.000Z",
"truncated": false
}
}The form's values are in data.fields, keyed by the input name. A test from the dashboard sends an endpoint.test event instead, with data.endpoint_id and data.message and no fields. The full reference is on Webhooks.
The four rules
| Rule | Why |
|---|---|
| Verify against the raw body | The signature covers the exact bytes. Parsed and re-serialized JSON can differ by a space and fail |
| Reject old timestamps | A signature older than 5 minutes may be a replay. The SDK does this for you |
| Skip repeats | Retries carry the same webhook-id. Store it with a unique constraint |
| Answer within 15 seconds | Slower answers count as failures and are retried. Do slow work after answering |
Handlers
Next.js route handler
import { after } from "next/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { notifyTeam, saveLead } from "@/lib/leads";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type === "submission.created") {
const created = await saveLead(event.id, event.data.fields);
if (created) after(() => notifyTeam(event.data.form.name, event.data.fields));
}
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response(error.reason, { status: 400 });
}
throw error;
}
}request.text() gives the raw body. The insert uses on conflict do nothing, so a retry of a delivery you already stored returns false and skips the notification. after() runs the notification once the response is sent, so Ikiform gets its 204 right away.
Express
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const app = express();
app.post("/webhooks/ikiform", express.text({ type: "application/json", limit: "512kb" }), async (req, res) => {
try {
const event = await verifyWebhook({
payload: req.body,
headers: req.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET,
});
if (event.type === "submission.created") {
console.info("New submission", event.id, event.data.fields);
}
res.status(204).end();
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);
res.status(500).end();
}
});
app.use(express.json());
app.listen(3000);express.text({ type: "application/json" }) keeps the body as the exact string Ikiform sent. A global express.json() registered before this route parses the body first and the signature fails, so register the webhook route first, as here.
Cloudflare Worker
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
interface Env {
IKIFORM_WEBHOOK_SECRET: string;
LEADS: KVNamespace;
}
export default {
async fetch(request: Request, env: Env) {
if (request.method !== "POST") return new Response(null, { status: 405 });
const payload = await request.text();
try {
const event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
if (event.type === "submission.created") {
await env.LEADS.put(event.id, JSON.stringify(event.data));
}
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
},
};{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "ikiform-webhooks",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"routes": [{ "pattern": "hooks.example.com", "custom_domain": true }],
"kv_namespaces": [{ "binding": "LEADS", "id": "<your KV namespace id>" }]
}Storing each submission under its delivery id makes retries harmless: writing the same key twice leaves one entry. Run npx wrangler types for the KVNamespace type. Ikiform refuses workers.dev URLs, so the custom_domain route puts the Worker on a hostname of yours.
Python: FastAPI and Flask
import base64
import hashlib
import hmac
import json
import os
import time
from fastapi import BackgroundTasks, FastAPI, Request, Response
app = FastAPI()
SECRET = base64.b64decode(os.environ["IKIFORM_WEBHOOK_SECRET"].removeprefix("whsec_"))
def verify(body: bytes, headers) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > 300:
return False
content = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(SECRET, content, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
def save_lead(delivery_id: str, fields: dict) -> None:
print("New submission", delivery_id, fields)
@app.post("/webhooks/ikiform")
async def ikiform_webhook(request: Request, background: BackgroundTasks):
body = await request.body()
if not verify(body, request.headers):
return Response("invalid signature", status_code=400)
event = json.loads(body)
if event["type"] == "submission.created":
background.add_task(save_lead, event["id"], event["data"]["fields"])
return Response(status_code=204)Read the bytes with await request.body() in FastAPI or request.get_data() in Flask, before anything parses them. In FastAPI, don't declare a Pydantic model as the body parameter; that parses the JSON before you can check it. BackgroundTasks runs save_lead after the 204 is sent.
Without the SDK
Ikiform uses the Standard Webhooks scheme, so any Standard Webhooks library works. By hand, in Node:
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
export function verify(body, headers) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const received = Buffer.from(signature, "base64");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}- Read webhook-id, webhook-timestamp and the raw body exactly as received.
- Reject the request if the timestamp is more than 5 minutes from now.
- Base64-decode the part of the secret after whsec_ and use it as the HMAC key.
- Compute HMAC-SHA256 of id.timestamp.body and base64-encode it.
- Compare it, in constant time, with each v1 value in webhook-signature. There are two while a secret rotates.
- If one matches: 2xx, handle it, answer fast.
- If none match: 400, with the reason.
The signed content is {webhook-id}.{webhook-timestamp}.{raw body}. The key is the base64-decoded part of the secret after whsec_. The header can hold several space-separated v1, signatures while a secret rotates; accept the request if any one matches.
Getting the raw body
| Framework | Raw body | Pitfall |
|---|---|---|
| Next.js App Router | await request.text() | Calling request.json() first |
| Next.js Pages Router | Read the stream yourself | The default body parser. Turn it off with export const config = { api: { bodyParser: false } } |
| Express | express.text({ type: "application/json" }) on the route | A global express.json() before it |
| Hono, Workers, Deno, Bun | await request.text() | None |
| Fastify | A content type parser with parseAs: "string" | The default JSON parser |
| FastAPI | await request.body() | A Pydantic model as the body parameter |
| Flask | request.get_data() | Re-serializing request.get_json() |
| Django | request.body | CSRF middleware. Mark the view @csrf_exempt; the signature replaces it |
Connect Ikiform
Add the endpoint in Ikiform
- In the Ikiform dashboard, open Webhooks and choose Add endpoint.
- Paste your handler's URL, like
https://example.com/webhooks/ikiforminto URL. - Under Forms, pick one form, or All forms in the workspace.
- Add a Description if you like, such as "Production API", then choose Add endpoint.
- Copy the signing secret. It starts with
whsec_and is shown in full only once.
There's no event picker. An endpoint gets
submission.createdfor every accepted submission on the forms you chose, andendpoint.testwhen you send a test. A workspace on Pro can have up to 3 endpoints.Set the secret
Store the
whsec_secret asIKIFORM_WEBHOOK_SECRETin your server's environment, never in the code. Deploy, then send a test.
Ikiform only delivers to public https:// URLs on port 443. IP addresses, localhost, internal hostnames, workers.dev and pages.dev are refused when you save the endpoint.
Testing locally
Ikiform can't reach localhost. Run a tunnel that gives your local server a public https:// URL on port 443, and add that URL as a second endpoint while you work:
cloudflared tunnel --url http://localhost:3000
ngrok http 3000Cloudflare's quick tunnel prints a trycloudflare.com URL, and ngrok prints its own. Both change on each run unless you set up a named tunnel or a reserved domain, so Edit the endpoint when they do. Delete the test endpoint when you're done; Pro allows 3.
Test it
Open the endpoint in Ikiform and choose Send test. Ikiform sends one endpoint.test event with no retries, and the result shows up in the endpoint's delivery log within a few seconds. Then send a real submission from your form, or from a terminal:
curl https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx \
-H "Content-Type: application/json" \
-d '{"name":"Ada","email":"ada@example.com","message":"Hello from curl"}'A 201 with "ok": true means Ikiform stored it. The delivery appears on the endpoint page, and the message lands in your server's logs.
How delivery works
| The receiver answers | Ikiform |
|---|---|
Any 2xx within 15 seconds | Marks the delivery succeeded |
| A redirect | Doesn't follow it, and counts a failure |
429 or 503 with Retry-After | Waits that long, up to the next step of the schedule |
410 Gone | Fails the delivery and disables the endpoint |
| Anything else, or no answer in 15 seconds | Retries |
Failed deliveries are retried 8 times over about 3 d 6 h, with the same webhook-id each time. If every delivery to an endpoint fails for 5 days, Ikiform disables it and emails the workspace owners.
- Any 2xx within 15 seconds: the delivery succeeded.
- 429 or 503 with Retry-After: Ikiform waits that long, up to the next step of the retry schedule.
- 410 Gone: the delivery fails and the endpoint is disabled.
- A redirect, any other status, or no answer within 15 seconds: Ikiform retries, 8 retries over about 3 d 6 h, with the same webhook-id.
Answer 410 Gone only when you want Ikiform to stop sending to this URL for good; it disables the endpoint immediately. For a short outage, answer 503 with Retry-After.
Troubleshooting
| Problem | What to do |
|---|---|
400 no_matching_signature | The secret is wrong, or the body was parsed and re-serialized before verifying. Use the whsec_ secret of this endpoint and the raw body exactly as received. After Roll secret, update it within 24 hours. |
400 timestamp_too_old | The signature is more than 5 minutes old. Check the server clock, and don't replay old deliveries by hand; use Retry now on the delivery instead, which signs it again. |
400 missing_headers | A proxy or framework dropped the webhook-* headers, or the request didn't come from Ikiform. Header names are lowercase; look them up case-insensitively. |
Delivery log shows redirect_not_followed | The URL answers with a 3xx. Use the final URL, with the exact path and trailing slash, so it answers 2xx directly. |
Delivery log shows timeout | Nothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away. |
| The same submission arrives twice | A retry after a slow or failed answer. Every retry carries the same webhook-id, so skip ids you've already handled. |
| Endpoint is disabled | Every delivery failed for 5 days, the URL answered 410, or its hostname started resolving to a private address. Fix the cause, then turn Enabled back on and Retry now the failed deliveries. |
| No delivery at all | Check that the endpoint covers this form, that it's enabled, and that the submission wasn't marked as spam or held because the workspace is over its monthly limit. |
Next steps
- Fetch more about a submission with the REST API, using
data.submission_id. - Validate fields at Ikiform's edge before they reach you: define fields.
- Read every header, retry and limit in Webhooks.
Stuck on a step, or want a guide for another tool? Email hello@ikiform.com.