Docs User guide

API access

Everything the web app does goes through a JSON API, and workspace owners can give that API to other programs. A program authenticates with an API key that belongs to one workspace. Use it for scheduled imports from another system, a bot that adds records, a dashboard that reads a query, or a script on your own machine.

This page covers creating keys, signing requests, and worked curl examples for the common tasks. Every route is described in the OpenAPI document (see The OpenAPI document).

Create a key

Only workspace owners can create and revoke keys.

  1. Open Workspace settings from the user menu and go to API keys.
  2. Choose Create API key. Give it a name that says what uses it, such as "Nightly stock sync".
  3. Pick the access level and an expiry (see below).
  4. Copy the key from the next dialog and store it in your secret manager.

The full key is shown exactly once. AbiData keeps only a keyed hash of it, so nobody, including administrators, can read it again. If you lose a key, revoke it and create another.

A key looks like abd_01J9Z3...Q4_... and is about 74 characters long. Treat it like a password.

Access levels

A key has a role in its workspace, and the permissions table applies to it unchanged.

Role What the key can do
Viewer Read tables, run saved queries and reports
Editor Also add, change and delete records, and run unsaved visual queries
Builder Also change tables, queries, forms, scripts and jobs, import data and run jobs

A key can never be an owner. It cannot manage members, backups, the egress allowlist or other keys, and it cannot create or revoke keys. Choose the lowest role that does the job: a bot that only adds records needs Editor, and a dashboard that only reads needs Viewer.

What a key can reach

  • A key works only on the workspace routes /api/v1/w/<workspace id>/... of its own workspace. Another workspace answers 404 NOT_FOUND, exactly as it does for someone who is not a member.
  • A key never works on /api/v1/admin/..., /api/v1/auth/..., /metrics or the log stream. It gets 403 FORBIDDEN or 401 UNAUTHENTICATED there.
  • Writes made with a key are recorded as the key: in a record's History, in audit entries and in the created_by of anything it creates, as API key "Nightly stock sync".
  • A key belongs to the workspace, not to the person who created it. If that person is removed from the workspace or leaves, the key keeps working. Owners and platform administrators can revoke it at any time (API keys in the workspace settings, or API keys in the admin console).
  • Revoking is immediate: the next request, and any open event stream within 30 seconds, is refused.
  • A workspace can hold at most 25 active keys.

Sign requests

Send the key in an Authorization header on every request. There is no login step and no cookie.

export ABIDATA=https://abidata.example.com    # your server's public address
export WS=01J9Z3K8M2N4P6Q8R0S2T4V6W8          # the workspace id (see below)
export KEY=abd_01J9Z3...                      # the API key

curl -sS -H "Authorization: Bearer $KEY" "$ABIDATA/api/v1/w/$WS"
{"id":"01J9Z3K8M2N4P6Q8R0S2T4V6W8","name":"Shop","status":"active","role":"editor"}
  • Base URL: your server's address plus /api/v1. Workspace data lives under /api/v1/w/<workspace id>/. The workspace id is in the address bar when you have the workspace open (/w/<workspace id>).
  • If an Authorization: Bearer header is present, it is the only credential used. Cookies are ignored. A wrong, expired or revoked key gets 401 UNAUTHENTICATED with the same generic message whichever part was wrong.
  • Requests that change data need no Origin header and no CSRF token. Those protect browser sessions; a key is never sent by a browser on its own. The server sends no CORS headers, so web pages on other sites cannot use a key even if they had it. That is also why you must never put a key in browser code (see Keep keys safe).
  • Send Content-Type: application/json with request bodies. Responses are JSON, except downloads and the event stream.

The OpenAPI document

The complete, current description of every route, parameter and response is served at:

  • /api/v1/openapi.json (also /api/v1/openapi.yaml)

It needs no authentication. Load it into Postman, Insomnia, or a client generator such as openapi-generator. The examples below are a tour of the routes bots use most; use the document for the rest. Routes the document lists under /api/v1/admin, /api/v1/auth and for members, backups, settings and keys are not available to keys.

Find your tables and fields

Rows are addressed by table id and field id, not by display name, so renaming a table or field never breaks a bot. List them with the schema (viewer):

