Blog

Notion8 min read

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.

Preet SutharFounder, Ikiform

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.

How a submission gets from your form to Notionyour formany html form,framework orserverIkiformstores it,signs awebhookrelayworker orroute handleryou deployNotionone page persubmissionpostsignedapi callHow a submission gets from your form to Notionyour formany html form, framework orserverIkiformstores it, signs a webhookrelayworker or route handler youdeployNotionone page per submissionpostsignedapi call
  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, Zapier and Make 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
{
  "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.

Set it up

  1. Create the database

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

    PropertyTypeFilled with
    NameTitleThe form's name field, or the form name
    EmailEmailThe email field
    MessageTextThe message field
    SubmittedDatedata.created_at
    Submission IDTextdata.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.

  2. Create a Notion connection

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

  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
    curl https://api.notion.com/v1/databases/$DATABASE_ID \
      -H "Authorization: Bearer $NOTION_TOKEN" \
      -H "Notion-Version: 2026-03-11"
    Response
    {
      "object": "database",
      "id": "2a8e5f3c-0000-0000-0000-000000000000",
      "data_sources": [
        { "id": "1f2c7b9e-0000-0000-0000-000000000000", "name": "Leads" }
      ]
    }

    Use the id inside data_sources.

  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.

    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 });
      },
    };
    How the relay maps an Ikiform submission to Notion pageIkiform eventNotion pagefields.name: AdaName (title): Adafields.emailEmail (email)fields.messageMessage (text)data.created_atSubmitted (date)submission_idSubmission ID (text)every fieldpage body: one line per fieldHow the relay maps an Ikiform submission to Notion pageIkiform eventNotion pagefields.name:AdaName (title):Adafields.emailEmail (email)fields.messageMessage(text)data.created_atSubmitted(date)submission_idSubmissionID (text)every fieldpage body: oneline per field
    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
    {
      "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.
  5. Deploy it

    Cloudflare Worker

    Terminal
    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
    {
      "$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
    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.

  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.

  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
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 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 the relay tells Ikiform when Notion answers2xxok204no contentdonedelivery succeeded429rate limited503same retry-afterwaitsthen tries againother error4xx or 5xx502bad gatewayretries8 retries over about3 d 6 hNotion answersrelay answersIkiformWhat the relay tells Ikiform when Notion answers2xxok204nocontentdonedeliverysucceeded429ratelimited503sameretry-afterwaitsthentriesagainothererror4xx or5xx502badgatewayretries8 retriesoverabout3 d 6 hNotionanswersrelayanswersIkiform
  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

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.
502 with Notion answered 404The 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 400A 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 401The token is wrong or was revoked. Copy it again from the connection's Configuration tab.
Delivery log shows timeoutNothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away.
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

  • 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.
Notion referenceLink
Creating a connectiondevelopers.notion.com
Create a pagedevelopers.notion.com
Property valuesdevelopers.notion.com
Data sourcesdevelopers.notion.com
Request limitsdevelopers.notion.com

Stuck on a step, or want a guide for another tool? Email hello@ikiform.com.