Notion
Paid add-on. Every submission creates a page in a Notion database, to turn an answer into a working file.
This document describes what the module guarantees and what it refuses. The user guide gives the short version, under “Creating a Notion page”.
What sets this module apart from Airtable, which it resembles
Both write a row into a base. Three things separate them, and they are the three sections that follow.
Notion creates the missing option, and no flag stops it
Writing a name that does not exist into a select property does not fail: Notion adds the option to the data source’s schema.
It is the same danger as Airtable’s typecast flag, with one difference that
makes it worse: there, you had to ask for it; here it is the default behaviour,
and there is nothing to switch off.
On a public form, that means a visitor typing whatever they like into a free field mapped to a select adds whatever they like to the options of the client’s base. That is no longer writing a page, it is modifying the schema — from outside, and without anybody having asked for it.
The safeguard is therefore in the module, since the API offers none. The permitted options are read at the moment of the setting and kept with the mapping; a value not among them is not sent. The comparison ignores case — a “Yes / No” list and a “yes” answer designate the same thing.
When the option exists, it is its id that goes out, not its name: a name renamed in Notion would have a duplicate created carrying the old one.
It is the data source that is targeted, not the database
A Notion database now carries one or more data sources, each with its own property schema.
As long as a database has only one, the old database id still works. The day
somebody adds a second, it stops designating anything, and Notion answers
validation_error: the id is no longer precise enough.
Adding a data source is an ordinary act of organisation, done by somebody who does not know a form writes there. Saving the database would therefore have been saving a time bomb.
The properties, meanwhile, are held by id and not by name — for the same reason as at Airtable: you rename a column the way you rename a file.
Notion has no idempotent write
No native merge as at Airtable, no PUT on a fingerprint as at Mailchimp:
POST /pages creates, always. A request whose response is lost on the way is
therefore replayed, and leaves two pages. The local unique key can do nothing
about it: the first call did succeed at Notion.
The only defence is to search first. With a matching property set, the module queries the data source on that value: it updates the page found, or creates one if it finds none. That costs one more call on a budget of three per second, and that is what the absence of duplicates costs.
The chosen property must be mapped — searching on a property you do not write would never find anything — and of a type the query filter knows how to compare against an exact value: title, text, number, select, address, email, phone. A checkbox would suit the filter but not the purpose: it distinguishes nobody.
When the key is set but empty on a submission, the module creates instead of searching, and writes it to the log: searching on an empty value would find the first page whose property is empty, which is to say any of them.
The connection screen shows the number of pages created rather than updated.
An integration sees nothing by default
That is the number one cause of a panel offering no database, and it is not an outage at all: in Notion, you have to open each database and add the connection to it, one by one.
Both screens say so before you start looking, and the connection test distinguishes “the token does not answer” from “the token answers but sees no database”.
The title is required
Every Notion page has one, and Notion displays “Untitled” when it is empty. A base filled with “Untitled” rows informs nobody and cannot be sorted.
The setting therefore requires a title property to be mapped — that is its only obligation. And if this particular submission left it empty, nothing is written: the reason says so.
What the module converts
| Property type | What is sent |
|---|---|
| Title, text, rich text | the value, truncated to 2,000 characters |
| Number | a number — the decimal comma and grouping spaces are read |
| Checkbox | true, except non, no, false, off, 0 |
| Select, multi-select | the option’s id if it already exists |
| Date | YYYY-MM-DD, or ISO 8601 if the entry carries a time |
| URL, email, phone | the value, truncated to Notion’s limit |
What does not convert is omitted, never guessed: Notion refuses the whole page for a single invalid property.
An empty value is not sent either: on an update, it would erase what the page already carried.
Truncating a text is better than having the page refused for a slightly long message — and that is the case of a “your message” field, which is precisely the one you map.
An internal integration token rather than an application
Both are possible; the screen recommends the first, and this time the reason is severe.
An internal integration token is created in the workspace, does not expire, and has nothing to renew.
An OAuth token lasts about eight hours, renews itself — rotating the refresh token — and goes out definitively 180 days after the first authorisation. That ceiling does not slide: renewing does not push it back by a day. A site connected by OAuth stops writing to Notion six months later, whatever its activity.
It is the only integration in this series whose date of death can be written in advance. The screen therefore displays it permanently, in days remaining, and warns in orange during the last month.
Renewal never happens in twos
Notion withdraws the old token pair as soon as it returns a new one. Two tasks renewing at the same time would lose the authorisation for good.
Renewal is therefore taken under an atomic lock — an insert settled by the unique key of the options table, the way WordPress locks its own updates. It is the same lock as Airtable’s, shared rather than copied: a copied guarantee is one more occasion to make it false.
Notion leaves a window of one step, in which the previous token is still accepted once more. That is a net, not a guarantee, and it is not what we rely on.
Three requests per second
180 a minute outside the Business and Enterprise plans, which go up to 600. A send
consumes one, two with a matching key. An overrun arrives as a 429, and the
retry waits a minute, then five, then thirty.
What is not attempted, and what is retried
Nothing is scheduled without a data source, without a mapped title, without a connection, or if the form’s condition is not met. A submission marked as spam or sent to the trash does not go out.
Definitive refusals are not replayed: share withdrawn, property renamed or
deleted, data source erased, incompatible type. object_not_found almost never
means “deleted” — it means “this integration no longer sees that database”, and
the gesture that repairs it is to go back there in Notion.
429 and 5xx are.
A Notion outage never blocks the submission. It is recorded, confirmed to the visitor and notified by email before this module is called on.
What the log does not contain
Neither the request, nor the response, nor the page’s id. The first two carry the submission’s values; the third opens a page whose title is, most often, a person’s name.
The address recorded is the endpoint’s, with nothing designating a particular file.
Schema
slf_notion_pages: one row per submission bound for Notion, with the data source,
the page’s id, whether it was created or updated, the state, the reason for the
last problem and the number of attempts.
The public address is not kept: the one Notion returns contains the page’s title. Only the id is kept, and the address is rebuilt at display time.
The connection lives in an option: the kind of connection, the client id, the workspace’s name and the date of first authorisation in clear — the last carrying the deadline — the tokens and the client secret encrypted.
Erasing a submission erases its tracking row and its log. The page already written in Notion stays: it belongs to the client’s workspace, and deleting it from here would be deciding in their place — on a workspace this module does not even have the read scope for.
What V1 does not do
No block in the page’s body, no file, no comment, no relation to another database, no computed property, no reading, no deletion, no reverse synchronisation.
An arbitrary block composed from a visitor’s input would be an injection vector into the client’s workspace, and what the module has to write fits in the properties — which are typed, bounded and viewable as a table. A form opens a file; it does not hold the workspace.
