One bearer key, two scopes, and the same write path the app uses — validation, casting, history, recalc and webhooks all fire whether a row arrives from a person, an agent or a script.
Create a key in the app under workspace settings. It is shown once and stored only as a hash.
curl https://bips.ai/v1/bases \ -H "Authorization: Bearer $BASE_KEY"
tables:readPOST /query — which is read-only however it is spelled.tables:writeschema:writeGive a sync job tables:write and nothing else. It can fill rows all day and cannot drop a table, rename a column or undo its way through your schema. Adding columns during an import needs schema:write too, which is why create_missing_columns is refused without it.
Keys minted before this split kept the shape access they already had — a credential in use is never quietly narrowed. Mint a new key to get the narrow one.
A missing key and an insufficient scope both answer 404: a key learns nothing about a base it may not touch. Exceeding the rate limit — 120 requests a minute per key — answers 429 with a Retry-After header.
A row's values live in cells, keyed by column key — a slug derived from the label when the column is created, and stable afterwards. Renaming a column does not move its key, so an integration written today keeps working after someone tidies the headers.
{
"id": "9f1c…", "source": "api",
"cells": { "name": "Ada", "amount": 499, "total": 998 },
"created_at": "…", "updated_at": "…"
}
Computed columns — formula, lookup, rollup, count, reverse-link — are evaluated on read and merged into cells alongside the stored ones. They are never writable.
List endpoints take limit (1–500, default 100) and offset, and answer with the page, the total, and the next offset — or null when there is no next page. Paging happens in the database, not after the fact.
Flat, machine-first, one snake-case code per failure: not_found, invalid_request, invalid_cell (with field), invalid_options, forbidden_columns (with fields), rate_limited. Batch failures add rows_index and return the rows that did land.
| Endpoint | Does |
|---|---|
| Bases | |
| GET /v1/bases | Every base in the workspace. |
| POST /v1/bases | Create a base — optionally copying an Airtable one into it, with airtable_token and airtable_base_id. schema:write |
| GET /v1/bases/:base/overview | Tables with row counts and SQL view names. |
| Tables | |
| GET …/tables | List. |
| GET …/tables/:t | One table and its columns. |
| POST …/tables | Create — blank, from a template, or from imported data. schema:write |
| PATCH …/tables/:t | Rename, describe. schema:write |
| DELETE …/tables/:t | Archive. History is kept. schema:write |
Columns — all schema:write | |
| POST …/tables/:t/columns | Add a column of any of the 25 field types. |
| PATCH …/tables/:t/columns/:c | Relabel, change options. |
| DELETE …/tables/:t/columns/:c | Archive. Cell data is kept. |
| POST …/tables/:t/columns/reorder | Reorder by id. |
| GET …/tables/:t/columns/:key/link-choices | Rows a link column may point at. |
A batch is one round trip, one history entry and one webhook — not five hundred of each.
| Endpoint | Does |
|---|---|
| GET …/tables/:t/rows | A page of rows. limit, offset. |
| GET …/tables/:t/rows/:r | One row. |
| GET …/tables/:t/display?viewId= | Rows through a saved view — its filters, sorts, grouping and hidden columns applied server-side. |
| POST …/tables/:t/rows | Create one. |
| POST …/tables/:t/rows/batch | Create up to 500. |
| PATCH …/tables/:t/rows/:r | Merge cells into one row. |
| PATCH …/tables/:t/rows/batch | Merge into up to 500. |
| POST …/tables/:t/rows/:r/duplicate | Copy a row below itself. |
| POST …/tables/:t/rows/reorder | Move rows. |
| DELETE …/tables/:t/rows/:r | Archive one row. |
curl -X POST "$BASE/v1/bases/$B/tables/$T/rows/batch" \
-H "Authorization: Bearer $BASE_KEY" -H "content-type: application/json" \
-d '{"rows":[{"cells":{"name":"Ada"}},{"cells":{"name":"Grace"}}]}'
Rows written this way are stamped source: "api", which is what automation payloads report and what the activity feed shows. Deletes are soft: the row is archived, and the change log can put it back.
Every table is exposed as a read-only SQLite view named t_<slug>, so aggregates, joins and searches are one request rather than a download and a loop.
curl -X POST "$BASE/v1/bases/$B/query" \
-H "Authorization: Bearer $BASE_KEY" -H "content-type: application/json" \
-d '{"sql":"SELECT status, COUNT(*) n FROM t_orders GROUP BY status"}'
A single SELECT or WITH, nothing else, enforced in four independent layers and run inside a transaction that always rolls back. limit defaults to 200 and caps at 1,000.
Computed columns are not in the views. Formula, lookup, rollup, count and reverse-link values are evaluated by the formula engine on read, and SQLite cannot call it. Read those through the row endpoints. GET …/tables/:t reports each column's sql_type so you can discover the shape rather than guess it.
| Endpoint | Does |
|---|---|
Views — all schema:write | |
| POST …/tables/:t/views | Create a grid, calendar, gallery, gantt, chart, pivot, kanban or list view. |
| PATCH …/tables/:t/views/:v | Change filters, sorts, grouping, colours, hidden columns. |
| DELETE …/tables/:t/views/:v | Remove one. A table keeps at least one. |
| Comments | |
| GET …/tables/:t/rows/:r/comments | A row's thread. |
| POST …/tables/:t/rows/:r/comments | Add a comment. |
| History | |
| GET …/tables/:t/history | Undo tree, saved versions, recent changes. |
| GET …/tables/:t/rows/:r/history | Field-level before/after for one row. |
| GET …/tables/:t/asof | The table as it stood at a moment. |
| POST …/tables/:t/undo | Undo the last change unit. schema:write |
| POST …/tables/:t/redo | Redo along the newest branch. schema:write |
| POST …/tables/:t/goto | Jump to any node of the undo tree. schema:write |
| POST …/tables/:t/save-version | Name a snapshot. |
| POST …/tables/:t/restore | Restore a saved version. schema:write |
| Endpoint | Does |
|---|---|
| POST …/tables | With mode: "import", creates a table from the data. |
| POST …/tables/:t/import | Appends into an existing table, matching headers to columns. create_missing_columns needs schema:write. |
| POST …/tables/:t/paste | Writes a rectangle of cells. |
| GET …/tables/:t/export.csv | The whole table as CSV. |
Import accepts CSV, TSV, JSON, NDJSON, a markdown table or XLSX — inline in data, as a base64 data: URL, or as a url for Base to fetch (public hosts only, 5 MB cap, every redirect re-checked). Types are inferred conservatively; at most 5,000 rows land per call and truncated tells you when the rest did not.
A rule watches a table or a form and POSTs a signed JSON body to your URL when it fires.
| Endpoint | Does |
|---|---|
| GET …/automations | List rules with their last delivery status. |
| POST …/automations | Create. The signing secret comes back once. |
| PATCH …/automations/:a | Rename, retarget, enable or disable. |
| DELETE …/automations/:a | Delete the rule and its log. |
| POST …/automations/:a/test | Send a signed ping now. |
| GET …/automations/:a/deliveries | The newest 100 deliveries. |
| POST …/automations/:a/deliveries/:d/retry | Re-send one. |
The API is default-deny: a path that is not on its allowlist answers 404 whatever the key. Two things are held back on purpose.
There is a second machine surface. Base's MCP server covers everything here plus forms, and speaks OAuth as well as API keys — so an assistant can drive a base the way a person drives the app. See Base vs Airtable over MCP.
How this API lines up against Airtable's, endpoint by endpoint — including what Base is missing — is written up in the repository as docs/airtable-api-audit.md. Matching Airtable was never the goal; measuring the distance was worth doing anyway.