# Send form submissions to a Notion database

> Turn every Ikiform submission into a page in a Notion database, with a small relay that verifies the signature and calls the Notion API.

Published 2026-10-23 · Guide, Notion · https://ikiform.com/blog/form-submissions-to-notion

A Notion database works well as a lightweight inbox for applications, feedback or leads. Your team can sort, assign and comment on each submission. This guide creates one Notion page per Ikiform submission.

Diagram: How a submission gets from your form to Notion

1. A visitor submits your form, and it posts to Ikiform.
2. Ikiform stores the submission and sends a signed webhook to your relay.
3. The relay checks the signature, maps fields to properties and creates a page with the Notion API.
4. Notion answers the relay, and the relay answers Ikiform.

The relay checks Ikiform's signature, then creates a page through the Notion API.

## Why there's a relay

Notion has no inbound webhook URL for databases. New pages are created with an authenticated call to `POST /v1/pages`, with a body in Notion's own shape. Ikiform can't add Notion's headers or send a custom body, so a relay in between does it. It also keeps your Notion token out of Ikiform.

If you'd rather not deploy code, [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md), [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) and [Make](https://ikiform.com/blog/form-submissions-to-make.md) all have a Notion action that does the same thing.

## Before you start

- An Ikiform form that already receives submissions.
- A Notion workspace where you're a workspace owner, so you can create a connection.
- Somewhere to run the relay: a Cloudflare account with a domain on it, or a Next.js app on Vercel or any other host.

## What Ikiform sends

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

## Set it up

### Step 1: Create the database

Create a Notion database with these properties. The names must match exactly, including case.

| Property | Type | Filled with |
| --- | --- | --- |
| `Name` | Title | The form's `name` field, or the form name |
| `Email` | Email | The `email` field |
| `Message` | Text | The `message` field |
| `Submitted` | Date | `data.created_at` |
| `Submission ID` | Text | `data.submission_id` |

Every field is also written into the page body as one line each, so fields without a property aren't lost. Add more properties later by extending the `PROPERTIES` map in the relay.

### Step 2: Create a Notion connection

