# 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.

Published 2026-10-23 · Guide, Webhooks · https://ikiform.com/blog/form-submissions-to-your-own-server

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.

Diagram: How a submission gets from your form to your server

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](#local).
- `@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`:

```http
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`:

```json
{
  "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](https://ikiform.com/docs/webhooks.md#payloads).

## 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

**Route handler** (`app/api/webhooks/ikiform/route.ts`)

```ts
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;
  }
}
```

**lib/leads.ts**

```ts
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function saveLead(deliveryId: string, fields: Record<string, unknown>) {
  const result = await pool.query(
    "insert into leads (delivery_id, fields) values ($1, $2) on conflict (delivery_id) do nothing",
    [deliveryId, JSON.stringify(fields)],
  );
  return result.rowCount === 1;
}

export async function notifyTeam(form: string, fields: Record<string, unknown>) {
  console.info(`New ${form} submission`, fields);
}
```

**SQL** (`schema.sql`)

```text
create table leads (
  id bigint generated always as identity primary key,
  delivery_id text not null unique,
  fields jsonb not null,
  received_at timestamptz not null default now()
);
```

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

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

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

```json
{
  "$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

**FastAPI** (`main.py`)

```python
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)
```

**Flask** (`app.py`)

```python
import base64
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
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")
def ikiform_webhook():
    body = request.get_data()
    if not verify(body, request.headers):
        return "invalid signature", 400
    event = request.get_json()
    if event["type"] == "submission.created":
        save_lead(event["id"], event["data"]["fields"])
    return "", 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](https://www.standardwebhooks.com) scheme, so any Standard Webhooks library works. By hand, in Node:

`verify.js`:

```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);
  });
}
```

Diagram: How your handler checks an Ikiform signature

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

| 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

### Step 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.

### Step 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`:

```bash
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](https://ikiform.com/docs/webhooks.md#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`:

```bash
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 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.

Diagram: What Ikiform does with each answer from your server

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

| 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](https://ikiform.com/docs/api.md#get-submission), using `data.submission_id`.
- Validate fields at Ikiform's edge before they reach you: [define fields](https://ikiform.com/docs/fields.md).
- Read every header, retry and limit in [Webhooks](https://ikiform.com/docs/webhooks.md).
