Documentation

Get started with
Datatomically

Everything you need to build, organize, and automate your business data.

Introduction

Datatomically is a no-code database platform that combines the simplicity of a spreadsheet with the power of a relational database. You organize data into bases, which contain tables, which contain records.

Each record has one or more fields — typed columns that define the structure of your data. You can view the same table through multiple views like Grid, Kanban, Calendar, and more.

New to Datatomically?

Start with a template. Head to the Templates page and pick one that fits your use case. You can always customize it after.

Quickstart

Get your first base running in under 5 minutes.

  • Create a workspace

    After signing up, you'll land in your workspace. A workspace holds all your bases. You can rename it from the workspace settings panel.

  • Create or import a base

    Click "+ New Base" to start from scratch, choose a template, import a CSV from Excel or Google Sheets, or import your Airtable bases directly.

  • Add fields

    Click the "+ Field" button in the column header to add a new field. Choose from 20+ field types including text, number, select, date, and linked records.

  • Add records

    Click "+ Add record" to add rows. Click any cell to edit inline. Press Tab to move right, Enter to move down.

  • Invite your team

    Open workspace settings and click "Invite members." Set their role: Editor, Commenter, or Viewer.

Moving from Airtable

Bring every base across in one pass. The importer reads Airtable's API directly and changes nothing in Airtable, so you can run both side by side until you're ready to switch.

  • Create an Airtable personal access token

    Go to airtable.com/create/tokens, add the scopes data.records:read and schema.bases:read, and under Access pick the bases, or whole workspaces, to move. To move every base, choose All current and future bases in all current and future workspaces.

  • Import

    In the app, open Import from Airtable, paste the token and click List my bases. Everything is selected; bases you already imported are left unticked. Click Import. Bases come over one at a time with a live progress bar. You can close the dialog to stop, and bases already imported are kept. Have several Airtable accounts? Repeat with each account's token.

  • Review the notes

    Each base gets a short report. The importer checks every formula, lookup, rollup and count against the values Airtable returned for up to 200 records per table. Anything that doesn't match is listed, along with anything that couldn't come across exactly.

What comes across

  • Tables, field names and descriptions, the primary field, every record, and its created time.
  • All field types: text, long text, rich text (as Markdown), number, currency, percent, duration, single and multiple select (with colors), date and date-time (with format), checkbox, rating, URL, email, phone, attachments, barcode (as text), linked records (two-way, single or multiple), lookup, rollup, count, formula, autonumber, created time and last modified time.
  • Formulas keep working: Airtable stores them with field ids, and the importer rewrites them to field names. Every Airtable formula function is supported, and number formats (currency, precision, percent) are kept.
  • Rollups keep their aggregation. Airtable's API doesn't expose it, so it's worked out from Airtable's own values (SUM, AVERAGE, MIN, MAX, COUNT, COUNTALL, ARRAYJOIN, ARRAYUNIQUE, AND, OR).
  • Autonumbers keep their values, gaps included, and new records continue from the highest number.
  • Views: grid, kanban, calendar, gallery and form views, with their hidden fields and field order. Kanban views group by their select field and calendars use their date field.
  • Attachments are copied into Datatomically's storage, because Airtable's file links expire after a few hours.
  • Ids: each base and record remembers its Airtable id, so the API accepts app… and rec… ids (see below).

What doesn't, yet

  • Button fields are skipped. Collaborator, created-by and last-modified-by fields become text with each person's name.
  • View filters and sorts aren't available from Airtable's API, so imported views start unfiltered. Timeline, Gantt and other view types arrive as grids.
  • Record comments, revision history, Airtable automations, interfaces and extensions aren't copied. Rebuild automations with Datatomically's automations.
  • Plan limits apply: Free imports up to 3 bases of 1,000 records each. Pro has unlimited bases of up to 50,000 records each, the same as Airtable Team.

Switch your integrations

The Datatomically API speaks Airtable's protocol at https://datatomically.com/v0. Point an integration at it by changing two things: the host (api.airtable.com → datatomically.com) and the token (your Airtable token → a Datatomically dt_ token). Imported bases answer to their old app… id and records to their old rec… ids, so stored ids keep working.

// airtable.js const Airtable = require('airtable'); const base = new Airtable({ apiKey: 'dt_YOUR_API_TOKEN', endpointUrl: 'https://datatomically.com' }) .base('appYourOldBaseId'); const rows = await base('Tasks').select({ view: 'Grid view', filterByFormula: "{Status} = 'Todo'" }).all(); # pyairtable from pyairtable import Api api = Api('dt_YOUR_API_TOKEN', endpoint_url='https://datatomically.com') table = api.table('appYourOldBaseId', 'Tasks') table.batch_upsert([{'fields': {'Email': 'ada@example.com', 'Plan': 'Pro'}}], key_fields=['Email']) # curl curl "https://datatomically.com/v0/appYourOldBaseId/Tasks?maxRecords=3" -H "Authorization: Bearer dt_YOUR_API_TOKEN"

