Skip to content

Latest commit

 

History

History
1373 lines (1120 loc) · 55.4 KB

File metadata and controls

1373 lines (1120 loc) · 55.4 KB

Airtable — Extended Reference

Appendix for the unified Airtable skill. Full payload examples, element schemas, curl templates.


Internal API — Curl Templates

Setup

# Capture from DevTools → Network → any v0.3 request → Copy Cookie header
COOKIE="__Host-airtable-session=...; ..."
BASE_ID="appXXXXXXXXXXXXXX"

Common headers (all internal endpoints)

HEADERS=(
  -H "x-airtable-application-id: $BASE_ID"
  -H "x-airtable-inter-service-client: webClient"
  -H "x-airtable-page-load-id: pgloXXXXXXXXXXXXX"
  -H "x-requested-with: XMLHttpRequest"
  -H "x-time-zone: UTC"
  -H "x-user-locale: en"
  -H "cookie: $COOKIE"
)

Read base schema (includes automation IDs)

curl -s "https://airtable.com/v0.3/application/$BASE_ID/read?includeAllData=true" \
  "${HEADERS[@]}" | jq '.data.workflowSectionsById'

Read interface page layout

PAGE_ID="pagXXXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/page/$PAGE_ID/readDraft?stringifiedObjectParams=%7B%22expectedPageLayoutSchemaVersion%22%3A26%7D" \
  "${HEADERS[@]}" | jq '.data.value.elementById | keys | length'

Read automation

WORKFLOW_ID="wflXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/workflow/$WORKFLOW_ID/read" \
  "${HEADERS[@]}" | jq '.data.workflow.name'

Read automation deployment (full definition)

DEPLOYMENT_ID="wdpXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/workflowDeployment/$DEPLOYMENT_ID/read?stringifiedObjectParams=%7B%7D" \
  "${HEADERS[@]}" | jq '.data'

List all automations in a base (lightweight enumeration)

Faster than parsing application/{baseId}/read?includeAllData=true when you only need the workflow inventory. Returns id, name, deployment status, version, trigger, and graph summary for every workflow.

curl -s "https://airtable.com/v0.3/application/$BASE_ID/listWorkflows" \
  "${HEADERS[@]}" | jq '.data.workflows | map({id, name, deploymentStatus, version})'

List execution history for a workflow (run frequency / runaway detection)

Returns the most recent runs of a single workflow with createdTime + status. Paginated via offset. Use this to find runaway automations or compute per-workflow run rate.

WORKFLOW_ID="wflXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/workflow/$WORKFLOW_ID/listExecutions" \
  "${HEADERS[@]}" | jq '.data.workflowExecutions | map({createdTime, status})'

# Pagination:
OFFSET="<value from .data.offset>"
curl -s "https://airtable.com/v0.3/workflow/$WORKFLOW_ID/listExecutions?offset=$OFFSET" \
  "${HEADERS[@]}"

Page size is fixed at 20. To compute current run rate without paginating thousands of records, fetch the latest 20 and divide 19 / (newest_ts - oldest_ts).

Disable / enable an automation (off → on toggle)

State-changing POSTs. Both require secretSocketId and x-airtable-page-load-id from the user's active browser session — see gotcha below.

# Disable: POST unregister with empty params
SOCKET_ID="socXXXXXXXXXXXXXX"  # from active browser websocket
PAGE_LOAD="pglXXXXXXXXXXXXXXX" # from active browser tab
curl -s -X POST "https://airtable.com/v0.3/workflow/$WORKFLOW_ID/unregister" \
  "${HEADERS[@]}" \
  -H "content-type: application/x-www-form-urlencoded; charset=UTF-8" \
  -H "x-airtable-page-load-id: $PAGE_LOAD" \
  -H "origin: https://airtable.com" \
  --data-urlencode "stringifiedObjectParams={}" \
  --data-urlencode "requestId=req$(openssl rand -hex 7 | head -c14)" \
  --data-urlencode "secretSocketId=$SOCKET_ID"

# Enable: POST create on a NEW (client-generated) deployment ID
NEW_DEP="wfd$(openssl rand -hex 7 | head -c14)"
curl -s -X POST "https://airtable.com/v0.3/workflowDeployment/$NEW_DEP/create" \
  "${HEADERS[@]}" \
  -H "content-type: application/x-www-form-urlencoded; charset=UTF-8" \
  -H "x-airtable-page-load-id: $PAGE_LOAD" \
  -H "origin: https://airtable.com" \
  --data-urlencode "stringifiedObjectParams={\"workflowId\":\"$WORKFLOW_ID\"}" \
  --data-urlencode "requestId=req$(openssl rand -hex 7 | head -c14)" \
  --data-urlencode "secretSocketId=$SOCKET_ID"

Success returns {"msg":"SUCCESS","data":null}. After enable, the workflow's targetWorkflowDeploymentId is the new value. Verify with listWorkflows.

Use case — break a feedback loop in a runaway automation: disable → 5s pause → re-enable. Note that toggling does NOT fix the underlying loop condition — it only resets execution state. If the trigger condition still matches, the storm resumes.

Safe automation step update — preserve input.config

Internal-API step writes are full-object replaces, not patches. Every step (trigger or action) carries an input.config map that binds the step's named inputs (e.g. recordId, tableId, value, email, body) to either literal values or references to upstream data — trigger record IDs, prior-step outputs, formulas. If you POST back a step without its existing input.config, those bindings vanish — the step still executes but every reference resolves to empty. Common symptoms: "Update record" hits the wrong record (or no record), "Send email" sends with blank body, "Run script" receives an empty input.config() object inside the script.

Mandatory round-trip pattern:

# 1. Fetch full deployment (contains every step's complete config)
DEPLOYMENT_ID="wdpXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/workflowDeployment/$DEPLOYMENT_ID/read?stringifiedObjectParams=%7B%7D" \
  "${HEADERS[@]}" > /tmp/wfd.json

# 2. Inspect the target step — confirm what input.config currently holds
jq '.data.workflow.stepsById["<stepId>"].config' /tmp/wfd.json

# 3. Mutate ONLY the target leaf (jq edit-in-place, write to a new file).
#    Example: change one mapped field's value while leaving every other
#    binding in input.config untouched.
jq '.data.workflow.stepsById["<stepId>"].config.input.config["<inputName>"].value = "<new>"' \
   /tmp/wfd.json > /tmp/wfd.new.json

# 4. Diff to confirm only the intended leaf changed.
diff <(jq -S . /tmp/wfd.json) <(jq -S . /tmp/wfd.new.json)

# 5. POST back the COMPLETE step (the write endpoint expects the whole step
#    object — anything missing is treated as cleared, not preserved).

Sanity checks before writing:

  • jq '.data.workflow.stepsById["<stepId>"].config.input.config | keys' /tmp/wfd.new.json should match the same query against /tmp/wfd.json. Any key dropped from the new file is a binding you are about to destroy.
  • For "Run script" steps, input.config is exactly the object the script's input.config() call returns at runtime. A missing key means the script reads undefined.
  • For "Update record" / "Find records" steps, the binding map includes tableId and the per-field value references — losing them retargets the action to the wrong table or clears the field write set.