1. Open [app.notion.com/developers/connections](https://app.notion.com/developers/connections) and create a new internal connection for your workspace.
2. Give it the capability to insert content.
3. Copy the token from the **Configuration** tab.

Then open the database in Notion, choose the **•••** menu at the top right, choose **Add connections**, and pick your connection. Without this step the API answers `404 object_not_found`.

### Step 3: Find the data source ID

Since API version 2025-09-03, pages are created under a data source, not a database. In Notion, open the database's **Manage data sources** menu and choose **Copy data source ID**. Or ask the API, using the database ID from the database's URL:

`Terminal`:

```bash
curl https://api.notion.com/v1/databases/$DATABASE_ID \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2026-03-11"
```

`Response`:

```json
{
  "object": "database",
  "id": "2a8e5f3c-0000-0000-0000-000000000000",
  "data_sources": [
    { "id": "1f2c7b9e-0000-0000-0000-000000000000", "name": "Leads" }
  ]
}
```

Use the `id` inside `data_sources`.

### Step 4: Write the relay

Both versions verify the signature with `@ikiform/sdk`, map fields to properties, and ignore `endpoint.test` events so tests don't create pages.

**Cloudflare Worker** (`src/index.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  NOTION_TOKEN: string;
  NOTION_DATA_SOURCE_ID: string;
}

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

const NOTION_VERSION = "2026-03-11";
const TITLE_FIELD = "name";
const PROPERTIES: Record<string, { property: string; type: "rich_text" | "email" | "url" | "number" | "select" }> = {
  email: { property: "Email", type: "email" },
  message: { property: "Message", type: "rich_text" },
};

function richText(text: string) {
  const characters = Array.from(text);
  const chunks: string[] = [];
  for (let index = 0; index < characters.length && chunks.length < 100; index += 2000) {
    chunks.push(characters.slice(index, index + 2000).join(""));
  }
  return (chunks.length ? chunks : [""]).map((content) => ({ type: "text", text: { content } }));
}

function property(type: string, value: unknown) {
  const text = valueText(value);
  if (type === "email") return { email: text.slice(0, 200) };
  if (type === "url") return { url: text.slice(0, 2000) };
  if (type === "select") return { select: { name: text.replaceAll(",", " ").slice(0, 100) } };
  if (type === "number") {
    const number = Number(value);
    return Number.isFinite(number) ? { number } : null;
  }
  return { rich_text: richText(text) };
}

function toNotion(event: Extract<WebhookEvent, { type: "submission.created" }>, dataSourceId: string) {
  const { form, fields, submission_id, created_at } = event.data;
  const title = fields[TITLE_FIELD] ? valueText(fields[TITLE_FIELD]) : `${form.name} submission`;
  const properties: Record<string, unknown> = {
    Name: { title: richText(title.slice(0, 2000)) },
    Submitted: { date: { start: created_at } },
    "Submission ID": { rich_text: richText(submission_id) },
  };
  for (const [field, target] of Object.entries(PROPERTIES)) {
    if (fields[field] === undefined || fields[field] === "") continue;
    const value = property(target.type, fields[field]);
    if (value) properties[target.property] = value;
  }
  const children = Object.entries(fields)
    .slice(0, 99)
    .map(([name, value]) => ({
      object: "block",
      type: "paragraph",
      paragraph: { rich_text: richText(`${name}: ${valueText(value)}`) },
    }));
  if (event.data.truncated) {
    children.push({
      object: "block",
      type: "paragraph",
      paragraph: { rich_text: richText("The submission was too large to include. Open it in Ikiform.") },
    });
  }
  return { parent: { type: "data_source_id", data_source_id: dataSourceId }, properties, children };
}

async function createPage(event: WebhookEvent, token: string, dataSourceId: string) {
  if (event.type !== "submission.created") return new Response(null, { status: 204 });
  return fetch("https://api.notion.com/v1/pages", {
    method: "POST",
    headers: {
      authorization: `Bearer ${token}`,
      "content-type": "application/json",
      "notion-version": NOTION_VERSION,
    },
    body: JSON.stringify(toNotion(event, dataSourceId)),
  });
}

export default {
  async fetch(request: Request, env: Env) {
    if (request.method !== "POST") return new Response(null, { status: 405 });
    const payload = await request.text();
    let event: WebhookEvent;
    try {
      event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
      throw error;
    }
    const response = await createPage(event, env.NOTION_TOKEN, env.NOTION_DATA_SOURCE_ID);
    if (response.status === 429) {
      const retryAfter = response.headers.get("retry-after") ?? "30";
      return new Response("Notion rate limit", { status: 503, headers: { "retry-after": retryAfter } });
    }
    if (!response.ok) return new Response(`Notion answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  },
};
```

**Next.js** (`app/api/ikiform/notion/route.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

const NOTION_VERSION = "2026-03-11";
const TITLE_FIELD = "name";
const PROPERTIES: Record<string, { property: string; type: "rich_text" | "email" | "url" | "number" | "select" }> = {
  email: { property: "Email", type: "email" },
  message: { property: "Message", type: "rich_text" },
};

function richText(text: string) {
  const characters = Array.from(text);
  const chunks: string[] = [];
  for (let index = 0; index < characters.length && chunks.length < 100; index += 2000) {
    chunks.push(characters.slice(index, index + 2000).join(""));
  }
  return (chunks.length ? chunks : [""]).map((content) => ({ type: "text", text: { content } }));
}

function property(type: string, value: unknown) {
  const text = valueText(value);
  if (type === "email") return { email: text.slice(0, 200) };
  if (type === "url") return { url: text.slice(0, 2000) };
  if (type === "select") return { select: { name: text.replaceAll(",", " ").slice(0, 100) } };
  if (type === "number") {
    const number = Number(value);
    return Number.isFinite(number) ? { number } : null;
  }
  return { rich_text: richText(text) };
}

