Leaving Airtable? Bring your bases to Artavolo, checked record by record. See how

Data API (v2)

One REST resource per table, for programs and for SvGrid's createRestDataSource, which reads and writes it with no adapter.

Signing in

The same credentials as the rest of the API:

  • a signed-in browser session
  • an API token (Profile → API tokens), sent either way:
    • Authorization: Basic base64(email:token)
    • API-KEY: <token>

A token works only in the workspace it was created for, and only on the bases switched on for it. AI connector tokens are refused here; they belong to /mcp.

Routes

{code} is the base code from the base's address. {table} is the table id, which is the name in the schema.

RequestAnswer
GET /api/v2/bases/{code}/schema200 with the base and its tables
GET /api/v2/bases/{code}/tables/{table}/rows200 with { "rows": [...], "rowCount": 120, "offset": 0, "limit": 100 }
GET .../rows/{id}200 with one record
POST .../rows with a record201 with the record as stored
POST .../rows with { "records": [...] } (up to 100)201 with { "records": [...] }
PATCH .../rows/{id} with the changed fields200 with the record as stored
DELETE .../rows/{id}204; the record goes to the table's trash for 30 days
GET .../tables/{table}/trash200 with { "records": [{ "trashId", "deletedAt", "deletedBy", "deletedVia", "record" }] }
POST .../tables/{table}/trash/{trashId}/restore200 with the record, back under its own id; needs the right to add records
GET .../tables/{table}/trash?limit=500 or ?rows=12,13The trash list as above, up to limit entries (at most 1,000), or only those records; with "total", how many records are in the trash
POST .../tables/{table}/trash/restore with { "ids": [12, 13] }200 with { "restored": ["12"] }: records put back by their ids, each from its latest trash entry, under its own id. One that cannot come back is left out. Needs the right to add records.
POST .../tables/{table}/trash/purge with { "ids": [12] }200 with { "purged": ["12"] }: delete forever. The records' trash entries go, so they cannot be restored. Needs the right to delete records.
GET .../rows/{id}/history200 with { "changes": [{ "at", "by", "via", "columnId", "value", "from" }] }: the record's last 200 changes from the audit log, oldest first. at is in milliseconds, by a member id, via the app a change came through ("Claude"). An added record is one entry with "added": true. Values are in the same form as a read.
GET /api/v2/bases/{code}/assistant200 with the connector's address, whether this person may change records from AI chat, and example prompts written from each table's fields
GET /api/v2/bases/{code}/schema?format=base200 with the tables in SvGrid Base's shape (BaseTable: fields, views), each with the permissions the screen should offer, plus people (id, name, picture) and me
POST /api/v2/bases/{code}/editor with { "editor": "base" }200. Every base opens in the table screen; "classic" is refused (422, "code": "retired"). Owners only.
POST .../tables/{table}/fields with { "field": "priority", "fieldType": "text" }201; the column for a new field (letters, digits and _). A formula, lookup, rollup or count needs none: "column": false. A name the table already has answers 409 with "code": "column-exists". With "default", every record already in the table gets that value (a new field holds nothing yet), and "filled" says how many. An autonumber, created time or created by field numbers or stamps the records there are. Owners only.
GET /api/v2/forms/{table code}200 with the table's published form: its form view's fields as SvGrid Base columns, which are required, its conditions, prefill fields and footer, and how it looks. Only title and needsPassword until a visitor gives the password. 404 for a table that shares no form.
POST /api/v2/forms/{table code} with { "values": {...}, "recaptcha": "..." }201 with { "id" }. Checked again: only the form's fields, its required ones (a field with a condition is never required here), the password and reCAPTCHA. Fields the form does not ask for get their default, number and time.
POST /api/v2/forms/{table code}/password with { "password" }, POST .../files (multipart file)200 once the password is right (kept in the visitor's session); 201 with a file for a form that asks for one
GET /api/v2/workspaces/{code}/people or /api/v2/bases/{code}/people200 with who is in (role owner, contributor or readonly, and whether their access is on), for a base also the workspace's people apart, the open invitations, who asks to join, and the plan's limit. Owners only.
POST .../people/invites with { "email", "role", "message" } or { "link": true, "role" }, DELETE .../people/invites/{id}201 or 200 with the list; an e-mail invitation is sent. An address invited already or in already answers 409.
PATCH .../people/{id} with { "role" } and/or { "active" }, DELETE .../people/{id}200 with the list, or { "owner": false } when the change ended your own ownership. A workspace's last active owner cannot step down, be switched off or be removed (409, "code": "last-owner").
POST /api/v2/bases/{code}/people/requests with { "message" }; POST .../people/requests/{id}/accept with { "role" }201 once asked (anyone signed in who is not in, once); an owner lets them in with a role.
GET /api/v2/bases/{code}/permissions, PUT with { "records": {...}, "tables": [...] } or { "reset": true }200 with who may add, change and delete records (contributors, owners, specific_users with people, or nobody), who sees each table (everyone, readonly, contributors, owners), each table's row rule, and the base's people with their roles. Every value is checked: who may add is never wider than who may change, and the people named belong to the base. Owners only.
GET .../tables/{table}/form, PATCH with any of published, forEveryone, recaptcha, password, clearPassword, view, welcome, thanks, background200 with the table's form settings: who may fill the form in, its password (only whether there is one), which form view is shared, the welcome and thank-you screens, logo, cover, background and the links to share. What the form asks is its form view. Owners only.
POST .../tables/{table}/form/images/{logo, cover or background} (multipart file, header X-Artavolo-Upload: 1), DELETE the same200 with the settings; a JPEG, PNG, GIF or WebP picture up to 5 MB, else 415 or 413. Owners only.
GET /api/v2/bases/{code}/trash-items200 with { "items": [...] }: the fields, views and tables deleted in the table screen in the last 30 days, as SvGrid Base trash items. Any member reads it.
POST .../trash-items with { "items": [...] }, POST .../trash-items/remove with { "ids": [...] }keep items, or take them out (restored or deleted forever). Owners only; for anyone else a remove does nothing. Records are not kept here: each table's own trash holds them.
PUT /api/v2/bases/{code}/design with { "tables": [{ "id", "name", "primaryField", "columns", "views", "removed": ["field"] }] }200 with { "saved": [ids], "skipped": [ids] }. The new table screen's fields (SvGrid Base columns) and views, and the table's name. Record fields are written to the classic screen's settings too; a field leaves them only when it is named in removed, and its column and values stay. Formulas, lookups, rollups, counts and the views are kept for the new screen only. Tables are not added or deleted here. Owners only.

Writes must be sent with Content-Type: application/json.

Records

A record is flat. It has:

  • id
  • the table's fields, by their key (for example dataFieldDefault1)
  • four fields the server sets: created_at, updated_at, created_by and updated_by (member ids)
{ "id": 12, "dataFieldDefault1": "Acme", "dataFieldAmount": 12000, "dataFieldStage": "proposal",
  "dataFieldTags": ["eu", "q4"], "dataFieldWon": false, "dataFieldClose": "2026-11-20",
  "created_at": "2026-11-04T10:12:00", "updated_at": null, "created_by": 7, "updated_by": null }
Field type in the schemaValue
texta string; line breaks as \n
numbera number, or null
booleantrue or false
dateYYYY-MM-DD
datetimeYYYY-MM-DDTHH:MM:SS, server time
enumthe option's value; a write may give the label instead
json with multiple: truea list of option values
jsonfiles and checklists, as JSON
json with kind: "link"the linked records' ids, [12, 40], or []; table names the linked table, and multiple says whether it holds several records or one. A write takes a list (or one id); a filter eq:12 means "includes 12"

A link is stored by record id, so deleting a record in the linked table never moves anyone's links to another record; links to the deleted record are cleared.

GET .../schema?format=base answers with the same tables in SvGrid Base's own shape: { id, name, primaryField, columns: [{ id, field, header, fieldType, fieldProps }], views }, ready for <SvBase tables>, plus notes. The notes name everything that does not carry over exactly, such as rich text stored as HTML or a view type SvGrid Base does not have. php utilities/base-schema.php --all prints the same notes for every base on an instance.

kind names the field as Artavolo stores it, for example richText (read and written as Markdown, with format: "markdown": bold, *italic*, code, links and "- " lists; the cell keeps the HTML the old screen draws, and HTML sent by an older client is kept), collaborator (a member id), formula or ai. Fields marked readonly cannot be written: formulas and AI fields (still computed in the browser), files (use the upload flow) and created-time fields.

Each table in the schema also carries access: canCreate, canEdit and canDelete for the caller.

Reading

ParameterMeaning
offset, limitpaging; limit is 100 by default and at most 10,000
sort=-dataFieldAmount,dataFieldDefault1fields, comma separated; - sorts descending
search=acmeany text field contains it
<field>=<op>:<value>eq, contains, startsWith, gt, lt, between:a,b, in:a,b,c, isNull
fields=dataFieldDefault1,dataFieldAmountreturn only these fields (and id)
filter=<FieldFilter JSON>a saved view's filter as SvGrid Base sends it ({"kind":"fieldFilter","conjunction":"and","conditions":[...]}, one level of groups). All of it is applied in SQL and the answer says "appliedExpression": true, or none of it is and the answer leaves that out: a condition on a formula, lookup, rollup or count, a link matched by name, rich text, files, a checklist or a time of day cannot be SQL, and the screen then works it out itself
tz=Europe/Berlinthe viewer's time zone, for "today" and the other relative days of a date condition (default: the server's)
groupBy=dataFieldStageone row per key instead of records: { "dataFieldStage": "won", "childCount": 4000, "leafCount": 4000 }, ordered by key (or as sort orders that field); rowCount counts the groups. The records under a key: dataFieldStage=eq:won (an empty key: isNull). Not by a link, a multiple select, rich text or JSON yet (400)
aggregate=sum:dataFieldAmount,count:dataFieldDuetotals per group, under each field's name: sum, avg, min, max (number fields) and count (filled cells)
grandTotal=1with aggregate: the totals over every record the read matches, in "grandTotal"