The same discipline applies to manually authored automation edits in the Airtable UI when the change is scripted via the internal API: read full → mutate one leaf → write full. Never assemble a step from scratch unless you are creating a brand-new step.

Internal POST gotcha — secretSocketId and x-airtable-page-load-id

State-changing POST endpoints (unregister, workflowDeployment/.../create, and similar) reject requests with HTTP 401 INVALID_AUTH_TOKEN unless the body includes a valid secretSocketId AND the headers include x-airtable-page-load-id from an active browser tab. GET endpoints (listWorkflows, listExecutions, read) do NOT require these — they only need the session cookie.

Both values rotate per browser session:

  • secretSocketId (soc...) — tied to the user's active websocket connection. Server validates it against the live ws session list.
  • x-airtable-page-load-id (pgl...) — tied to the page that loaded the current SPA bundle.

To get them (DevTools route): open the Airtable web UI in Chrome, open DevTools → Network, perform any state-change action (e.g. toggle an automation off/on once), and copy the secretSocketId from the request body and x-airtable-page-load-id from request headers.

To get them (headless via AppleScript + Chrome): inject this snippet into a live Airtable tab and read both IDs out of performance.getEntriesByType('resource'):

JSON.stringify((function(){
  var e = performance.getEntriesByType('resource').slice(-80);
  var p = new Set(), s = new Set();
  e.forEach(function(x){
    var m = x.name.match(/pgl[A-Za-z0-9]{12,20}/); if (m) p.add(m[0]);
    var n = x.name.match(/soc[A-Za-z0-9]{12,20}/); if (n) s.add(n[0]);
  });
  return { pgl: Array.from(p), soc: Array.from(s) };
})())

Full pattern (Bash + AppleScript + JS file) documented in the "Internal API — Workspace + Application Management" section → "Move from headless: AppleScript → Chrome → fetch() inside live SPA tab".

Generated values DO NOT work — the server checks the websocket registry, so random socXXX... strings always fail. The IDs must come from a live SPA session.

See "Internal API — Workspace + Application Management" → "Write path requires live secretSocketId" for an applied recipe (workspace move via AppleScript→Chrome→fetch).

readQueries (data fetch)

curl -s -X POST "https://airtable.com/v0.3/application/$BASE_ID/readQueries" \
  "${HEADERS[@]}" \
  -H "content-type: application/x-www-form-urlencoded; charset=UTF-8" \
  --data-urlencode "stringifiedObjectParams={
    \"queries\": [{
      \"id\": \"qryCustom1\",
      \"spec\": {
        \"source\": {\"type\": \"table\", \"tableId\": \"tblXXXXXXXXXXXXXXX\"},
        \"columnIds\": [\"fldSTATUS\", \"fldDATE\"],
        \"sorts\": [{\"columnId\": \"fldDATE\", \"ascending\": true}],
        \"filters\": {
          \"conjunction\": \"and\",
          \"filterSet\": [{
            \"columnId\": \"fldSTATUS\",
            \"operator\": \"isAnyOf\",
            \"value\": [\"selOPTION1\", \"selOPTION2\"]
          }]
        }
      }
    }],
    \"subscribeToRealtimeUpdates\": false,
    \"allowMsgpackOfResult\": false
  }"

Tip: Set allowMsgpackOfResult: false to get JSON instead of msgpack for debugging.


Internal API — Full Element Type Schemas

cellEditor (most common)

{
  "nodeType": "element",
  "id": "pel...",
  "type": "cellEditor",
  "source": {
    "type": "column",
    "columnId": "fld..."
  },
  "isReadOnly": false,
  "label": {"isEnabled": true, "value": "Custom Label"},
  "shouldShowDescription": true,
  "description": [{"type": "paragraph", "children": [{"text": "Help text"}]}],
  "visibilityFilters": {"conjunction": "and", "filterSet": [...]},
  "visualVariant": {"type": "grid", "size": "medium"},
  "foreignRowSelectionConstraint": {
    "filters": {"conjunction": "and", "filterSet": [{"columnId": "fld...", "operator": "|", "value": null}]}
  },
  "foreignRowEmbeddedFormButton": {
    "isEnabled": true,
    "buttonLabel": "Add new",
    "formFieldVisibility": "allNonComputed",
    "formFields": {}
  }
}

section

{
  "nodeType": "element",
  "id": "pel...",
  "type": "section",
  "visualVariant": "grid",
  "title": "Section Title",
  "shouldDisplayTitle": true,
  "shouldDisplayDescription": false,
  "labelLayoutVariant": "stacked",
  "style": null,
  "visibilityFilters": {"conjunction": "and", "filterSet": [...]}
}

queryContainer

{
  "nodeType": "element",
  "id": "pel...",
  "type": "queryContainer",
  "source": {
    "type": "foreignKey",
    "tableId": "tbl...",
    "foreignColumnId": "fld...",
    "foreignRow": {"type": "row", "tableId": "tbl...", "outputId": "peo..."}
  },
  "staticFilters": null,
  "presetFilters": null,
  "savedFilterSets": null,
  "activeFilterType": "custom",
  "allRowsLabel": null,
  "endUserControls": {
    "isFilterEnabled": false,
    "isSortEnabled": false,
    "isSearchEnabled": false,
    "isGroupLevelsEnabled": false,
    "isHideEmptyParentsEnabled": false
  },
  "outputs": {"query": {"type": "query", "id": "peo..."}},
  "viewCanvasAreas": [{"canvasAreaId": "pla..."}],
  "isPdfExportEnabled": true,
  "label": {"isEnabled": true, "value": "Actions"}
}

levels (hierarchical list)

{
  "nodeType": "element",
  "id": "pel...",
  "type": "levels",
  "leafTableId": "tbl...",
  "sourceLevel": 1,
  "rowHeight": "medium",
  "isReadOnly": true,
  "editability": {},
  "label": {"isEnabled": true, "value": "Actions"},
  "queryByLevel": {},
  "levelsConfig": {},
  "expandedRowByTableId": {}
}

button

{
  "nodeType": "element",
  "id": "pel...",
  "type": "button",
  "action": {"type": "openUrl", "url": {"type": "column", "columnId": "fld..."}},
  "colorTheme": "red",
  "buttonText": null,
  "visibilityFilters": {"conjunction": "and", "filterSet": [...]}
}

attachmentCarousel

{
  "nodeType": "element",
  "id": "pel...",
  "type": "attachmentCarousel",
  "source": {"type": "column", "columnId": "fld..."},
  "isReadOnly": false,
  "previewImageCoverFitType": "fit",
  "numAttachmentsPerCarouselPage": 1,
  "visibilityFilters": {"conjunction": "or", "filterSet": [...]}
}

slotElement (layout tree)

