Developers
REST API v1
OpenRows' public, versioned API follows the shape of Airtable's Web API,
so integrations and scripts written for Airtable work with few changes. It lives under /api/v1
and is described, with every other route, in the OpenAPI document at GET /api/openapi.json.
Authentication
Send a personal access token (Settings → "Developer: personal access tokens"):
Authorization: Bearer or_pat_…
A token reaches what its owner can reach, narrowed to its scopes and its workspaces or bases.
The signed-in session cookie works too (same origin only). Integrations acting for other people
register an OAuth app and get or_oat_… access tokens that work the same way: see
oauth.md.
| Scope | Endpoints |
|---|---|
schema.bases:read |
GET /meta/bases, GET /meta/bases/:baseId/tables |
schema.bases:write |
POST /meta/bases/:baseId/tables, POST …/tables/:table/fields, PATCH …/fields/:fieldId; new select options with typecast |
data.records:read |
listing and reading records |
data.records:write |
creating, updating and deleting records |
data.recordComments:read / :write |
listing / posting comments |
webhook:manage |
a base's webhooks (see Webhooks) |
automations:read |
listing and reading a base's automations and their runs |
automations:manage |
turning automations on and off |
automations:run |
triggering webhook-received and button-clicked automations |
With Airtable's client
import Airtable from 'airtable';
const base = new Airtable({
apiKey: process.env.OPENROWS_TOKEN,
endpointUrl: 'https://app.openrows.io/api',
apiVersion: '1.0.0', // the client builds `/v<major>/…` URLs
}).base('<base id>');
const records = await base('Projects').select({ filterByFormula: "{Status} = 'Done'" }).all();
test/integration/v1-airtable.test.ts runs the official client (0.12) against the server on
every CI run.
With the OpenRows SDK
@openrows/sdk is a typed client generated from these
endpoints' contracts: batching to 10, paging, Retry-After on 429 and errors with their request
id. It is on npm with provenance: npm install @openrows/sdk.
import { OpenRows } from '@openrows/sdk';
const openrows = new OpenRows({ apiKey: process.env.OPENROWS_TOKEN });
const done = await openrows
.table('<base id>', 'Projects')
.select({ filterByFormula: "{Status} = 'Done'" })
.all();
From n8n
@openrows/n8n-nodes-openrows adds an
OpenRows node (records: get, get many, search, create, update, create or update, delete) and an
OpenRows Trigger (a signed webhook it registers on activation) to n8n, with a personal access
token. In a self-hosted n8n: Settings → Community nodes → Install, @openrows/n8n-nodes-openrows
(AGPL-3.0-only, so not an n8n verified node and not on n8n Cloud).
From AI agents (MCP)
AI agents (Claude Code, Cursor, any MCP client) use the API through the MCP server: remote
at POST /mcp with the token as a bearer header, or locally with @openrows/mcp-server over
stdio (npx -y @openrows/mcp-server, on npm). See mcp.md (releases too) and the
/docs/api/mcp page.
Endpoints
Method and path (under /api/v1) |
Airtable's |
|---|---|
GET /meta/bases |
List bases (permissionLevel: read, comment, edit, create, owner) |
GET /meta/bases/:baseId/tables |
Get base schema |
POST /meta/bases/:baseId/tables |
Create table (the first field is the primary field) |
POST /meta/bases/:baseId/tables/:tableIdOrName/fields |
Create field |
PATCH /meta/bases/:baseId/tables/:tableIdOrName/fields/:fieldId |
Update field (name, description) |
GET /:baseId/:tableIdOrName |
List records |
POST /:baseId/:tableIdOrName/listRecords |
List records, parameters in the body |
POST /:baseId/:tableIdOrName |
Create records (fields, or up to 10 records) |
PATCH / PUT /:baseId/:tableIdOrName |
Update / replace up to 10 records, performUpsert |
DELETE /:baseId/:tableIdOrName?records[]=… |
Delete up to 10 records |
GET / PATCH / PUT / DELETE /:baseId/:tableIdOrName/:recordId |
One record |
GET / POST /:baseId/:tableIdOrName/:recordId/comments |
Comments |
Tables are named by id or name, and only inside the base in the path. Cells are keyed by field
name, or by field id with returnFieldsByFieldId; writes accept either. Empty cells are left out
of answers. Deletes move records to the trash.
Ids
Ids are OpenRows' UUIDs: a base, table, field, view, record or comment id is the same UUID the app
and the internal API use. Airtable's prefixes (app…, tbl…, fld…, viw…, rec…, usr…)
aren't used; anything that only passes ids through works unchanged.
Cell values (cellFormat=json)
| Field | Value |
|---|---|
| Single select | the choice name |
| Multiple select | choice names |
| Link to another record | linked record ids |
| User, created by, last modified by | { id, name, email } (email for workspace members only) |
| Attachment | [{ id, filename, size, type, width?, height?, url }]; url needs the same credential and redirects to a short-lived signed URL |
| Others | as in the app's API (numbers, ISO dates, text, booleans) |
Field types answer with Airtable's name (multipleRecordLinks, multipleSelects,
multilineText, phoneNumber, multipleAttachments, singleCollaborator, …) and ours in
openrowsType. Creating a field takes either name; options are OpenRows' (new select choices may be
just { name }).
filterByFormula
Formulas run as database queries, never row by row. Supported:
AND(…),OR(…),NOT(…),TRUE(),FALSE()- a field compared with a value:
=,!=,<,<=,>,>=(text, numbers,TRUE(),FALSE(),BLANK()or'', andTODAY()for dates); text comparisons ignore case - a field on its own: checked (checkbox), or not empty
FIND('text', {Field})andSEARCH('text', {Field}), as a condition,> 0or= 0(both ignore case)IS_BEFORE({Date}, '2026-01-31'),IS_AFTER(…),IS_SAME(…, 'day')(dates compare by day)RECORD_ID() = '<id>'
Anything else (other functions, arithmetic, comparing two fields) answers 422
INVALID_FILTER_BY_FORMULA.
Errors
{ "error": { "type": "INVALID_FILTER_BY_FORMULA", "message": "…", "code": "INVALID_FILTER_BY_FORMULA", "requestId": "…" } }
type is Airtable's error type where one fits (AUTHENTICATION_REQUIRED, INVALID_PERMISSIONS,
NOT_FOUND, INVALID_REQUEST_UNKNOWN, INVALID_MULTIPLE_CHOICE_OPTIONS, TOO_MANY_REQUESTS,
SERVER_ERROR), code OpenRows' stable code. Statuses are OpenRows': 401, 403 (role, scope,
or a table or field edit permission), 404 (missing, or outside your access), 409, 413, 422, 429.
Limits
Pages of at most 100 records, batches of at most 10.
Rate limits are token buckets kept in Postgres, so every server instance shares them: per
base, 5 requests a second with a burst of 10 (every caller of the base together), and per
personal access token (on every route) or signed-in user (on /api/v1), 50 a second with a burst
of 100. Self-hosters change them with API_RATE_LIMIT_BASE, API_RATE_LIMIT_BASE_BURST,
API_RATE_LIMIT_TOKEN and API_RATE_LIMIT_TOKEN_BURST (RATE_LIMIT_STORE=memory keeps them per
process). Answers carry RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and
RateLimit-Policy for the bucket closest to empty; a refusal is 429 TOO_MANY_REQUESTS with
Retry-After and the bucket in error.limit (base, token or user). A base the caller can't
reach takes nothing from its bucket. Each token's requests per day are shown in Settings.
The rendered reference, with these guides and a "From Airtable" page, is at /docs/api.
Webhooks
Push notifications of record, field, table and form events, signed with HMAC-SHA256, managed
under /api/v1/bases/{baseId}/webhooks with the webhook:manage scope: see
webhooks.md.
Automations
The automations a base's creators built in the app, under
/api/v1/bases/{baseId}/automations. Every route needs the base's creator role and its scope;
building and changing automations stays in the app.
Method and path (under /api/v1/bases/:baseId) |
Scope | What |
|---|---|---|
GET /automations |
automations:read |
The base's automations: trigger, actions, enabled, last run, next scheduled run (never the webhook URL or its last request) |
GET /automations/:automationId |
automations:read |
One automation |
GET /automations/:automationId/runs?limit=&status= |
automations:read |
Its runs of the last 30 days, newest first, with each step's (redacted) input and output |
PATCH /automations/:automationId { "enabled": true } |
automations:manage |
Turns it on or off. Turning it on makes it run as you, as in the app; one without actions can't be turned on (422) |
POST /automations/:automationId/trigger |
automations:run |
Queues a run: { "body": … } for a webhook-received automation (any JSON, at most 100 KB, as if POSTed to its URL; {} when left out), { "recordId": "…" } for a button-clicked one (a click on that record's button by you: you need to be able to edit the record, the table's edit permission included). Answers { "queued": true, "triggerId": "…" }; the run shows in /runs once the worker ran it. 409 for an automation that is off or has another trigger |
Runs run as the automation's creator, in the worker, under the workspace's monthly runs and outbound limits, exactly as when its own trigger fires.