function toNotion(event: Extract<WebhookEvent, { type: "submission.created" }>, dataSourceId: string) {
  const { form, fields, submission_id, created_at } = event.data;
  const title = fields[TITLE_FIELD] ? valueText(fields[TITLE_FIELD]) : `${form.name} submission`;
  const properties: Record<string, unknown> = {
    Name: { title: richText(title.slice(0, 2000)) },
    Submitted: { date: { start: created_at } },
    "Submission ID": { rich_text: richText(submission_id) },
  };
  for (const [field, target] of Object.entries(PROPERTIES)) {
    if (fields[field] === undefined || fields[field] === "") continue;
    const value = property(target.type, fields[field]);
    if (value) properties[target.property] = value;
  }
  const children = Object.entries(fields)
    .slice(0, 99)
    .map(([name, value]) => ({
      object: "block",
      type: "paragraph",
      paragraph: { rich_text: richText(`${name}: ${valueText(value)}`) },
    }));
  if (event.data.truncated) {
    children.push({
      object: "block",
      type: "paragraph",
      paragraph: { rich_text: richText("The submission was too large to include. Open it in Ikiform.") },
    });
  }
  return { parent: { type: "data_source_id", data_source_id: dataSourceId }, properties, children };
}

async function createPage(event: WebhookEvent, token: string, dataSourceId: string) {
  if (event.type !== "submission.created") return new Response(null, { status: 204 });
  return fetch("https://api.notion.com/v1/pages", {
    method: "POST",
    headers: {
      authorization: `Bearer ${token}`,
      "content-type": "application/json",
      "notion-version": NOTION_VERSION,
    },
    body: JSON.stringify(toNotion(event, dataSourceId)),
  });
}