{
  "id": "pls...",
  "nodeType": "slotElement",
  "parentId": "pel...",
  "slotType": "sectionGridRows",
  "elementId": "pel...",
  "index": "a0"
}

slotType: sectionGridRows (rows in section) or sectionGridRowChildren (elements in row). index: alphabetical sort key (a0, a3, a4).

Layout Tree Traversal

rootCanvasAreaId
  → canvasAreaById[id].canvasId
    → fullCanvasElementById[canvasId].elementId
      → elementById[elementId] (root: recordContainer or levels)
        → slotElementsById filtered by parentId → child elements
          → recurse

REST API — Extended Examples

List with complex filter

curl "https://api.airtable.com/v0/$BASE_ID/Orders?\
fields[]=Subject&fields[]=Status&fields[]=Start%20Date&\
filterByFormula=AND(%7BStatus%7D%3D'Scheduled'%2CIS_AFTER(%7BStart%20Date%7D%2CTODAY()))&\
sort[0][field]=Start%20Date&sort[0][direction]=asc&\
pageSize=100" \
  -H "Authorization: Bearer $PAT"

Create with all field types

curl -X POST "https://api.airtable.com/v0/$BASE_ID/Orders" \
  -H "Authorization: Bearer $PAT" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [{
      "fields": {
        "Title": "Sample Order",
        "Status": "Open",
        "Start Date": "2026-04-01T10:00:00.000Z",
        "End Date": "2026-04-01T11:00:00.000Z",
        "Customer": ["recXXX"],
        "Owner": ["recYYY"],
        "Channel": "Web",
        "Priority": 4,
        "Notes": "Sample notes",
        "Price": 500,
        "Rating": 2,
        "External": true
      }
    }],
    "typecast": true
  }'

Upsert with merge

curl -X PATCH "https://api.airtable.com/v0/$BASE_ID/Contacts" \
  -H "Authorization: Bearer $PAT" \
  -H "Content-Type: application/json" \
  -d '{
    "performUpsert": {"fieldsToMergeOn": ["Email"]},
    "records": [{"fields": {"Email": "client@example.com", "Name": "Updated Name"}}],
    "typecast": true
  }'

Webhook payload structure

{
  "cursor": 43,
  "mightHaveMore": true,
  "payloads": [{
    "timestamp": "2026-03-28T10:30:00.000Z",
    "baseTransactionNumber": 42,
    "actionMetadata": {
      "source": "client",
      "sourceMetadata": {"user": {"id": "usrXXX", "email": "x@y.com"}}
    },
    "changedTablesById": {
      "tblXXX": {
        "changedRecordsById": {
          "recABC": {
            "current": {"cellValuesByFieldId": {"fldXXX": "new"}},
            "previous": {"cellValuesByFieldId": {"fldXXX": "old"}}
          }
        },
        "createdRecordsById": {},
        "destroyedRecordIds": []
      }
    }
  }]
}

Sources: client, publicApi, formSubmission, automation, system, sync, anonymousUser.


Scripting — Advanced Patterns

Fetch external API

let response = await fetch('https://api.example.com/data', {
    method: 'POST',
    headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer TOKEN'},
    body: JSON.stringify({key: 'value'})
});
let data = await response.json();

Generate unique ID

const table = base.getTable('Records');
const query = await table.selectRecordsAsync({fields: ['ID']});
const maxId = Math.max(0, ...query.records
    .map(r => parseInt(r.getCellValueAsString('ID').replace('ACME-', '')) || 0));
output.set('newId', `ACME-${String(maxId + 1).padStart(5, '0')}`);

Deduplicate records

const table = base.getTable('Contacts');
const query = await table.selectRecordsAsync({fields: ['Email', 'Name']});
const seen = new Map();
const duplicates = [];
for (const record of query.records) {
    const email = record.getCellValueAsString('Email').toLowerCase();
    if (seen.has(email)) duplicates.push({id: record.id, email});
    else seen.set(email, record.id);
}
output.text(`Found ${duplicates.length} duplicates`);
output.table(duplicates);

Cross-table sync

const source = base.getTable('Source');
const target = base.getTable('Target');
const srcQ = await source.selectRecordsAsync({fields: ['Key', 'Value']});
const tgtQ = await target.selectRecordsAsync({fields: ['Key', 'Value']});
const tgtMap = new Map(tgtQ.records.map(r => [r.getCellValueAsString('Key'), r]));

const updates = [], creates = [];
for (const sr of srcQ.records) {
    const key = sr.getCellValueAsString('Key');
    const value = sr.getCellValueAsString('Value');
    const existing = tgtMap.get(key);
    if (existing) {
        if (existing.getCellValueAsString('Value') !== value)
            updates.push({id: existing.id, fields: {'Value': value}});
    } else {
        creates.push({fields: {'Key': key, 'Value': value}});
    }
}
while (updates.length) await target.updateRecordsAsync(updates.splice(0, 50));
while (creates.length) await target.createRecordsAsync(creates.splice(0, 50));

Rate limit retry (REST API)

async function airtableRequest(url, options, maxRetries = 3) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        const response = await fetch(url, options);
        if (response.status === 429) {
            await new Promise(r => setTimeout(r, Math.max(30000, 2 ** attempt * 1000)));
            continue;
        }
        if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
        return response.json();
    }
    throw new Error('Max retries exceeded');
}

Complete DATETIME_FORMAT Tokens

Token Output Example
YYYY 4-digit year 2026
YY 2-digit year 26
Q Quarter 1-4
M / MM Month 3 / 03
MMM / MMMM Month name Mar / March
D / DD Day 5 / 05
Do Day ordinal 5th
d / dd / ddd / dddd Weekday 5 / Fr / Fri / Friday
H / HH Hour 24h 9 / 09
h / hh Hour 12h 9 / 09
m / mm Minute 5 / 05
s / ss Second 5 / 05
SSS Milliseconds 123
A / a AM/PM AM / am
X Unix seconds 1774692000
x Unix ms 1774692000000
W ISO week 13

Internal API — Calculated Field Creation

The public Meta API refuses to create calculated fields (UNSUPPORTED_FIELD_TYPE_FOR_CREATE: "Creating rollup fields is not supported at this time" — same for formula, lookup, count, aiText). The internal /v0.3/column/{fieldId}/create endpoint fully supports them — this is the endpoint the Airtable web UI itself uses.

When to use this vs. Omni AI

Two supported paths exist for creating calculated fields. Pick per task:

Situation Path
One-off field, human at the keyboard Omni AI — natural language, in-browser, zero setup
Bulk / repeated / scripted / CI-driven Internal Web API (this section)
Need reproducibility across environments Internal Web API
Don't want to maintain an undocumented-endpoint integration Omni AI

Risk of this path: the /v0.3/column/ endpoints are not part of Airtable's public contract. Airtable can change the payload shape, add CSRF validation, or block non-browser clients without notice. If the browser UI still works but your script 4xx's, assume the payload shape drifted — re-capture a HAR and diff.

Reverse-engineered from a browser HAR capture (April 2026). No _csrf token is required for these endpoints — only session cookies.

