Blog

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.

Preet SutharFounder, Ikiform

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.

How a submission gets from your form to your serveryour formany html form,framework orserverIkiformstores it,signs a webhookyourhandlerverifies, skipsrepeats,answers 204after theansweremails, syncs,slow workpostsignedthenHow a submission gets from your form to your serveryour formany html form, framework orserverIkiformstores it, signs a webhookyour handlerverifies, skips repeats, answers204after the answeremails, syncs, slow workpostsignedthen
  1. A visitor submits your form, and it posts to Ikiform.
  2. Ikiform stores the submission and posts it to your endpoint, signed with your endpoint's secret.
  3. Your handler verifies the signature against the raw body, skips delivery ids it has seen, and answers 2xx within 15 seconds.
  4. Slow work, like notifications, runs after the answer.
Ikiform posts each submission to your endpoint, signed with your endpoint's secret.

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/sdk for 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

Headers
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:

submission.created
{
  "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

RuleWhy
Verify against the raw bodyThe signature covers the exact bytes. Parsed and re-serialized JSON can differ by a space and fail
Reject old timestampsA signature older than 5 minutes may be a replay. The SDK does this for you
Skip repeatsRetries carry the same webhook-id. Store it with a unique constraint
Answer within 15 secondsSlower 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

server.js
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

src/index.ts
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;
    }
  },
};
wrangler.jsonc
{
  "$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:

verify.js
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);
  });
}
How your handler checks an Ikiform signaturewebhook-idwebhook-timestampwithin 5 minraw bodyexact bytessecretbase64 after whsec_HMAC-SHA256of id.timestamp.bodywebhook-signaturev1,… one per secretany v1 match?2xxhandle it, answerfast400with the reasonkeyexpectedyesnofrom the requestyours, never sentHow your handler checks an Ikiform signaturewebhook-idwebhook-timestampwithin 5 minraw bodyexact bytessecretbase64 after whsec_HMAC-SHA256ofid.timestamp.bodywebhook-signaturev1,… one per secretany v1match?2xxhandle it, answerfast400with the reason
  1. Read webhook-id, webhook-timestamp and the raw body exactly as received.
  2. Reject the request if the timestamp is more than 5 minutes from now.
  3. Base64-decode the part of the secret after whsec_ and use it as the HMAC key.
  4. Compute HMAC-SHA256 of id.timestamp.body and base64-encode it.
  5. Compare it, in constant time, with each v1 value in webhook-signature. There are two while a secret rotates.
  6. If one matches: 2xx, handle it, answer fast.
  7. 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

FrameworkRaw bodyPitfall
Next.js App Routerawait request.text()Calling request.json() first
Next.js Pages RouterRead the stream yourselfThe default body parser. Turn it off with export const config = { api: { bodyParser: false } }
Expressexpress.text({ type: "application/json" }) on the routeA global express.json() before it
Hono, Workers, Deno, Bunawait request.text()None
FastifyA content type parser with parseAs: "string"The default JSON parser
FastAPIawait request.body()A Pydantic model as the body parameter
Flaskrequest.get_data()Re-serializing request.get_json()
Djangorequest.bodyCSRF middleware. Mark the view @csrf_exempt; the signature replaces it

Connect Ikiform

  1. 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/ikiform into 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.created for every accepted submission on the forms you chose, and endpoint.test when you send a test. A workspace on Pro can have up to 3 endpoints.

  2. Set the secret

    Store the whsec_ secret as IKIFORM_WEBHOOK_SECRET in 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:

Terminal
cloudflared tunnel --url http://localhost:3000
ngrok http 3000

Cloudflare'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.

To check a secret, headers and body without a server, paste a delivery into the signature verifier. It runs in the browser and can also generate a correctly signed example.

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:

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.

If the form has a human check or allowed domains turned on, a post from curl is refused or marked as spam, and spam never sends a webhook. Test from your real site instead.

How delivery works

The receiver answersIkiform
Any 2xx within 15 secondsMarks the delivery succeeded
A redirectDoesn't follow it, and counts a failure
429 or 503 with Retry-AfterWaits that long, up to the next step of the schedule
410 GoneFails the delivery and disables the endpoint
Anything else, or no answer in 15 secondsRetries

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.

What Ikiform does with each answer from your server2xxwithin 15 ssucceededdone429 or 503with retry-afterwaits that longup to the next step of theschedule410 gonefails itand disables the endpointanything elsea redirect, an error, or noanswer in 15 sretries8 retries over about3 d 6 hyour server answersIkiformWhat Ikiform does with each answer from your server2xxwithin 15 ssucceededdone429 or 503with retry-afterwaits thatlongup to the nextstep of theschedule410 gonefails itand disables theendpointanything elsea redirect, anerror, or noanswer in 15 sretries8 retries overabout 3 d 6 hyour server answersIkiform
  1. Any 2xx within 15 seconds: the delivery succeeded.
  2. 429 or 503 with Retry-After: Ikiform waits that long, up to the next step of the retry schedule.
  3. 410 Gone: the delivery fails and the endpoint is disabled.
  4. 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

ProblemWhat to do
400 no_matching_signatureThe 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_oldThe 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_headersA 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_followedThe URL answers with a 3xx. Use the final URL, with the exact path and trailing slash, so it answers 2xx directly.
Delivery log shows timeoutNothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away.
The same submission arrives twiceA retry after a slow or failed answer. Every retry carries the same webhook-id, so skip ids you've already handled.
Endpoint is disabledEvery 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 allCheck 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.