Rows come in record id order unless a sort is given. On an instance that encrypts cell values, filter, search and sort are refused with 400; paging still works. With format=base the schema gives each table's recordCount; the table screen reads one with more than 10,000 records a part at a time.

Airtable's API too

Scripts and tools written for Airtable's REST API can use the Airtable-compatible API at /v0/...: the same paths, ids and shapes, on top of this API. A table also exports in Airtable's CSV and JSON formats (.../tables/{id}/export?format=airtable-csv|airtable-json).

Computed fields

Formulas, lookups, rollups and counts made in the table screen are worked out on the server too, in PHP (libs/BaseCompute.class.php and libs/Formula.class.php, a port of SvGrid Base's engine that tests/formula_test.php checks value by value against it), so a read gives the values people see. Nothing has to run beside PHP. They come with each record under their field key and are listed in the schema as read-only ("computed": true, kind says which).

  • Linked records come into a lookup or rollup only as the caller may see them (row rules).
  • They cannot be written, and records cannot be filtered or sorted by them yet: that answers 400 with a sentence, never a result that ignores the condition.
  • computed=0 leaves them out (the table screen asks this way; it works them out itself).
  • "Today" and bare dates are in the server's time zone, or the viewer's with tz=.
  • Should working them out fail, the records still come, without them, and the answer says "computed": false.

Refusals

Every refusal is a status and one sentence: { "error": "..." }. A 422 for a batch also lists each problem under problems.

StatusWhen
401not signed in
403signed in, but the base's rules do not allow this, or the token's scope does not reach this base
404no such base, table or record, or one the caller may not see
415a write that is not JSON
422a field that does not exist, a read-only field, or a value of the wrong kind; the sentence names the fields or options that would work

What a write also does

These are the same things that happen when someone edits in the browser:

  • It follows the base's rules for who may create, edit and delete, and the table's visibility.
  • It is recorded in the audit log, with the before and after values.
  • It starts the table's automations.
  • It sends the base's webhooks.
  • It updates the table for anyone who has it open, when live updates are switched on.
A question these pages do not answer? Write to us and we answer by e-mail, usually within one business day. Contact us