Endpoints

Method URL Purpose
POST /v0.3/column/{newFieldId}/create Create a new field (text, formula, rollup, lookup, count, select, foreignKey, button, …). The {newFieldId} is generated client-side — the server uses the ID you put in the URL as the new field's ID.
POST /v0.3/column/{fieldId}/updateConfig Update an existing field's type/typeOptions (e.g. add filters to a rollup, change formula text). Returns {"data": {"actionId": "act..."}}.
POST /v0.3/table/{tableId}/getUnsavedColumnConfigResultType Validate a formula before saving. Returns {"pass": true, "resultType": "text"}. Optional but recommended for formulas — catches syntax errors before create.

Client-generated field IDs. The Airtable web client generates a random fld... ID (17 chars, starts with fld, then 14 chars of A-Za-z0-9) and puts it in the URL. The server trusts it. If you send two creates with the same ID, the second will conflict. Use a short random generator — e.g. Python "fld" + "".join(random.choices(string.ascii_letters + string.digits, k=14)).

Auth

Cookie-based session. Same cookies as the existing "Internal Web API" section of SKILL.md — capture from DevTools → Network → any /v0.3/ request → copy the full Cookie header. The important cookie is __Host-airtable-session; Airtable also sets brwIds, __Host-airtable-session.sig, and several analytics cookies, so copy the whole header as one blob.

Never hardcode. Store the full cookie string in 1Password under the appropriate client/base vault — op://{vault}/airtable-web-session/cookie — and retrieve with op read at runtime. One secret, one consumer: each base gets its own item; do not share cookies across clients.

export AIRTABLE_COOKIE="$(op read 'op://{vault}/airtable-web-session/cookie')"

Session lifetime is whatever the browser session is — typically weeks, but any logout or password change invalidates it. If a request returns 401 or redirects to /login, re-capture.

Required headers (all calculated-field endpoints)

x-airtable-application-id: {baseId}
x-airtable-inter-service-client: webClient
x-airtable-page-load-id: pglo{14 chars}      # any stable random value per session is fine
x-requested-with: XMLHttpRequest
x-time-zone: {IANA tz, e.g. UTC}
x-user-locale: en
origin: https://airtable.com
referer: https://airtable.com/{baseId}/{tableId}/{viewId}
content-type: application/x-www-form-urlencoded; charset=UTF-8
cookie: {full cookie blob}

origin and referer are checked loosely — missing them gets 403. x-airtable-page-load-id is checked for presence/format, not against any registry.

Body format — form-encoded with a JSON blob

Every create / updateConfig body is application/x-www-form-urlencoded with one field of interest:

stringifiedObjectParams={url-encoded JSON}&requestId=req{14 chars}&secretSocketId=soc{14 chars}
  • requestId — any req + 14 random chars; appears in idempotency / log correlation but the server does not require strict uniqueness
  • secretSocketId — any soc + 14 random chars; only used for realtime push fan-out, safe to invent

Create — text / select / etc. (baseline shape)

{
  "tableId": "tbl...",
  "name": "Label",
  "config": { "default": null, "type": "text", "typeOptions": null },
  "description": null,
  "activeViewId": "viw...",
  "afterOverallColumnIndex": 4,
  "origin": "gridAddFieldButton"
}

afterOverallColumnIndex controls column position (0-indexed, places new column after that index). activeViewId can be any view on the target table; origin is telemetry — any string works but "gridAddFieldButton" is safest.

Create — formula

{
  "tableId": "tbl...",
  "name": "Calculation",
  "config": {
    "default": null,
    "type": "formula",
    "typeOptions": {
      "formulaText": "DATETIME_DIFF(TODAY(), {Created}, 'days')"
    }
  },
  "description": null,
  "activeViewId": "viw...",
  "afterOverallColumnIndex": 7,
  "origin": "gridAddFieldButton"
}

Formula text uses field NAMES (wrapped in {} if they contain spaces), not field IDs. Same syntax as the UI formula editor. Validate first via getUnsavedColumnConfigResultType:

// POST /v0.3/table/{tableId}/getUnsavedColumnConfigResultType
{
  "config": {
    "default": null,
    "type": "formula",
    "typeOptions": { "formulaText": "TODAY()&Status" }
  }
}
// → {"msg":"SUCCESS","data":{"pass":true,"resultType":"text"}}

resultType returned: text, number, date, boolean, or the parent field type if the formula resolves to a select. If pass: false, the create will return 422.

Formula output type coercion (select). To make a formula output a single-select with colored chips, add to typeOptions:

{
  "formulaText": "...",
  "formulaSelectFallbackChoice": "selFORMULADEFAULT",
  "choices": {
    "selFORMULADEFAULT": { "id": "selFORMULADEFAULT", "name": "Default", "color": "gray" },
    "sel{14chars}":       { "id": "sel{14chars}",       "name": "OptionA", "color": "blue" }
  },
  "choiceOrder": ["selFORMULADEFAULT", "sel{14chars}"],
  "disableColors": false,
  "formulaOutputColumnType": "select"
}

Create — rollup

Minimum required fields: relationColumnId, foreignTableRollupColumnId, formulaText.

{
  "tableId": "tbl...",
  "name": "Total Paid",
  "config": {
    "default": null,
    "type": "rollup",
    "typeOptions": {
      "relationColumnId": "fld{LINK FIELD on this table}",
      "foreignTableRollupColumnId": "fld{FIELD on linked table to aggregate}",
      "formulaText": "SUM(values)"
    }
  },
  "description": null,
  "activeViewId": "viw...",
  "afterOverallColumnIndex": 10,
  "origin": "gridAddFieldButton"
}

Aggregation formulasformulaText is a formula expression over the implicit array variable values:

  • SUM(values), AVERAGE(values), MIN(values), MAX(values), COUNT(values), COUNTA(values), COUNTALL(values)
  • ARRAYUNIQUE(values), ARRAYCOMPACT(values), ARRAYJOIN(values, ', ')
  • AND(values), OR(values)
  • Any regular formula — e.g. IF(SUM(values) > 100, "hi", "lo")

Filter on rollup source rowsfilters is optional in both create and updateConfig. Observed working in the HAR via updateConfig after create (the UI flow always created first with empty filter, then updated with filters), but the structure is identical to create bodies for other field types, so it should work in create too. Safe path: create without filters, then updateConfig with filters.

"typeOptions": {
  "relationColumnId": "fld...",
  "foreignTableRollupColumnId": "fld...",
  "formulaText": "SUM(values)",
  "filters": {
    "conjunction": "and",
    "filterSet": [
      {
        "id": "flt{14chars}",
        "columnId": "fld{column on the LINKED table to filter by}",
        "operator": "=",
        "value": false
      }
    ]
  },
  "sorts": [
    { "id": "srt{14chars}", "columnId": "fld...", "ascending": true }
  ]
}