Zapier and Make: their built-in Airtable apps always call Airtable, so use their HTTP / Webhooks steps with the URLs above. To get notified when data changes, use an automation with the Send a webhook action.

REST API

Every base you can open in Datatomically is also available over a JSON REST API, so you can read, create, update and delete records from scripts, integrations and other systems.

Base URL: https://datatomically.com/api/v1 (also https://datatomically.com/v0, Airtable's path)
Airtable compatible

The API uses the same request and response shapes as Airtable's records API (records, fields, offset, filterByFormula, typecast…), so existing Airtable integrations and client libraries only need the host and the token changed. See Switch your integrations.

Authentication

Create a personal API token in the app under Account → API tokens. Tokens start with dt_ and are shown once — copy it straight away; only a hash is stored. Each token has read and/or write scopes. Pass it as a Bearer token on every request:

Authorization: Bearer dt_YOUR_API_TOKEN

A token acts as you: it can read every base in workspaces you belong to, and write to bases where you are an owner or editor. Requests without a valid token get 401 AUTHENTICATION_REQUIRED; requests you are not allowed to make get 403 NOT_AUTHORIZED.

Finding base, table and field ids

Tables and fields can be addressed by id or by name (table names are matched case-insensitively). Two discovery endpoints return everything you need:

GET /api/v1/bases → { "bases": [{ "id", "name", "permissionLevel" }] } GET /api/v1/bases/{baseId}/tables → { "tables": [{ "id", "name", "primaryFieldId", "fields": [{ "id", "name", "type", "options" }], "views": [{ "id", "name", "type" }] }] }

List records

GET https://datatomically.com/api/v1/{baseId}/{tableIdOrName} Response: { "records": [ { "id": "3f1c2a7e-5b7d-4c1e-9a0f-2d6b8e4c1a55", "createdTime": "2026-03-15T10:22:00.000Z", "fields": { "Name": "Project Alpha", "Status": "In Progress", "Points": 5, "Tags": ["design", "q2"] } } ], "offset": "100" }

Field values are keyed by field name. Empty values (blank text, unchecked checkboxes, empty lists) are omitted, as in Airtable. Computed fields (formula, lookup, rollup, count, autonumber, created and last-modified time) are included, evaluated by the same engine as the app. You can filter and sort on them too. A formula that fails returns { "error": "#ERROR!" }, as in Airtable.

Record ids are uuids. Records imported from Airtable also answer to their original rec… id, and imported bases to their app… id, anywhere an id is accepted (paths, records[], linked-record values).

Query parameters:

  • pageSize — records per page, default and maximum 100.
  • offset — cursor from the previous response. Keep requesting with the returned offset until the response has none.
  • maxRecords — stop after this many records in total.
  • view — a view id or name; the view's filters and sorts are applied.
  • filterByFormula — a formula; records where it is truthy are returned (see below).
  • sort[0][field], sort[0][direction] — sort by field name or id, asc (default) or desc. Add sort[1]… for secondary sorts. Explicit sorts override the view's sorts.
  • fields[]=Name&fields[]=Status (or fields=Name,Status) — return only these fields.
  • returnFieldsByFieldId=true — key fields by field id instead of name.
  • cellFormat=string — every value as display text, as shown in the app (linked records by their primary field, numbers with their format). timeZone and userLocale are accepted for compatibility.

Filter formulas too long for a URL? POST /api/v1/{baseId}/{table}/listRecords takes the same parameters as a JSON body, e.g. { "filterByFormula": "…", "sort": [{ "field": "Name", "direction": "desc" }], "fields": ["Name"] }. It needs only the read scope.

# curl curl "https://datatomically.com/api/v1/{baseId}/Tasks?pageSize=50&view=Grid&sort%5B0%5D%5Bfield%5D=Due%20Date" \ -H "Authorization: Bearer dt_YOUR_API_TOKEN" // JavaScript (fetch) — walk every page const base = 'https://datatomically.com/api/v1/{baseId}/Tasks'; const headers = { Authorization: 'Bearer dt_YOUR_API_TOKEN' }; let offset, all = []; do { const url = base + (offset ? '?offset=' + offset : ''); const page = await fetch(url, { headers }).then(r => r.json()); all.push(...page.records); offset = page.offset; } while (offset);

