Airtable
Paid add-on. Every submission writes a row into an Airtable table, to feed an operational tracker or an internal portal.
This document describes what the module guarantees and what it refuses. The user guide gives the short version, under “Writing to an Airtable base”.
What sets this module apart from the others in the series
It does not write into a CRM or a sending platform, but into a base that people open and read. Three things follow: the values are the ones a human would read, the columns are designated by their id, and the module converts for itself instead of letting Airtable guess.
The three sections that follow are the module’s three decisions.
Columns go out by id, never by name
Airtable accepts both in a write. The name is what you read on screen; the id —
fldXXXXXXXXXXXXXX — is what does not change.
Renaming a column is the most ordinary gesture in the world in Airtable. It is
also what breaks a mapping by name: Airtable answers UNKNOWN_FIELD_NAME, a
definitive refusal, and the submission is lost — for one word changed in an
interface, by somebody who did not know a form was writing there.
The id is therefore what gets saved, and the readable name is kept only for display.
The same goes for the base and the table.
The module converts; Airtable does not guess
Airtable offers a typecast flag which makes any string acceptable for any
column. For a single-select, it does not merely convert: it creates the missing
option.
Put on a public form, that means a visitor typing whatever they like into a free field mapped to a single-select adds whatever they like to the options of the client’s base. That is no longer writing a row, it is modifying the schema — from outside, and without anybody having asked for it.
The flag therefore stays off. The conversion happens here, from the column’s type, recorded with the mapping at the moment of the setting:
| Column type | What is sent |
|---|---|
| Text, long text, email, URL, phone, single select | the value as it is |
| Number, currency, percent, rating, duration | a number — the decimal comma and grouping spaces are read |
| Checkbox | true, except non, no, false, off, 0 |
| Multiple select | the value re-split on commas |
| Date | YYYY-MM-DD — the DD/MM/YYYY form is recognised before anything else |
| Date and time | ISO 8601 in universal time |
What does not convert is omitted, never guessed. Omitting costs an empty
column; guessing costs a 422 refusal on the whole record, because Airtable
does not keep the valid fields of a request one field of which is not. A
misread date would therefore lose the complete submission rather than a column.
An empty value is not sent either: on an update, it would overwrite what the row already carried.
If every value is omitted, nothing is written: an empty row in the base informs nobody and is indistinguishable from an outage.
The merge key is what makes the write idempotent
With no key, every send creates. A request whose response is lost on the way is replayed, and leaves two rows in the base. The local unique key can do nothing about it: the first call did succeed at Airtable.
With a key, the write is a PATCH carrying performUpsert: Airtable looks for
the row on that column, updates it if it exists, creates it otherwise.
The key is therefore not a comfort option, and the panel says so in plain words rather than letting the duplicates be discovered. It must be a mapped column — merging on a column you do not write would never find anything — and of a type Airtable allows: text, long text, email, URL, phone, number, single or multiple select, date. A checkbox or a computed column has the whole request refused, and is therefore not offered.
When the key is set but empty on a submission, the module creates instead of merging, and writes it to the log: merging on an empty column would find the first row whose column is empty, which is to say any of them.
The connection screen shows the number of records created rather than merged. That is the figure you come looking for when a base fills with duplicates.
A personal token rather than an application
Both are possible; the screen recommends the first, and not out of convenience.
A personal access token is created in a minute at
airtable.com/create/tokens, does not expire, and carries the scopes you give it
— here data.records:write and schema.bases:read. There is nothing to renew.
An OAuth token lasts an hour and renews itself, rotating the refresh token: Airtable invalidates the old pair as soon as it returns a new one. That is precisely the trap, and it is detailed below.
OAuth is still offered for the case where it is imposed: a token that must not belong to one particular person’s account, because that person will leave the company before the site does.
Renewal never happens in twos
Two tasks renewing at the same time produce this: the first obtains a fresh pair and saves it; the second presents the token it had read earlier, already dead, is refused — and would erase the valid pair the first had just saved.
The authorisation would be lost for good. No retry finds it again: a human has to come back and reauthorise. And it would happen precisely when the site is doing well, since it takes two closely spaced submissions to get there.
Renewal is therefore taken under a lock, and that lock is an insert settled by the unique key of the options table — never a read followed by a write, which would leave between the two the interval in which the other process reads the same thing. It is the procedure WordPress itself uses to lock its updates.
Whoever does not get the lock does not start waiting: it re-reads the connection, and if the other has finished, the token is fresh. Otherwise it gives up for this time, and the retry takes care of it. Putting a scheduled task to sleep would immobilise the process running all the others.
A personal token does not go through any of that: it has no rotation, no lock, and no possible failure.
PKCE, which Airtable makes compulsory
The other integrations in this series do without it. Airtable requires it: the authorisation request carries the SHA-256 fingerprint of a randomly drawn secret, and the exchange carries the secret itself. An intercepted authorisation code is therefore worth nothing without that secret, which never left this server.
The verifier is kept in the same per-user transient as the state, and destroyed on use.
The client secret, meanwhile, travels in the Authorization: Basic header — that
is the form Airtable imposes; placed in the body, it is refused.
Five requests per second per base
That is the documented limit, and it is low. A send consumes only one — two at
most with a token renewal. A 429 asks you to wait thirty seconds; the retry
waits a minute, then five, then thirty. It is not an outage, it is a queue.
What is not attempted, and what is retried
Nothing is scheduled without a base, without a table, without a mapped column, 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: unknown column, missing select option, incompatible type, deleted table, token without the write scope. Replaying them would give the same no three times over.
429 and 5xx are, at 1, 5 then 30 minutes.
An Airtable outage never blocks the submission. It is recorded, confirmed to the visitor and notified by email before this module is called on.
Resending
The resend button replays the same write: with a key, it updates the row; without a key, it creates a second one. The screen says so before you press.
What the log does not contain
Neither the request nor the response. The first carries the submission’s values, and in the second Airtable returns the record as it wrote it. Recording them would make the log a second copy, which would go off into backups and exports — where it would read as a technical trace when it describes a person.
The address, on the other hand, is recorded in full: a base and a table are not personal data, and they are the first two things to check when nothing is arriving.
Schema
slf_airtable_records: one row per submission bound for Airtable, with the base,
the table, the id of the record returned, whether it was created or merged, the
state, the reason for the last problem and the number of attempts.
The connection lives in an option: the kind of connection, the client id and the account id in clear — they are not secrets — the tokens and the client secret encrypted.
Erasing a submission erases its tracking row and its log. The row already written in Airtable stays: it belongs to the client’s base, and deleting it from here would be deciding in their place — on a base this module does not even have the read scope for.
What V1 does not do
No reading of records, no deletion, no attachment, no link to another table, no computed column, no reverse synchronisation.
An attachment is described by a publicly downloadable address, and a link by the id of a record that would first have to be looked up: both ask for more than a form has to give. A form writes; it does not hold the base.