Checkbox filter operators — observed in HAR: contains for text. For checkbox fields, the standard Airtable filter operators are = with value: true/value: false, and isEmpty / isNotEmpty. Checkbox unchecked stores as null in cell data but filter comparison with =false works in the UI. If =false fails, fall back to isEmpty (for unchecked) or isNotEmpty (for checked).

Each filter needs a client-generated idflt + 14 random chars. Same for sorts: srt + 14 chars.

Create — count

Count is just a filter-aware counter over linked records. Minimum: relationColumnId.

{
  "tableId": "tbl...",
  "name": "Active Registrations",
  "config": {
    "default": null,
    "type": "count",
    "typeOptions": {
      "relationColumnId": "fld{LINK FIELD}",
      "filters": {
        "conjunction": "and",
        "filterSet": [
          { "id": "flt{14chars}", "columnId": "fld{col on linked table}", "operator": "=", "value": false }
        ]
      }
    }
  },
  "description": null,
  "activeViewId": "viw...",
  "afterOverallColumnIndex": 11,
  "origin": "gridAddFieldButton"
}

Create — lookup

{
  "tableId": "tbl...",
  "name": "Linked Names",
  "config": {
    "default": null,
    "type": "lookup",
    "typeOptions": {
      "relationColumnId": "fld{LINK FIELD}",
      "foreignTableRollupColumnId": "fld{FIELD on linked table to read}",
      "rowLimit": { "limit": 1, "firstOrLast": "last" },
      "filters": {
        "conjunction": "and",
        "filterSet": [
          { "id": "flt{14chars}", "columnId": "fld...", "operator": "contains", "value": "das" }
        ]
      },
      "sorts": [
        { "id": "srt{14chars}", "columnId": "fld...", "ascending": true }
      ]
    }
  },
  "description": null,
  "activeViewId": "viw...",
  "afterOverallColumnIndex": 11,
  "origin": "gridAddFieldButton"
}

rowLimit is optional (omit for all rows). firstOrLast: "last" takes the latest by sort order.

Create — AI text (aiText)

Not captured in the HAR used for this reverse-engineering. The shape matches other calculated fields — type: "aiText", typeOptions with a prompt template. Capture fresh from a session where you create an AI field, then update this section.

Response shape

On success:

{ "msg": "SUCCESS", "data": null }

(The field is now live. Re-read the table schema via MCP describe_table or Meta API to get the full created config.)

On failure — returns 422 with:

{ "error": { "type": "VALIDATION_FAILED", "message": "Sorry, there was a problem creating this field. The options are not valid." } }

Observed cause: rollup created with empty relationColumnId, or formula that fails parse.

Warnings (e.g. schema dependency impact) return 422 with type: "COLUMN_CONFIG_UPDATE_WARNING" and a base64-JSON body containing hasDependencyCheckWarning: true. To bypass, re-send with schemaDependenciesCheckParams.columnUpdateSkipWarningPrefs.skipColumnConfigExternalSyncWarning: true and skipColumnConfigChangeWarning: true.

Python recipe

import os, json, secrets, string, urllib.parse, requests

def _rid(prefix, n=14):
    alphabet = string.ascii_letters + string.digits
    return prefix + "".join(secrets.choice(alphabet) for _ in range(n))

BASE_ID  = os.environ["AIRTABLE_BASE_ID"]
TABLE_ID = os.environ["AIRTABLE_TABLE_ID"]
VIEW_ID  = os.environ["AIRTABLE_VIEW_ID"]     # any view on that table
COOKIE   = os.environ["AIRTABLE_COOKIE"]       # full Cookie header from op read

HEADERS = {
    "x-airtable-application-id": BASE_ID,
    "x-airtable-inter-service-client": "webClient",
    "x-airtable-page-load-id": _rid("pglo"),
    "x-requested-with": "XMLHttpRequest",
    "x-time-zone": "UTC",
    "x-user-locale": "en",
    "origin": "https://airtable.com",
    "referer": f"https://airtable.com/{BASE_ID}/{TABLE_ID}/{VIEW_ID}",
    "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
    "cookie": COOKIE,
}

def create_column(config, name, after_index=0, description=None):
    new_field_id = _rid("fld")
    params = {
        "tableId": TABLE_ID,
        "name": name,
        "config": config,
        "description": description,
        "activeViewId": VIEW_ID,
        "afterOverallColumnIndex": after_index,
        "origin": "gridAddFieldButton",
    }
    body = urllib.parse.urlencode({
        "stringifiedObjectParams": json.dumps(params),
        "requestId": _rid("req"),
        "secretSocketId": _rid("soc"),
    })
    r = requests.post(
        f"https://airtable.com/v0.3/column/{new_field_id}/create",
        headers=HEADERS, data=body, timeout=30,
    )
    r.raise_for_status()
    data = r.json()
    if data.get("msg") != "SUCCESS":
        raise RuntimeError(f"create failed: {data}")
    return new_field_id

def update_column_config(field_id, typeOptions_config):
    """typeOptions_config is the FULL config object (default, type, typeOptions)."""
    params = {
        **typeOptions_config,
        "activeViewId": VIEW_ID,
        "schemaDependenciesCheckParams": {
            "columnUpdateSkipWarningPrefs": {
                "skipColumnConfigExternalSyncWarning": False,
                "skipColumnConfigChangeWarning": False,
            }
        },
    }
    body = urllib.parse.urlencode({
        "stringifiedObjectParams": json.dumps(params),
        "requestId": _rid("req"),
        "secretSocketId": _rid("soc"),
    })
    r = requests.post(
        f"https://airtable.com/v0.3/column/{field_id}/updateConfig",
        headers=HEADERS, data=body, timeout=30,
    )
    r.raise_for_status()
    return r.json()

Usage — a rollup SUM(values) of Payments.amount:

fid = create_column(
    config={
        "default": None,
        "type": "rollup",
        "typeOptions": {
            "relationColumnId": "fldLINK",
            "foreignTableRollupColumnId": "fldAMOUNT",
            "formulaText": "SUM(values)",
        },
    },
    name="Total Paid",
    after_index=20,
)
print("created", fid)

Gotchas

  1. Public Meta API still refuses these field types. Always use the internal endpoint for create; use the public API for reads/updates of records after creation.
  2. Field ID collisions. Client generates the fld... ID — roll a cryptographically random 14-char suffix. Don't reuse IDs.
  3. relationColumnId must be a link field on the SAME table as the rollup/count/lookup. foreignTableRollupColumnId must be a field on the linked table (the target of the link).
  4. Rollup formulaText syntax differs from regular formulas — uses values as the implicit array. SUM({Amount}) will NOT work; use SUM(values) where foreignTableRollupColumnId already points at Amount.
  5. Filter id required. Every filter entry and sort entry must have a unique flt... / srt... id — the server rejects filters without one.
  6. Warning bypass. If updateConfig returns 422 with COLUMN_CONFIG_UPDATE_WARNING, re-send with both skipColumnConfigExternalSyncWarning: true and skipColumnConfigChangeWarning: true.
  7. Referer must be a valid base/table/view URL on airtable.com. A blank referer returns 403.
  8. Cookies expire silently. A 302 redirect to /login HTML instead of JSON means re-capture the cookie.
  9. No rate limit documented, but UI throttles to ~1 field-create per 500 ms. Don't loop faster than 2 QPS or you'll hit the generic 429.
  10. Filters with msg:"SUCCESS", data:null — success returns data: null on create. Don't confuse with failure.
  11. updateConfig returns an actionId — you can poll it via the base realtime socket, or just re-read schema after ~500 ms.
  12. Formula preview endpoint (getUnsavedColumnConfigResultType) only validates formulas — it ignores relationColumnId validity for rollups. Do your own existence check before POSTing.

