splitforms.com
All articles/ INTEGRATIONS10 MIN READPublished May 2, 2026

How to Send Form Submissions to Notion

Push every contact form submission into a Notion database as a fully-typed page. Create an integration token, grab a database ID, map fields to Notion properties, and wire up a tiny Cloudflare Worker proxy that forwards splitforms webhooks to the Notion API.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Why route forms to a Notion database

Notion has quietly become the default lightweight CRM for indie founders, agencies, and content teams. If your leads, project briefs, podcast guest pitches, or job applications all live in a Notion database with the same ten properties — name, email, source, status, priority, owner, notes — you don't need Salesforce. You need a way to get the inbound form into the right row, automatically, without copy-paste.

The vanilla Notion product doesn't ship a public form builder you can embed on a site. Their "form view" (added in late 2024) is internal-only and locked to the Notion domain — you can't style it, you can't put it on your homepage, and it bounces visitors out of your site to fill it in. So the actual move is: keep your contact form on your own domain (HTML, React, whatever), point it at splitforms, and forward each webhook to the Notion API. Below is the full setup including the 30-line Cloudflare Worker that does the actual POST.

Step 1 — Create the Notion integration

Go to https://www.notion.so/my-integrations while signed into the workspace that owns your target database. Click + New integration.

  1. Name it something like "splitforms → Leads DB".
  2. Pick the workspace that contains the database.
  3. Type: Internal (not Public — that's for OAuth apps).
  4. Capabilities: check Insert content at minimum. Add Update content if you want to dedupe by updating existing rows; Read content if you want to look up rows before writing.
  5. Click Save, then on the next screen click Show next to the Internal Integration Secret and copy it. It starts with secret_ or ntn_.

Treat that token like a database password. It grants write access to anything you share with it. If it leaks, regenerate from the same page — the URL stays valid, only the secret changes.

Step 2 — Get the database ID and share it with the integration

The integration starts with zero permissions. You have to explicitly share each database with it before the API will let you write rows.

  1. Open the database as a full page (not a linked view).
  2. Click the ... menu in the top-right → Connections → search your integration name → click it.
  3. Click ShareCopy link.

The URL looks like:

https://www.notion.so/myworkspace/Leads-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p?v=abc123...

The 32-character hex string before ?v= is the database ID. The Notion API accepts it with or without hyphens — both 1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p and 1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p work.

Step 3 — Map form fields to Notion properties

Notion property types are strict. A title property does not accept the same JSON shape as a rich_text, and getting it wrong returns a 400 with a not-very-helpful error. Here's the cheat sheet for the four types you'll use 95% of the time:

// title — every database has exactly one. Usually "Name" or "Lead".
"Name": {
  "title": [{ "text": { "content": "Ada Lovelace" } }]
}

// rich_text — multi-line freeform fields like "Message" or "Notes".
"Message": {
  "rich_text": [{ "text": { "content": "Hi, I'd like a demo." } }]
}

// email / phone_number / url — typed strings, validated by Notion.
"Email":  { "email": "ada@example.com" },
"Phone":  { "phone_number": "+1-555-123-4567" },
"Source": { "url": "https://example.com/pricing" }

// select — must match an existing option exactly (case-sensitive).
"Status": { "select": { "name": "New" } }

// multi_select — array of names.
"Tags":   { "multi_select": [{ "name": "demo" }, { "name": "enterprise" }] }

// date — ISO 8601 string.
"Submitted": { "date": { "start": "2026-05-02T12:00:00Z" } }

// checkbox
"Subscribed": { "checkbox": true }

// number
"Score": { "number": 42 }

The full Notion request body for a new database row looks like this:

POST https://api.notion.com/v1/pages
Authorization: Bearer secret_AbC123...
Notion-Version: 2022-06-28
Content-Type: application/json

{
  "parent": { "database_id": "1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" },
  "properties": {
    "Name":      { "title":     [{ "text": { "content": "Ada Lovelace" } }] },
    "Email":     { "email":     "ada@example.com" },
    "Message":   { "rich_text": [{ "text": { "content": "Hi, testing." } }] },
    "Status":    { "select":    { "name": "New" } },
    "Submitted": { "date":      { "start": "2026-05-02T12:00:00Z" } }
  }
}

The Notion-Version header is required. 2022-06-28 is still the current stable version as of May 2026 — Notion has been remarkably restrained about breaking changes. If you omit it the API returns a 400.

Step 4 — The webhook proxy (Cloudflare Worker)

splitforms can deliver webhooks to any HTTPS endpoint, but the Notion API requires a bearer token in a header — and you don't want that token sitting in a public splitforms config UI. The right pattern is a tiny stateless proxy that holds the token as an environment variable and translates the splitforms payload into the Notion shape.

Cloudflare Workers are ideal: free tier covers 100k requests/day, runs in ~10ms cold start, and gives you proper secret env vars. Save this as worker.js:

export default {
  async fetch(req, env) {
    if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });

    const submission = await req.json();
    const f = submission.fields ?? submission.data ?? {};

    const body = {
      parent: { database_id: env.NOTION_DATABASE_ID },
      properties: {
        Name:      { title:     [{ text: { content: f.name || "(no name)" } }] },
        Email:     f.email ? { email: f.email } : undefined,
        Message:   f.message ? { rich_text: [{ text: { content: f.message } }] } : undefined,
        Status:    { select: { name: "New" } },
        Submitted: { date:   { start: submission.submitted_at || new Date().toISOString() } },
        Source:    f.source ? { url: f.source } : undefined,
      },
    };

    // strip undefined keys so Notion doesn't 400
    Object.keys(body.properties).forEach(
      (k) => body.properties[k] === undefined && delete body.properties[k]
    );

    const res = await fetch("https://api.notion.com/v1/pages", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.NOTION_TOKEN}`,
        "Notion-Version": "2022-06-28",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (!res.ok) {
      const err = await res.text();
      console.error("Notion API error", res.status, err);
      return new Response(err, { status: 502 });
    }

    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

Deploy with wrangler deploy. Set the two secrets:

wrangler secret put NOTION_TOKEN
# paste the secret_... token from Step 1
wrangler secret put NOTION_DATABASE_ID
# paste the 32-char database ID from Step 2

You'll get a public URL like https://splitforms-notion.your-subdomain.workers.dev. That's your webhook destination. Drop it into your splitforms dashboard under the form's Webhooks → Add → Generic JSON setting. The same code runs on Vercel Edge Functions, Deno Deploy, or any Node host — swap env.NOTION_TOKEN for process.env.NOTION_TOKEN.

Step 5 — Test with curl

Before pointing your live form at the worker, fire a synthetic submission at it from your terminal:

curl -X POST https://splitforms-notion.your-subdomain.workers.dev \
  -H "Content-Type: application/json" \
  -d '{
    "submitted_at": "2026-05-02T12:00:00Z",
    "fields": {
      "name":    "Ada Lovelace",
      "email":   "ada@example.com",
      "message": "Hi — testing the Notion integration.",
      "source":  "https://example.com/contact"
    }
  }'

You should get {"ok":true} back in under 200ms and see a new row appear in the Notion database within another second. If you get a 502, check the worker logs (wrangler tail) — most failures are a property-name mismatch (Notion is case-sensitive) or a select option that doesn't exist on the database (you need to add "New" to the Status property options first, manually in Notion).

For a copy-paste contact form pointed at splitforms, see the contact form templates— drop one in, change the action URL to your form's endpoint, and you're done.

Hardening: HMAC, retries, idempotency

The 40 lines above will serve you well for any normal-volume form. Three upgrades worth the extra code if this is going into production:

1. Verify the splitforms HMAC signature. Anyone who guesses your worker URL can spam your Notion database otherwise. splitforms signs every webhook with the form's secret in the X-Splitforms-Signatureheader. Reject any request that doesn't match:

async function verify(req, secret) {
  const sig = req.headers.get("X-Splitforms-Signature");
  const body = await req.text();
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
  );
  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
  const expected = Array.from(new Uint8Array(mac))
    .map((b) => b.toString(16).padStart(2, "0")).join("");
  return { ok: sig === expected, body };
}

2. Idempotency. splitforms retries failed webhooks for up to 24 hours with exponential backoff. If your worker returns a 502 and Notion actually wrote the row anyway (rare but possible on timeout), you'll get duplicates. Cheapest fix: pass the splitforms submission_id as a unique property on the Notion row, then on retry query the database first and skip if it exists. The full splitforms webhook envelope and retry policy is in the docs.

3. Multiple databases per form. If you want sales leads in one DB and support tickets in another, branch on a form field inside the worker — read fields.type and pick the right NOTION_DATABASE_ID. Pair this with the contact form routing playbook for the full pattern.

Tech support and troubleshooting

The five Notion API errors that account for almost every failed webhook:

  • 401 UnauthorizedThe integration token is wrong, was rotated, or wasn't pasted into wrangler secret put. Regenerate from notion.so/my-integrations and re-deploy.
  • 404 object_not_foundThe integration hasn't been added to the database via Connections. Open the DB > ... > Connections > pick your integration.
  • 400 validation_error on selectNotion select values must match an existing option exactly (case-sensitive). Pre-create options like 'New' on the Status property.
  • 400 missing Notion-VersionThe Notion-Version: 2022-06-28 header is mandatory. Make sure your proxy sends it on every request.
  • Rich-text fields truncatedEach rich_text block has a 2,000-character cap. For long messages, split content across multiple block objects in the array.

Webhook signature, retry policy, and event payload are documented in the splitforms docs; raw endpoint reference is in the API reference. For account or billing questions see the splitforms FAQ.

FAQ

Why send form submissions to a Notion database instead of email?

Notion is where most small teams already track leads, projects, and content briefs. Routing inbound forms straight into a Notion database means no copy-paste from inbox into a tracker — every submission shows up as a typed row with select tags, dates, and rich-text properties you can filter, sort, and assign. It also turns Notion into a lightweight CRM without paying for a real one.

Do I need a paid Notion plan to use the API?

No. The Notion API is available on the free plan with the same endpoints and rate limits as paid tiers. You will hit the 1,000-block-per-page limit on extremely large rich-text fields, but for contact forms you'll never come close. The only restriction is request volume — three requests per second sustained, which is more than enough for any inbound form.

What's the difference between a Notion integration token and an OAuth app?

An internal integration token is a single secret that grants access to whatever pages and databases you explicitly share with it inside one workspace — perfect for a personal site or a single client's CRM. An OAuth app is what you'd build if you were shipping a product where end users connect their own Notion. For one-way form-to-Notion forwarding, the internal integration token is the right call.

How do I find the database ID in Notion?

Open the database as a full page, click Share → Copy link. The URL looks like https://www.notion.so/workspace/My-Leads-1a2b3c4d5e6f7g8h9i0j... — the 32-character hex string before the question mark is the database ID. You can paste it raw or with hyphens; the Notion API accepts both.

Can I map a form file upload to a Notion file property?

Yes, but Notion's Files & Media property requires an external URL — you can't upload binary content directly via the public API. The pattern is: splitforms hosts the uploaded file (S3-backed CDN URL is included in the webhook payload), and your proxy passes that URL into the files property as a type external entry. The file shows up in Notion as a clickable attachment.

What happens if a form field is missing or empty?

Notion accepts properties as a partial object — you only need to include the keys you want to set. If a field is empty in the form payload, your proxy should omit that property entirely rather than sending an empty string, which the API rejects for select and date types. Use a simple null-check before adding each property to the request body.

Is there a way to do this without writing code?

Yes — Make.com (formerly Integromat), Zapier, and n8n all have native Notion modules that connect to splitforms via the generic webhook trigger. Make is usually the cheapest path for low-volume forms ($9/mo for 10,000 ops). The trade-off is one more vendor in the chain and more latency. The Cloudflare Worker proxy below runs in ~30ms and costs nothing.

Will Notion send me a notification when a new row is added via the API?

Notion only sends in-app notifications when a human edits a page, not when an API integration writes one — by design, to avoid spam. If you want a ping, add a second splitforms webhook destination (email, Slack, or Discord) on the same form. That's the cheapest way to get both the structured Notion row and the human-readable alert.

Next steps

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open