export async function POST(request: Request) {
  const payload = await request.text();
  let event: WebhookEvent;
  try {
    event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  const response = await createPage(event, process.env.NOTION_TOKEN!, process.env.NOTION_DATA_SOURCE_ID!);
  if (response.status === 429) {
    const retryAfter = response.headers.get("retry-after") ?? "30";
    return new Response("Notion rate limit", { status: 503, headers: { "retry-after": retryAfter } });
  }
  if (!response.ok) return new Response(`Notion answered ${response.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

Diagram: How the relay maps an Ikiform submission to Notion page

1. fields.name: Ada becomes Name (title): Ada.
2. fields.email becomes Email (email).
3. fields.message becomes Message (text).
4. data.created_at becomes Submitted (date).
5. submission_id becomes Submission ID (text).
6. every field becomes page body: one line per field.

Without a name field, the title is the form name plus submission.

For the sample submission, the relay sends Notion this body:

`POST /v1/pages`:

```json
{
  "parent": { "type": "data_source_id", "data_source_id": "1f2c7b9e-0000-0000-0000-000000000000" },
  "properties": {
    "Name": { "title": [{ "type": "text", "text": { "content": "Ada" } }] },
    "Submitted": { "date": { "start": "2026-10-23T12:00:00.000Z" } },
    "Submission ID": { "rich_text": [{ "type": "text", "text": { "content": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3" } }] },
    "Email": { "email": "ada@example.com" },
    "Message": { "rich_text": [{ "type": "text", "text": { "content": "Hello" } }] }
  },
  "children": [
    { "object": "block", "type": "paragraph", "paragraph": { "rich_text": [{ "type": "text", "text": { "content": "name: Ada" } }] } }
  ]
}
```

- Notion allows 2,000 characters per text object, so long values are split into several objects.
- `TITLE_FIELD` and `PROPERTIES` decide which form fields go where. A field missing from the submission is skipped.
- Values are sent as plain text objects, so a visitor can't add links or mentions.
- A `429` from Notion becomes a `503` with Notion's `Retry-After`, so Ikiform waits before it tries again. Any other error becomes a `502`, which Ikiform retries.

### Step 5: Deploy it

### Cloudflare Worker

`Terminal`:

```bash
mkdir ikiform-notion && cd ikiform-notion
npm init -y
npm install @ikiform/sdk
npm install -D wrangler
```

Save the Worker code as `src/index.ts`, then set the route in `wrangler.jsonc`:

`wrangler.jsonc`:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "ikiform-notion",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "routes": [{ "pattern": "hooks.example.com", "custom_domain": true }]
}
```

> Ikiform refuses `workers.dev` URLs, so the Worker needs a hostname on a domain you have on Cloudflare, like `hooks.example.com`. The `custom_domain` route creates the DNS record and certificate for you on deploy.

Add the secrets and deploy:

`Terminal`:

```bash
npx wrangler secret put IKIFORM_WEBHOOK_SECRET
npx wrangler secret put NOTION_TOKEN
npx wrangler secret put NOTION_DATA_SOURCE_ID
npx wrangler deploy
```

Your relay URL is `https://hooks.example.com/`.

### Next.js

Add `@ikiform/sdk` to the app, save the route handler, and set `IKIFORM_WEBHOOK_SECRET`, `NOTION_TOKEN` and `NOTION_DATA_SOURCE_ID` in your host's environment variables. Use the production domain as the relay URL, not a protected preview URL.

### Step 6: Add the endpoint in Ikiform

- In the Ikiform dashboard, open **Webhooks** and choose **Add endpoint**.
- Paste your relay URL into **URL**.
- Under **Forms**, pick one form, or **All forms** in the workspace.
- Add a **Description** if you like, such as "Notion leads", 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 7: Add the secret to the relay

Set `IKIFORM_WEBHOOK_SECRET` to the `whsec_` secret. On Cloudflare run `npx wrangler secret put IKIFORM_WEBHOOK_SECRET` again. On other hosts, update the variable and redeploy.

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.

## 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 the Notion database.

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

The test event returns `204` from the relay and creates nothing. The real submission creates a page with the properties filled in.

## 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 the relay tells Ikiform when Notion answers

1. Notion accepts the request: the relay answers 204, and Ikiform marks the delivery succeeded.
2. Notion is rate limited (429): the relay answers 503 with Notion's Retry-After, and Ikiform waits that long before it tries again.
3. Notion answers with any other error: the relay answers 502, and Ikiform retries, 8 retries over about 3 d 6 h.

Notion allows about 180 requests a minute per connection. A normal form never comes close, and a burst that does gets Notion's `Retry-After` passed back to Ikiform.

> If Notion created the page but its answer took longer than 15 seconds, Ikiform retries and a second page appears. It's rare. The `Submission ID` property makes duplicates easy to spot, and you can filter on it before creating a page if you need strict once-only.

## 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. |
| `502` with `Notion answered 404` | The connection can't see the database. Add it with **•••** then **Add connections**, and check that `NOTION_DATA_SOURCE_ID` is the data source ID, not the database ID. |
| `502` with `Notion answered 400` | A property name or type doesn't match the database. Check the relay's logs for Notion's `validation_error` message, then fix the database or the `PROPERTIES` map. |
| `502` with `Notion answered 401` | The token is wrong or was revoked. Copy it again from the connection's **Configuration** tab. |
| Delivery log shows `timeout` | Nothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away. |
| 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

- Map more fields: add a `select` property for a topic field, or a `url` property for a website field.
- Send each form to its own database with one Ikiform endpoint and one relay per form.
- Post a message as well: see [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md).

| Notion reference | Link |
| --- | --- |
| Creating a connection | [developers.notion.com](https://developers.notion.com/docs/authorization) |
| Create a page | [developers.notion.com](https://developers.notion.com/reference/post-page) |
| Property values | [developers.notion.com](https://developers.notion.com/reference/page-property-values) |
| Data sources | [developers.notion.com](https://developers.notion.com/docs/upgrade-guide-2025-09-03) |
| Request limits | [developers.notion.com](https://developers.notion.com/reference/request-limits) |