Internal API — Personal Access Token Management

The public REST API does not expose any PAT-management endpoints — listing, creating, regenerating, renaming, and revoking PATs are all UI-only as far as Airtable's public docs go. The Builder Hub uses internal /v0.3/user/{userId}/... endpoints for all five operations. Reverse-engineered from a HAR capture (May 2026, Builder Hub session).

When to use this:

  • Programmatic rotation of PATs across many automations / consumers
  • Audits ("which PATs exist, what scopes, accessible to which bases")
  • CI-driven PAT lifecycle (mint → use → revoke at end of job)

When NOT to use this:

  • One-off mint/revoke — Builder Hub UI is faster
  • Anywhere the cookie-session liveness is fragile (CI on a shared runner)

Auth

Same cookie-based auth as the rest of the internal API. The PAT-management endpoints DO require _csrf (unlike /v0.3/column/...). Capture both the cookie blob AND the CSRF token from any logged-in /v0.3/... request — the CSRF token is in the x-csrf-token request header and rotates per session.

# In secret-capture or a 1P item, store both:
op://{vault}/airtable-web-session/cookie       # full Cookie header
op://{vault}/airtable-web-session/csrf-token   # x-csrf-token header value

Endpoints

All bodies use the stringifiedObjectParams envelope — the params are JSON-encoded as a string field inside the outer JSON. Don't send the params as a nested object; the server expects a string.

Method URL Purpose
GET /v0.3/user/{userId}/getPersonalAccessTokensForDevelopersHub List the calling user's PATs (id, name, scopes, accessible workspaces/applications, last-used). Does NOT return the token value — values are write-only at mint time.
POST /v0.3/user/{userId}/createPersonalAccessToken Mint a new PAT. Response includes the plaintext token value once — capture and route to consumer immediately.
POST /v0.3/user/{userId}/regeneratePersonalAccessToken In-place value swap — same tokenId, new value. Old value invalidated server-side at the moment the new value is returned. No revoke step needed; this IS rotation.
POST /v0.3/user/{userId}/updatePersonalAccessTokenName Rename a PAT.
POST /v0.3/user/{userId}/destroyMultiplePersonalAccessTokens Revoke one or more PATs by id.

Request body shapes (scrubbed)

// POST createPersonalAccessToken
{
  "stringifiedObjectParams": "{\"name\":\"my-rotation-pat\",\"explicitTokenResources\":{\"type\":\"specificModelIds\",\"workspaceIds\":[],\"applicationIds\":[\"appXXXXXXXXXXXXXX\"]},\"scopes\":[\"data.records:read\",\"data.records:write\",\"webhook:manage\"]}",
  "requestId": "req<random16>",
  "_csrf": "<csrf-token>"
}

// POST regeneratePersonalAccessToken
{
  "stringifiedObjectParams": "{\"tokenId\":\"patXXXXXXXXXXXXXX\"}",
  "requestId": "req<random16>",
  "_csrf": "<csrf-token>"
}

// POST updatePersonalAccessTokenName
{
  "stringifiedObjectParams": "{\"tokenId\":\"patXXXXXXXXXXXXXX\",\"name\":\"new-name\"}",
  "requestId": "req<random16>",
  "_csrf": "<csrf-token>"
}

// POST destroyMultiplePersonalAccessTokens
{
  "stringifiedObjectParams": "{\"tokenIds\":[\"patXXXXXXXXXXXXXX\",\"patYYYYYYYYYYYYYY\"]}",
  "requestId": "req<random16>",
  "_csrf": "<csrf-token>"
}

Response envelope

{ "msg": "SUCCESS", "data": { ... } }
  • createPersonalAccessToken and regeneratePersonalAccessToken return the plaintext PAT value in data.personalAccessToken — single chance to capture it.
  • getPersonalAccessTokensForDevelopersHub returns data.tokens (array of token metadata; values masked).
  • destroyMultiplePersonalAccessTokens returns data: null on success.
  • updatePersonalAccessTokenName returns data.name.

Scope vocabulary

The scopes array in createPersonalAccessToken accepts the same scope strings exposed in Builder Hub UI. Common ones:

  • data.records:read
  • data.records:write
  • data.recordComments:read
  • data.recordComments:write
  • schema.bases:read
  • schema.bases:write
  • webhook:manage
  • block:manage
  • enterprise.account:read (enterprise plans only)

Resource targeting

explicitTokenResources controls which workspaces / bases the PAT can access:

type Behavior
allCurrentAndFuture Every workspace + base the user has access to, including future ones.
specificModelIds Limited to the listed workspaceIds[] and/or applicationIds[]. Empty arrays means none — be careful, you can mint a PAT with no resources by accident.

Operational gotchas

  1. The token value appears once. Both create and regenerate return the plaintext value in the response and never again. Capture into the consumer immediately — secret-capture skill is the safe path so the value never enters the agent transcript.
  2. regenerate is the safest rotation primitive. Same token id, scopes, and resource list — only the value changes. Consumers that store the value (only) need updating; consumers that store the token id can keep going. Use this over create + destroy whenever the goal is a value-only swap.
  3. Old value dies the moment the new one is returned. There is no overlap window. Stage the consumer update so the old value's last successful call happens BEFORE you call regenerate, and the next call uses the new value.
  4. destroyMultiple is bulk + idempotent. Sending an already-revoked id returns success. Useful for a "revoke all PATs matching a name prefix" sweep.
  5. CSRF token rotates per session. Cache it for the duration of a script's run; re-capture on 403 Invalid CSRF.
  6. Listing returns metadata only — never the value. A "show me what PATs exist" audit is safe; a "what is the value of this PAT" lookup is impossible (correctly).
  7. PAT id is pat + 14 chars [A-Za-z0-9] — same prefix as the value but a different identifier. The id is stable across regenerations; the value changes.
  8. The user's own user id (usrXXX...) is in the URL path. Find it in the response of any /v0.3/user/{userId}/... call you've already captured, or via getMe-style endpoints that some Builder Hub screens hit.

Python helper

import json, secrets, string, urllib.request

