Everything you need to build, organize, and automate your business data.
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.
Start with a template. Head to the Templates page and pick one that fits your use case. You can always customize it after.
Get your first base running in under 5 minutes.
After signing up, you'll land in your workspace. A workspace holds all your bases. You can rename it from the workspace settings panel.
Click "+ New Base" to start from scratch, choose a template, import a CSV from Excel or Google Sheets, or import your Airtable bases directly.
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.
Click "+ Add record" to add rows. Click any cell to edit inline. Press Tab to move right, Enter to move down.
Open workspace settings and click "Invite members." Set their role: Editor, Commenter, or Viewer.
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.
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.
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.
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.
app… and rec… ids (see below).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.
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)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.
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_TOKENA 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.
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" }] }] }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).
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.
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.
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 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 })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": [ ] }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.
offset to page through the rest.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.X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). Over the limit you get 429 with a Retry-After header.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.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 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).
{Name} or {Due Date}.Record {Name} was created.{ "event", "base", "table", "record": { "id", "fields": { "Field name": "display value", … } } }.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.
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.