A single record is available at GET /api/v1/{baseId}/{table}/{recordId} (404 NOT_FOUND if it does not exist).

Filtering with filterByFormula

Use the same formula language as formula fields. Reference fields with {Field Name}; a record is returned when the formula evaluates to something truthy. Remember to URL-encode the parameter.

# Open tasks worth more than 3 points curl -G "https://datatomically.com/api/v1/{baseId}/Tasks" \ -H "Authorization: Bearer dt_YOUR_API_TOKEN" \ --data-urlencode "filterByFormula=AND({Status} = 'Todo', {Points} > 3)" # Records whose name contains "alpha", ignoring case --data-urlencode "filterByFormula=FIND('alpha', LOWER({Name}))" # Records with no due date --data-urlencode "filterByFormula={Due Date} = BLANK()" // JavaScript const q = new URLSearchParams({ filterByFormula: "IS_AFTER({Due Date}, TODAY())", pageSize: 20 }); const { records } = await fetch(`${base}?${q}`, { headers }).then(r => r.json());

An invalid formula or an unknown field name returns 422 INVALID_FILTER_BY_FORMULA.

Create records

Send { "records": [{ "fields": … }] } (up to 10 records per request) or a single { "fields": … }. Fields can be given by name or id; anything you leave out gets its empty default. Values are coerced to the field type: numbers, booleans for checkboxes, strings for text and dates (ISO 8601), arrays for multiple select, linked records (record ids) and attachments ({ "url": … }).

POST https://datatomically.com/api/v1/{baseId}/Tasks Content-Type: application/json { "records": [ { "fields": { "Name": "New Project", "Status": "Backlog", "Due Date": "2026-05-01" } }, { "fields": { "Name": "Another one", "Points": 3 } } ], "typecast": true } Response: { "records": [ { "id": "…", "createdTime": "…", "fields": { … } }, … ] } (a single { "fields" } body gets a single record back) # curl curl -X POST "https://datatomically.com/api/v1/{baseId}/Tasks" \ -H "Authorization: Bearer dt_YOUR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"fields":{"Name":"New Project","Status":"Backlog"}}' // JavaScript await fetch(base, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ records: [{ fields: { Name: 'New Project', Status: 'Backlog' } }] }), });

Unknown select options are rejected with 422 INVALID_MULTIPLE_CHOICE_OPTIONS unless you pass "typecast": true, which creates the option in the field.

Linked records stay two-way: linking, unlinking or deleting a record through the API also updates the matching link field in the other table, just like in the app.

Update records

PATCH merges the fields you send into the record; PUT replaces every editable field (fields you omit are cleared). Both accept up to 10 records.

PATCH https://datatomically.com/api/v1/{baseId}/Tasks { "records": [ { "id": "3f1c2a7e-…", "fields": { "Status": "Done" } } ] } PATCH https://datatomically.com/api/v1/{baseId}/Tasks/{recordId} { "fields": { "Status": "Done" } } PUT …/Tasks or …/Tasks/{recordId} — same bodies, replaces all fields # curl curl -X PATCH "https://datatomically.com/api/v1/{baseId}/Tasks/{recordId}" \ -H "Authorization: Bearer dt_YOUR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"fields":{"Status":"Done"}}'

Delete records

DELETE https://datatomically.com/api/v1/{baseId}/Tasks/{recordId} DELETE https://datatomically.com/api/v1/{baseId}/Tasks?records[]={id1}&records[]={id2} (up to 10; records=id1,id2 also works) Response: { "records": [ { "id": "…", "deleted": true } ] } (single record: { "id", "deleted": true })

Upserts

Add performUpsert to a PATCH or PUT on the table. Each record is matched on the fields you name: matches are updated and the rest are created. Matching ignores case and surrounding spaces.

PATCH https://datatomically.com/api/v1/{baseId}/Contacts { "performUpsert": { "fieldsToMergeOn": ["Email"] }, "records": [ { "fields": { "Email": "ada@example.com", "Plan": "Pro" } } ] } Response: { "records": [ … ], "updatedRecords": [ "…" ], "createdRecords": [ ] }

Metadata API

Airtable's metadata endpoints, with Airtable field type names (singleLineText, singleSelect, multipleRecordLinks…) and options:

GET /v0/meta/whoami GET /v0/meta/bases list bases POST /v0/meta/bases { name, workspaceId?, tables: [{ name, fields: [...] }] } GET /v0/meta/bases/{baseId}/tables schema: tables, fields, views POST /v0/meta/bases/{baseId}/tables { name, description?, fields: [{ name, type, options? }] } PATCH /v0/meta/bases/{baseId}/tables/{tableId} { name?, description? } POST /v0/meta/bases/{baseId}/tables/{tableId}/fields { name, type, description?, options? } PATCH /v0/meta/bases/{baseId}/tables/{tableId}/fields/{id} { name?, description? }