def _airtable_internal_post(path: str, params: dict, *, cookie: str, csrf: str, base_id: str | None = None) -> dict:
    """POST to /v0.3/... with the stringifiedObjectParams envelope."""
    body = json.dumps({
        "stringifiedObjectParams": json.dumps(params, separators=(",", ":")),
        "requestId": "req" + "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)),
        "_csrf": csrf,
    }).encode()
    req = urllib.request.Request(
        f"https://airtable.com{path}",
        data=body,
        method="POST",
        headers={
            "Content-Type": "application/json",
            "Cookie": cookie,
            "x-csrf-token": csrf,
            "x-airtable-inter-service-client": "webClient",
            "x-requested-with": "XMLHttpRequest",
            "x-airtable-application-id": base_id or "appAAAAAAAAAAAAAA",
        },
    )
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())

def regenerate_pat(user_id: str, token_id: str, *, cookie: str, csrf: str) -> str:
    """Rotate a PAT in place. Returns the new plaintext token value (capture once)."""
    res = _airtable_internal_post(
        f"/v0.3/user/{user_id}/regeneratePersonalAccessToken",
        {"tokenId": token_id},
        cookie=cookie, csrf=csrf,
    )
    if res.get("msg") != "SUCCESS":
        raise RuntimeError(f"regenerate failed: {res}")
    return res["data"]["personalAccessToken"]

def list_pats(user_id: str, *, cookie: str) -> list[dict]:
    req = urllib.request.Request(
        f"https://airtable.com/v0.3/user/{user_id}/getPersonalAccessTokensForDevelopersHub",
        headers={
            "Cookie": cookie,
            "x-airtable-inter-service-client": "webClient",
            "x-requested-with": "XMLHttpRequest",
        },
    )
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())["data"]["tokens"]

def destroy_pats(user_id: str, token_ids: list[str], *, cookie: str, csrf: str) -> None:
    res = _airtable_internal_post(
        f"/v0.3/user/{user_id}/destroyMultiplePersonalAccessTokens",
        {"tokenIds": token_ids},
        cookie=cookie, csrf=csrf,
    )
    if res.get("msg") != "SUCCESS":
        raise RuntimeError(f"destroy failed: {res}")

Safe rotation pattern

# Goal: swap the PAT value without ever exposing it to logs/transcripts.
# The new value is captured directly into the consumer (env file, 1P, n8n).

new_value = regenerate_pat(USER_ID, TOKEN_ID, cookie=COOKIE, csrf=CSRF)
# `new_value` is in process memory only. Do NOT print it.
write_to_consumer(new_value)              # e.g. ssh + sed -i, or n8n credential update
del new_value

For agentic flows where the value should never enter the agent's process memory, use the secret-capture skill's ssh adapter — the user pastes the regenerated value into a hidden dialog, and the adapter routes it directly to the target without round-tripping through the agent.


Internal API — Workspace + Application Management

Discovered while trying to relieve one workspace's automation-run quota by moving production bases to a sibling workspace. The read path works headlessly; the write path needs a live SPA session (cookies alone are insufficient).

When to use this

  • Read: you need workspace → bases mapping (which bases live in which workspace). Public Meta API doesn't return this. Use the listApplicationsAndPageBundlesForDisplay endpoint.
  • Write: drive the operator's logged-in Chrome via AppleScript + injected fetch(). Headless HTTP replay fails — see "Write path requires live secretSocketId" below.

Endpoints

Endpoint Method Purpose
/v0.3/user/{userId}/listApplicationsAndPageBundlesForDisplay?stringifiedObjectParams={"shouldIncludePageBundleSharingApplications":true,"shouldIncludePageBundleIndex":true}&requestId=req<rand> GET Enumerate all workspaces + bases visible to user.
/v0.3/user/{userId}/getMostRecentlyOpenedWorkspaces?stringifiedObjectParams={}&requestId=req<rand> GET Recently-opened workspaces (less useful — listApplicationsAndPageBundlesForDisplay returns everything).
/v0.3/user/{userId}/getFavorites?stringifiedObjectParams={}&requestId=req<rand> GET Pinned/starred bases.
/v0.3/workspace/{srcWorkspaceId}/moveApplication POST Needs live secretSocketId + x-airtable-page-load-id — see "Write path" below.

Response shape — listApplicationsAndPageBundlesForDisplay

{
  "msg": "SUCCESS",
  "data": {
    "workspaceRecordById": {
      "wspXXXXXXXXXXXXXX": {
        "id": "wspXXXXXXXXXXXXXX",
        "name": "Workspace A",
        "visibleApplicationOrder": ["appXXXXXXXXXXXXXX", "appYYYYYYYYYYYYYY", ...],
        "createdTime": "2025-04-05T04:05:46.000Z",
        "sharedWithCurrentUser": {"sharedBy": "usr...", "directPermissionLevel": "owner", ...}
      }
    },
    "applicationRecordById": {
      "appXXXXXXXXXXXXXX": {
        "id": "appXXXXXXXXXXXXXX",
        "name": "Example Base",
        "color": "blue",
        "icon": "users",
        "createdTime": "...",
        "currentUserEffectivePermissionLevel": "owner",
        "isOverPlanLimits": false,
        ...
      }
    },
    "pageBundles": [...],
    "isPossiblyMissingPageBundles": false
  }
}

workspaceRecordById[wid].visibleApplicationOrder is the canonical workspace → bases mapping. Auth: cookies-only.

Cookie extraction — dedupe by host_key

Chrome stores three separate __Host-airtable-session rows in the cookies DB — one each for host_key='airtable.com', 'app.airtable.com', 'www.airtable.com'. The __Host- prefix means each is host-only, so the browser only sends the matching one per request, but browser_cookie3.chrome(domain_name='airtable.com') returns all three concatenated, which the server then rejects.

Use this dedupe pattern (prefer host_key='airtable.com'):

import browser_cookie3, os, sys

prof = sys.argv[1] if len(sys.argv) > 1 else 'Default'
cf = os.path.expanduser(f'~/Library/Application Support/Google/Chrome/{prof}/Cookies')
cj = browser_cookie3.chrome(cookie_file=cf, domain_name='airtable.com')

prio = {'airtable.com': 0, '.airtable.com': 1, 'app.airtable.com': 2, 'www.airtable.com': 3}
by_name = {}
for c in cj:
    p = prio.get(c.domain, 99)
    prev = by_name.get(c.name)
    if prev is None or p < prio.get(prev.domain, 99):
        by_name[c.name] = c
print('; '.join(f'{c.name}={c.value}' for c in by_name.values()))

Consume inline via $(...) — never assign to a long-lived shell variable per secret-capture discipline.

Chrome profile selection

When the operator runs multiple Chrome profiles (each holding a different Airtable login), there may be no Default profile. Map profile dir → name + Google account by reading ~/Library/Application Support/Google/Chrome/Local State:

jq -r '.profile.info_cache | to_entries | .[] | "\(.key)\t\(.value.name)\t\(.value.user_name // "")"' \
  ~/Library/Application\ Support/Google/Chrome/Local\ State

The Chrome profile's Google account does NOT guarantee the Airtable account inside it. To verify which profile holds the Airtable session for a given workspace, hit listApplicationsAndPageBundlesForDisplay and check whether the target workspace is in workspaceRecordById.

userSignature cookie — rolling per-action

