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.
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.
- A visitor submits your form, and it posts to Ikiform.
- Ikiform stores the submission and sends a signed webhook to your relay.
- The relay checks the signature, maps fields to properties and creates a page with the Notion API.
- Notion answers the relay, and the relay answers Ikiform.
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:
{
"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
Create the database
Create a Notion database with these properties. The names must match exactly, including case.
Property Type Filled with NameTitle The form's namefield, or the form nameEmailEmail The emailfieldMessageText The messagefieldSubmittedDate data.created_atSubmission IDText data.submission_idEvery 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
PROPERTIESmap in the relay.Create a Notion connection
- Open app.notion.com/developers/connections and create a new internal connection for your workspace.
- Give it the capability to insert content.
- 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.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
idinsidedata_sources.Write the relay
Both versions verify the signature with
@ikiform/sdk, map fields to properties, and ignoreendpoint.testevents so tests don't create pages.src/index.tsimport { 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 }); }, };- fields.name: Ada becomes Name (title): Ada.
- fields.email becomes Email (email).
- fields.message becomes Message (text).
- data.created_at becomes Submitted (date).
- submission_id becomes Submission ID (text).
- 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_FIELDandPROPERTIESdecide 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
429from Notion becomes a503with Notion'sRetry-After, so Ikiform waits before it tries again. Any other error becomes a502, which Ikiform retries.
Deploy it
Cloudflare Worker
Terminal mkdir ikiform-notion && cd ikiform-notion npm init -y npm install @ikiform/sdk npm install -D wranglerSave the Worker code as
src/index.ts, then set the route inwrangler.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 refusesworkers.devURLs, so the Worker needs a hostname on a domain you have on Cloudflare, likehooks.example.com. Thecustom_domainroute 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 deployYour relay URL is
https://hooks.example.com/.Next.js
Add
@ikiform/sdkto the app, save the route handler, and setIKIFORM_WEBHOOK_SECRET,NOTION_TOKENandNOTION_DATA_SOURCE_IDin your host's environment variables. Use the production domain as the relay URL, not a protected preview URL.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.createdfor every accepted submission on the forms you chose, andendpoint.testwhen you send a test. A workspace on Pro can have up to 3 endpoints.Add the secret to the relay
Set
IKIFORM_WEBHOOK_SECRETto thewhsec_secret. On Cloudflare runnpx wrangler secret put IKIFORM_WEBHOOK_SECRETagain. 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:
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.
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.
- Notion accepts the request: the relay answers 204, and Ikiform marks the delivery succeeded.
- Notion is rate limited (429): the relay answers 503 with Notion's Retry-After, and Ikiform waits that long before it tries again.
- 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.
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
selectproperty for a topic field, or aurlproperty 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 reference | Link |
|---|---|
| Creating a connection | developers.notion.com |
| Create a page | developers.notion.com |
| Property values | developers.notion.com |
| Data sources | developers.notion.com |
| Request limits | developers.notion.com |
Stuck on a step, or want a guide for another tool? Email hello@ikiform.com.