The first field of a new table is its primary field. A new multipleRecordLinks field also gets an inverse field in the linked table. Renaming a field updates the formulas that use it. Creating a base without workspaceId works when you can edit exactly one workspace. Button, collaborator, lookup and rollup fields can't be created through the API.

Limits

  • 100 records per page when listing; use offset to page through the rest.
  • 10 records per create, update or delete request.
  • Unfiltered, unsorted listing pages straight from the database. filterByFormula and view filters stop reading as soon as a page is full. Sorting reads the whole table, which is sized for the plan limit of 50,000 records per base. So do tables with lookups, rollups or autonumbers, and cellFormat=string, because their values depend on other records.
  • Rate limit: 50 requests per 10 seconds per token (about 5 per second). Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). Over the limit you get 429 with a Retry-After header.
  • Plan limits apply to the API too: Free workspaces hold up to 1,000 records per base, Pro up to 50,000.

Errors

Errors are always JSON with an Airtable-style error object:

{ "error": { "type": "UNKNOWN_FIELD_NAME", "message": "Unknown field name: \"Priorty\"" } }
  • 400 INVALID_REQUEST_BODY — body is not valid JSON.
  • 401 AUTHENTICATION_REQUIRED — missing, invalid or revoked token.
  • 403 NOT_AUTHORIZED — not a member of the workspace, not an editor, or the token lacks the scope.
  • 404 NOT_FOUND — unknown route, base, table or record.
  • 405 METHOD_NOT_ALLOWED — wrong HTTP method for the route.
  • 422 — validation: DUPLICATE_TABLE_NAME, DUPLICATE_OR_EMPTY_FIELD_NAME, UNSUPPORTED_FIELD_TYPE_FOR_CREATE, UNKNOWN_FIELD_NAME, INVALID_VALUE_FOR_COLUMN, INVALID_MULTIPLE_CHOICE_OPTIONS, INVALID_RECORD_ID, INVALID_FILTER_BY_FORMULA, VIEW_NAME_NOT_FOUND, INVALID_REQUEST_BODY (e.g. more than 10 records).
  • 422 PLAN_LIMIT_EXCEEDED — the workspace's plan doesn't allow more records in this base.
  • 429 TOO_MANY_REQUESTS — rate limit exceeded; wait for Retry-After seconds.
  • 500 SERVER_ERROR — something went wrong on our side; retry with backoff.
Webhooks & Zapier

To push data out when something changes, use an automation with the Send a webhook action (see Automations below) — it POSTs JSON to any URL, including Zapier and Make catch hooks. To pull data in, point your Airtable-compatible Zapier/Make step at this API's base URL with your token.

Automations

Automations react to changes in a base without any code. Each automation belongs to one base and has a single trigger (when) and one or more actions (then).

Creating an automation

  • Open the base and click the ⚡ Automations button in the toolbar (also available from the table menu as Automations…).
  • Click + New automation, give it a name and pick the table it watches.
  • Choose the trigger under When and add actions under Then. Action values can reference the triggering record's fields with placeholders like {Name} or {Due Date}.
  • Save. Automations are enabled by default; toggle them off from the list without deleting them.

Triggers

  • A record is created — fires when a new record is added to the table (in the grid, from a form, via import or the API while the app is open).
  • A record is updated — fires when a record changes. Optionally limit it to a single field so it only fires when that field changes.
  • A record matches a condition — pick a field, an operator (the same operators as view filters: is, contains, is empty, greater than, is before…) and a value. It fires when a record starts matching — a record that already matched does not fire again on unrelated edits.

Actions

  • Show a notification — displays an in-app toast with your message, e.g. Record {Name} was created.
  • Update the triggering record — sets a field on the record that fired the trigger to a new value (placeholders allowed).
  • Create a record — adds a record to any table in the base, with as many field values as you like copied or composed from the triggering record.
  • Send a webhook — POSTs JSON to a URL of your choice (Zapier, Make, n8n, your own server). The payload is { "event", "base", "table", "record": { "id", "fields": { "Field name": "display value", … } } }.
Automations run in your browser

Automations execute inside the Datatomically app while it is open — they are evaluated on the device that made the change. Records changed purely through the REST API, or while nobody has the base open, do not trigger automations. For server-side workflows, poll the REST API or trigger your own webhook from your integration.

Run history

Click Run history in the Automations panel to see the last 50 runs for the base — when each automation fired, whether it succeeded, and the error message if an action failed.