The session payload itself (__Host-airtable-session, base64 JSON) carries csrfSecret, userId, loggedInTime, and highSecurityModeEnabledTime. That's stable across a session.

Separately, state-changing endpoints check a userSignature + userSignature.sig cookie pair that the SPA generates client-side per-action:

userSignature=<userId><ISO-timestamp-rounded-to-minute>
userSignature.sig=<HMAC base64url>

Format example: userSignature=usrXXXXXXXXXXXXXX2026-01-01T12:27:00.000Z. The HMAC is derived from the session's csrfSecret. TTL appears to be ~5 minutes — the cookie is renewed whenever the user clicks anything in the SPA.

This cookie IS readable from Chrome's cookie DB (it's a normal first-party cookie on .airtable.com), so browser_cookie3 picks it up automatically — but only if the user has clicked something in Airtable within the last ~5 minutes. Stale cookie DBs won't have it.

Write path requires live secretSocketId + x-airtable-page-load-id from the SPA's active session

POST /v0.3/workspace/{wsid}/moveApplication (and other workspace-structure writes) returns 401 INVALID_AUTH_TOKEN from headless HTTP replay — even with all the right cookies + headers — because the body needs a secretSocketId value that the server validates against the live websocket connection owned by the user's active SPA session. Fabricated soc<rand> values are rejected. The header x-airtable-page-load-id must similarly match the SPA's current page load.

GET endpoints (listApplicationsAndPageBundlesForDisplay, getFavorites, workflowDeployment/.../read) don't require either — they're auth-cookie-only.

PerimeterX cookies (_pxvid / _pxhd / pxcts) are present but not in play for this path. The block is socket-binding, not fingerprint. The AppleScript-driven fetch approach below confirms this — once the request rides the live SPA session, the same cookies/headers that failed via curl succeed.

Move from headless: AppleScript → Chrome → fetch() inside live SPA tab

The reliable path: drive the operator's actual Chrome (the profile logged into the target Airtable account) and call fetch() from inside an open airtable.com tab. The browser supplies cookies + live socket binding automatically.

Prerequisite (one-time): Chrome menu → View → Developer → Allow JavaScript from Apple Events → tick.

Get the live IDs from the SPA without DevTools:

JSON.stringify((function(){
  var e = performance.getEntriesByType('resource').slice(-80);
  var p = new Set(), s = new Set();
  e.forEach(function(x){
    var m = x.name.match(/pgl[A-Za-z0-9]{12,20}/); if (m) p.add(m[0]);
    var n = x.name.match(/soc[A-Za-z0-9]{12,20}/); if (n) s.add(n[0]);
  });
  return { pgl: Array.from(p), soc: Array.from(s) };
})())

Returns the active pageLoadId and secretSocketId. Both rotate per SPA session — re-extract whenever the operator reloads the page.

Move payload (form-encoded body):

stringifiedObjectParams={"applicationId":"app...","targetWorkspaceId":"wsp...","targetIndex":1}
&requestId=req<rand>
&secretSocketId=<live-soc-from-performance-entries>

Required headers:

accept: application/json, text/javascript, */*; q=0.01
content-type: application/x-www-form-urlencoded; charset=UTF-8
x-airtable-inter-service-client: webClient
x-airtable-page-load-id: <live-pgl-from-performance-entries>
x-requested-with: XMLHttpRequest
x-time-zone: <user's-tz>
x-user-locale: en

Bash + AppleScript pattern that works:

# Write the fetch as a JS file (avoids AppleScript's quoting hell)
cat > /tmp/move.js <<'JS'
(function(){
  window.__r = null;
  var body = new URLSearchParams({
    stringifiedObjectParams: JSON.stringify({applicationId:"<APP>",targetWorkspaceId:"<DST>",targetIndex:1}),
    requestId: "req" + Math.random().toString(36).slice(2,18),
    secretSocketId: "<LIVE_SOC>"
  });
  fetch("https://airtable.com/v0.3/workspace/<SRC>/moveApplication", {
    method:"POST", credentials:"include",
    headers:{
      "accept":"application/json, text/javascript, */*; q=0.01",
      "content-type":"application/x-www-form-urlencoded; charset=UTF-8",
      "x-airtable-inter-service-client":"webClient",
      "x-airtable-page-load-id":"<LIVE_PGL>",
      "x-requested-with":"XMLHttpRequest",
      "x-time-zone":"<operator-timezone>",
      "x-user-locale":"en"
    },
    body: body.toString()
  }).then(function(r){return r.text().then(function(t){window.__r={status:r.status,body:t};});});
  return "fired";
})()
JS

# Load JS via shell-out from AppleScript (avoids => and other JS tokens breaking AS parser)
osascript -e 'set jsCode to (do shell script "cat /tmp/move.js")
tell application "Google Chrome"
  return execute (tab 2 of window 1) javascript jsCode
end tell'

# Poll for completion (async fetch + stash on window.__r)
sleep 2
osascript -e 'tell application "Google Chrome"
  return execute (tab 2 of window 1) javascript "JSON.stringify(window.__r)"
end tell'

Why pass JS via do shell script "cat …": AppleScript's parser chokes on => and other JS tokens even when JSON-encoded. Loading the JS body as a string from a file works around it cleanly.

Why poll instead of await: AppleScript's execute javascript returns the sync expression value; it cannot await a Promise. Fire-and-stash via window.__r, then poll.

What works headlessly + what doesn't

Operation Method
listApplicationsAndPageBundlesForDisplay (GET) cookies-only via curl ✅
getFavorites, getMostRecentlyOpenedWorkspaces (GET) cookies-only ✅
workflowDeployment/{id}/read (GET) cookies-only ✅
listWorkflows, listExecutions (GET) cookies-only ✅
application/{baseId}/readQueries (POST data fetch) cookies-only ✅
workflowDeployment/{NEW_id}/create (enable automation) needs live secretSocketId
workflow/{wflId}/unregister (disable automation) needs live secretSocketId
column/{fieldId}/create (create calculated field) needs live secretSocketId
workspace/{wsid}/moveApplication (move base) needs live secretSocketId + pageLoadId — use AppleScript→Chrome→fetch pattern above

The boundary is: any endpoint that mutates user-visible state via the realtime sync layer requires the socket binding. GET reads and PAT-management endpoints don't.

Practical move workflow

  1. Operator has Chrome open on Airtable in the profile logged into the target account; ensure "Allow JavaScript from Apple Events" is on.
  2. Agent extracts live pageLoadId + secretSocketId via performance.getEntriesByType('resource') injection.
  3. Agent reads inventory via listApplicationsAndPageBundlesForDisplay and presents per-base move plan.
  4. On approval: agent fires moveApplication POSTs sequentially with ~800ms gap, using fire-and-stash + poll for results.
  5. Re-fetch the listing and verify the source workspace ended with the expected residual (typically zero) and the destination has the expected new count.

Worked example: 11 bases moved between sibling workspaces in ~30 seconds wall-clock once IDs were extracted.