curl -sS -H "Authorization: Bearer $KEY" "$ABIDATA/api/v1/w/$WS/schema"
{
  "schema_version": 4,
  "tables": [
    {
      "id": "01J9Z4A1...",
      "display_name": "Products",
      "columns": [
        {"id": "01J9Z4A2...", "display_name": "ID", "field_type": "autonumber", "ordinal": 0},
        {"id": "01J9Z4A3...", "display_name": "Name", "field_type": "short_text", "is_required": true, "ordinal": 1},
        {"id": "01J9Z4A4...", "display_name": "Price", "field_type": "currency", "ordinal": 2}
      ]
    }
  ],
  "relationships": []
}

The response carries an ETag that is the schema version; compare it between calls to notice that a table or field changed. The examples below use these shell variables:

export T=01J9Z4A1...        # table id
export NAME=01J9Z4A3...     # id of the Name field
export PRICE=01J9Z4A4...    # id of the Price field
export H=(-H "Authorization: Bearer $KEY" -H "Content-Type: application/json")

Values by field type

Send and receive each field in this JSON form. null clears a field.

Field type JSON value
Short text, long text string
Whole number, lookup (the linked row's id) number
Number number
Currency number or string with at most 4 decimals. Responses use a string such as "19.9900" so no precision is lost
Yes/No true or false
Date "2026-03-01"
Date and time RFC 3339 such as "2026-03-01T12:30:00Z" (returned in UTC with milliseconds)
Hyperlink string starting with http:// or https://
Location (geo_point) a GeoJSON Point object such as {"type":"Point","coordinates":[-0.14189,51.50135]} (longitude first). Also accepted when sending: {"lat":51.50135,"lon":-0.14189}, the string "51.50135, -0.14189" and a GeoJSON Feature. Responses always use the Point form, rounded to 7 decimals
Area (geo_area) a GeoJSON Polygon or MultiPolygon object, normalised on write (rings closed, outer rings counter-clockwise, holes clockwise, 7 decimals). A Feature is accepted when sending. At most 10,000 positions and 1 MiB; areas crossing the 180° meridian are refused
Attachments an array of file ids such as ["01J...", "01J..."], in the order shown; [] or null clears it. See Upload and download files
Autonumber, calculated read-only; sending them is refused

A geometry that is not valid is refused with VALIDATION_FAILED and a message naming the field and the problem. A calculated field whose result type is Location or Area is returned as GeoJSON in the same way.

Read rows

List a page of rows (viewer):

curl -sS "${H[@]}" "$ABIDATA/api/v1/w/$WS/tables/$T/rows?limit=2&sort=-$PRICE"
{
  "items": [
    {"id": 7, "_version": 3, "fields": {"01J9Z4A3...": "Gadget", "01J9Z4A4...": "24.5000"}},
    {"id": 3, "_version": 1, "fields": {"01J9Z4A3...": "Widget", "01J9Z4A4...": "19.9900"}}
  ],
  "next_offset": 2
}
  • limit is 1 to 500 (default 200). Page with offset: while the response has a next_offset, request again with offset=<next_offset>. When it is missing you have the last page.
  • sort is a comma-separated list of field ids (or id), each optionally prefixed with - for descending.
  • filter is a JSON filter, URL-encoded. A condition is {"col": <field id or "id">, "op": ..., "value": ...}. The operators are eq, ne, lt, le, gt, ge, contains, starts, ends, in, between (both take a list), is_null and not_null. Combine conditions with {"and": [...]} and {"or": [...]}.
  • Location fields add two operators (and take is_null and not_null; every other operator is refused). within takes a GeoJSON Polygon or MultiPolygon as the value and matches locations inside it, boundary included and holes excluded. near takes {"lat": .., "lon": .., "metres": ..} (or [lat, lon, metres], or the text "lat,lon,metres") and matches locations at most that many metres away. Both scan the table; there is no spatial index.
curl -sS -G "${H[@]}" "$ABIDATA/api/v1/w/$WS/tables/$T/rows" \
  --data-urlencode "filter={\"and\":[{\"col\":\"$NAME\",\"op\":\"starts\",\"value\":\"W\"},{\"col\":\"$PRICE\",\"op\":\"lt\",\"value\":50}]}" \
  --data-urlencode "limit=100"
curl -sS -G "${H[@]}" "$ABIDATA/api/v1/w/$WS/tables/$T/rows" \
  --data-urlencode "filter={\"col\":\"$SPOT\",\"op\":\"near\",\"value\":{\"lat\":51.5,\"lon\":-0.14,\"metres\":500}}"

Count matching rows (capped at 100,000, with "capped": true when it was reached):

curl -sS -G "${H[@]}" "$ABIDATA/api/v1/w/$WS/tables/$T/rows/count" \
  --data-urlencode "filter={\"col\":\"$NAME\",\"op\":\"contains\",\"value\":\"get\"}"
{"count":2,"capped":false}

Get one row. The ETag is the row's version, and you need it to update or delete the row:

curl -sS -i "${H[@]}" "$ABIDATA/api/v1/w/$WS/tables/$T/rows/3"
HTTP/1.1 200 OK
Etag: "1"
Content-Type: application/json

{"id":3,"_version":1,"fields":{"01J9Z4A3...":"Widget","01J9Z4A4...":"19.9900"}}

Insert a row

Editor role or higher. Send the fields keyed by field id. Always add an Idempotency-Key, a unique string you choose for this attempt (a UUID works). If a network error leaves you unsure whether the request arrived, repeat it with the same key and body: the server returns the first result and does not insert a second row. The same key with a different body is refused with IDEMPOTENCY_CONFLICT.

curl -sS -i "${H[@]}" -X POST "$ABIDATA/api/v1/w/$WS/tables/$T/rows" \
  -H "Idempotency-Key: 5d7c1b2e-0000-4000-8000-feedc0ffee01" \
  -d "{\"fields\": {\"$NAME\": \"Sprocket\", \"$PRICE\": 4.25}}"
HTTP/1.1 201 Created
Etag: "1"

{"id":8,"_version":1,"fields":{"01J9Z4A3...":"Sprocket","01J9Z4A4...":"4.2500"}}

A missing required field gives 422 REQUIRED_FIELD, a value of the wrong shape 400 BAD_REQUEST, and a unique-index clash 409 DUPLICATE_VALUE. The message names the field.

Update a row with optimistic locking

Two programs, or a program and a person, may edit the same row. To stop one from silently overwriting the other, an update must say which version it started from, in an If-Match header (the ETag or _version you read). Only the fields you send change.

curl -sS -i "${H[@]}" -X PATCH "$ABIDATA/api/v1/w/$WS/tables/$T/rows/8" \
  -H 'If-Match: "1"' \
  -d "{\"fields\": {\"$PRICE\": 4.75}}"
HTTP/1.1 200 OK
Etag: "2"

{"id":8,"_version":2,"fields":{"01J9Z4A3...":"Sprocket","01J9Z4A4...":"4.7500"}}

If somebody changed the row since you read it, nothing is written and the response is 409:

{"error":{"code":"CONCURRENT_MODIFICATION","title":"Changed by someone else","message":"...","recovery":"...","request_id":"...","details":{"current":{"id":8,"_version":3,"fields":{"...":"..."}}}}}

details.current is the row as it is now. Re-apply your change on top of it and retry with If-Match: "3". Leaving out If-Match is a 400 BAD_REQUEST. The same header is required to delete.

Delete a row

curl -sS -i "${H[@]}" -X DELETE "$ABIDATA/api/v1/w/$WS/tables/$T/rows/8" -H 'If-Match: "2"'
HTTP/1.1 204 No Content

Deleting a row that other rows link to fails with 409 LINKED_RECORDS. A stale If-Match is 409 CONCURRENT_MODIFICATION, as for updates.

Change many rows at once

A batch applies up to 1,000 inserts, updates and deletes in one transaction: either all succeed or none do. Updates and deletes carry the version you read.

curl -sS "${H[@]}" -X POST "$ABIDATA/api/v1/w/$WS/tables/$T/rows/batch" \
  -H "Idempotency-Key: 5d7c1b2e-0000-4000-8000-feedc0ffee02" \
  -d "{\"ops\": [
        {\"op\": \"insert\", \"fields\": {\"$NAME\": \"Bolt\", \"$PRICE\": 0.10}},
        {\"op\": \"update\", \"id\": 3, \"version\": 1, \"fields\": {\"$PRICE\": 21}},
        {\"op\": \"delete\", \"id\": 7, \"version\": 3}
      ]}"
{"results":[
  {"op":"insert","id":9,"row":{"id":9,"_version":1,"fields":{"...":"Bolt"}}},
  {"op":"update","id":3,"row":{"id":3,"_version":2,"fields":{"...":"Widget"}}},
  {"op":"delete","id":7}
]}

If any operation fails, the response is the error for that operation and details.op_index tells you which one (counting from 0). Nothing was applied, so fix it and send the whole batch again.

Upload and download files

Files (photos, PDFs, spreadsheets) are stored once per workspace and attached to a row through an attachments field. Attaching is an ordinary record write: upload the file, then put its id in the field.

Upload with a raw body and the name in X-File-Name (percent-encode non-ASCII names). Editors and above can upload; a viewer key gets 403.

curl -sS -X POST -H "Authorization: Bearer $KEY" \
  -H "X-File-Name: sighting-017.jpg" --data-binary @sighting-017.jpg \
  "$ABIDATA/api/v1/w/$WS/files"
{"id":"01J8Z0...","sha256":"9f2c...","size":184223,"name":"sighting-017.jpg","content_type":"image/jpeg",
 "width":4032,"height":3024,"state":"pending","has_thumbnail":true,"created_at":"2026-10-11T09:14:02.113Z"}

multipart/form-data with the file in a field named file works too (curl -F file=@report.pdf). The content_type is detected from the bytes; a type you send is ignored. The default size limit is 25 MiB (ABIDATA_MAX_FILE_BYTES) and a workspace has a storage quota; both answer with an error envelope (BAD_REQUEST, QUOTA_EXCEEDED). Images over 40 megapixels are refused. Uploading the same bytes again costs no extra storage.

A new file is pending. It becomes attached when a row refers to it, and a pending file nothing refers to within 24 hours is deleted. A file that loses its last reference is kept for 7 days (undo and row history still show it), then deleted.

Attach it by writing the field ($PHOTOS is the attachments field's id; each field holds at most its max_files, 20 by default):

curl -sS -X PATCH "${H[@]}" -H 'If-Match: "3"' \
  -d '{"fields":{"'$PHOTOS'":["01J8Z0...","01J8Z1..."]}}' \
  "$ABIDATA/api/v1/w/$WS/tables/$T/rows/17"

Every id must be a file of the same workspace, or the write fails with BAD_REQUEST. Duplicates in one list are dropped and the order is kept.

Download:

curl -sS -H "Authorization: Bearer $KEY" -o photo.jpg "$ABIDATA/api/v1/w/$WS/files/01J8Z0.../content"
curl -sS -H "Authorization: Bearer $KEY" "$ABIDATA/api/v1/w/$WS/files/01J8Z0..."            # metadata
curl -sS -H "Authorization: Bearer $KEY" -o thumb.jpg "$ABIDATA/api/v1/w/$WS/files/01J8Z0.../thumbnail"
curl -sS -H "Authorization: Bearer $KEY" "$ABIDATA/api/v1/w/$WS/files?ids=01J8Z0...,01J8Z1..."   # several at once

Every key role (viewer and above) can download. /content supports Range requests and ETag; add ?download=true to force a download. Thumbnails are JPEGs of at most 320 pixels for PNG, JPEG and GIF files; other files answer 404. Uploads count against the key's rate limit, and a workspace accepts only a few uploads at the same time.

Run a saved query

Saved queries are run with the viewer role. List them to find the id, then run one. params fills the query's parameters by name and limit caps the rows (up to 50,000).

curl -sS "${H[@]}" "$ABIDATA/api/v1/w/$WS/queries"          # items[].id, items[].name, items[].parameters

curl -sS "${H[@]}" -X POST "$ABIDATA/api/v1/w/$WS/queries/$QUERY/run" \
  -d '{"params": {"prefix": "W%"}, "limit": 1000}'
{"columns":["Name","Price"],"rows":[["Widget",210000]],"truncated":false}

Query results are plain values in rows of arrays, in the order of columns. They are the stored values, not the display form that the row routes return: for example a currency column is a whole number of ten-thousandths (210000 is 21.0000), and yes/no is 0 or 1. truncated is true when the row limit cut the result short. Reports run the same way at POST /api/v1/w/$WS/reports/<id>/run; see the OpenAPI document.

Start a job

Starting a job needs the builder role. The job and its script are designed in the web app first; see Jobs.

curl -sS "${H[@]}" "$ABIDATA/api/v1/w/$WS/jobs"             # items[].id

curl -sS -i "${H[@]}" -X POST "$ABIDATA/api/v1/w/$WS/jobs/$JOB/run" -d '{"args": {"region": "north"}}'
HTTP/1.1 202 Accepted

{"id":"01J9Z5...","job_id":"01J9Z4...","trigger_kind":"manual","status":"queued","attempt":1,"triggered_by":"apikey:01J9Z3...","queued_at":"2026-03-01T12:30:00.000Z","rows_read":0,"rows_written":0}

args are merged over the job's own arguments for this run only. The run is recorded as triggered by the key. Poll GET /api/v1/w/$WS/runs/<run id> until status is succeeded, failed, cancelled or interrupted; the response includes the log. A job that is already running and set to skip overlapping runs is refused with 409.

Watch for changes (events)

GET /api/v1/w/<workspace id>/events is a Server-Sent Events stream of changes, so a bot can react instead of polling. Any role can subscribe.

curl -sS -N -H "Authorization: Bearer $KEY" "$ABIDATA/api/v1/w/$WS/events"
id: 1791691013331012
event: hello
data: {}

id: 1791691013331013
event: rows.changed
data: {"table_id":"01J9Z4A1...","op":"insert","ids":[8],"versions":[1]}
  • rows.changed carries the table, the operation and the changed row ids and versions. Fetch the rows if you need their values.
  • table.invalidated, schema.changed and design.changed mean you should reload tables, the schema, or a saved object. job.run (builders only) reports job progress. reset means you missed too many events: reload everything.
  • Every event has a numeric id that increases. After a dropped connection, reconnect with the header Last-Event-ID: <last id> and the server replays what you missed.
  • The server checks the key again about every 30 seconds. A revoked or expired key ends the stream, and the next connection attempt gets 401. Reconnect logic should stop on 401, 403 and 404 instead of retrying.
  • Heartbeat comment lines (: ping) arrive every 15 seconds.

Errors

Every error uses the same envelope, with an HTTP status that matches the code:

{"error":{"code":"VALIDATION_FAILED","title":"...","message":"...","recovery":"...","request_id":"01J9Z6...","details":{}}}

Branch on error.code, which is stable; show message and recovery to people. Quote request_id (also sent as the X-Request-ID header) when you ask an administrator to look something up. The full list is in the error reference. The ones a bot meets most:

Status Code Meaning
401 UNAUTHENTICATED The key is missing, malformed, wrong, revoked or expired
403 FORBIDDEN The key's role is too low for this route, the route is not available to keys, or the workspace is suspended
404 NOT_FOUND No such object, or the key belongs to another workspace
409 CONCURRENT_MODIFICATION The row changed since you read it: use details.current and retry
409 IDEMPOTENCY_CONFLICT The Idempotency-Key was used with a different request
422 REQUIRED_FIELD, VALIDATION_FAILED A value was refused; the message names the field
429 RATE_LIMITED Too many requests: wait for Retry-After seconds
503 WORKSPACE_MAINTENANCE, WORKSPACE_BUSY A restore is running or the workspace is busy: retry shortly, honouring Retry-After when present

Retry 429 and 503 after the Retry-After delay (when sent), with growing waits. Do not retry other 4xx errors unchanged.

Rate limits

  • Each key may send 600 requests a minute, as a steady 10 per second with bursts of up to 100. Beyond that, requests get 429 RATE_LIMITED with a Retry-After header. Other keys are not affected. Page through large tables with limit=500 and use batches for bulk writes instead of many single requests.
  • A client address that sends more than 30 requests with wrong keys in a minute gets 429 for further wrong keys for the rest of that minute. This slows down anyone guessing keys. A correct key from the same address is still served, and text that is not shaped like a key is rejected without counting. A bot with a revoked or mistyped key should stop, not loop.
  • The workspace's own limits (storage quota, busy-workspace protection) still apply to key requests.

Keep keys safe

  • Give each program its own key with a clear name, so you can revoke one without breaking the others and the history shows who changed what.
  • Use the lowest role. A viewer key cannot damage anything, even if it leaks.
  • Set an expiry and put a reminder in your calendar to rotate. To rotate: create the new key, deploy it, check that the old key's Last used stops changing, then revoke the old one.
  • Store keys in a secret manager or environment variable, never in source control, chat messages, tickets or logs.
  • Never use a key in browser code or a mobile app. Anyone who opens the page can read it. Call the API from a server you control, and let browsers talk to that server.
  • Revoke at once if a key may have leaked. Check Last used and the record History to see what it did.
  • Use HTTPS only. Keys sent over plain http can be read by anyone on the network.
  • Keys are not tied to an address or network. If you need that, restrict access at your reverse proxy.

For how keys are protected on the server, see Security.

Edit this page on GitHub