Skip to content
Back to skills

Airtable

ASecurity

Airtable is a hosted spreadsheet-database; its Web API reads and writes records, table schema, attachments and webhooks over REST. Use when tasks involve reading or writing Airtable data, importing or upserting rows from a CSV or another system, syncing external sources with Airtable bases, reacting to record changes with webhooks, connecting users through Airtable OAuth, or debugging 422 and 429 responses from api.airtable.com.

  • 142 stars
  • 0 votes
  • 2 copies
  • 6 views
  • Added May 27, 2026
toolspythongobashreactdebuggingapidatabase

Works with

  • cursor
  • terminal
  • cli
  • api

Security analysis

A96/100
  • mediumUses curl or wget to download content

Pro scans all 2 files and shows the line behind each finding

Scanned October 4, 2026

npx -y skills add TerminalSkills/skills --skill airtable --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Airtable?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Airtable
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/terminalskills-airtable/badge)](https://www.skillsdirectory.com/skills/terminalskills-airtable)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: airtable
description: >-
  Airtable is a hosted spreadsheet-database; its Web API reads and writes
  records, table schema, attachments and webhooks over REST. Use when tasks
  involve reading or writing Airtable data, importing or upserting rows from a
  CSV or another system, syncing external sources with Airtable bases, reacting
  to record changes with webhooks, connecting users through Airtable OAuth, or
  debugging 422 and 429 responses from api.airtable.com.
license: Apache-2.0
compatibility: "Airtable account with a personal access token or an OAuth integration; code samples use Python 3.9+ with requests"
metadata:
  author: terminal-skills
  version: "1.1.0"
  category: productivity
  tags: ["airtable", "api", "productivity", "databases", "automation"]
---

# Airtable API Integration

## Overview

The Airtable Web API is a REST API at `https://api.airtable.com/v0`. Records live in tables inside a base; IDs are prefixed by type (`app…` base, `tbl…` table, `fld…` field, `rec…` record, `viw…` view). Requests authenticate with a bearer token: a personal access token (PAT) for your own scripts, or an OAuth access token when other users connect their accounts. Legacy API keys stopped working on February 1, 2024. Writes are limited to 10 records per request and every base to 5 requests per second.

## Instructions

### Authentication

Create a personal access token at https://airtable.com/create/tokens, add only the scopes and bases the script needs, and keep it in an environment variable.

```bash
export AIRTABLE_TOKEN="pat..."        # set from your secret manager; never commit it
curl -s https://api.airtable.com/v0/meta/whoami -H "Authorization: Bearer $AIRTABLE_TOKEN"
# {"id":"usrL2PNC5o3H4lBEi"}
```

| Scope | Allows |
|---|---|
| `data.records:read` / `data.records:write` | Read / create, update and delete records |
| `schema.bases:read` / `schema.bases:write` | Read / change tables and fields |
| `webhook:manage` | Create, list and delete webhooks, fetch payloads; creating a `tableData` webhook and reading its payloads also need `data.records:read` |
| `user.email:read` | Email address in `whoami` |

### Records

```python
"""airtable_client.py — minimal Airtable Web API client."""
import base64, hashlib, hmac, os, time
import requests

API = "https://api.airtable.com/v0"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['AIRTABLE_TOKEN']}"

def call(method: str, url: str, **kwargs) -> dict:
    """One request, throttled to 5 per second; waits 30 s and retries on 429."""
    for _ in range(4):
        resp = session.request(method, url, timeout=30, **kwargs)
        if resp.status_code != 429:
            break
        time.sleep(30)
    if not resp.ok:
        raise RuntimeError(f"{method} {url} -> {resp.status_code} {resp.text}")
    time.sleep(0.2)
    return resp.json()

def batches(items: list, size: int = 10):
    for i in range(0, len(items), size):
        yield items[i:i + size]

def list_records(base_id, table, formula=None, fields=None, view=None, sort=None) -> list:
    """Every record of a table. POST /listRecords keeps long formulas out of the URL."""
    options = {"filterByFormula": formula, "fields": fields, "view": view, "sort": sort}
    body = {k: v for k, v in options.items() if v}
    records = []
    while True:
        page = call("POST", f"{API}/{base_id}/{table}/listRecords", json=body)
        records += page["records"]
        if "offset" not in page:
            return records
        body["offset"] = page["offset"]

def create_records(base_id, table, rows: list[dict], typecast=False) -> list:
    created = []
    for batch in batches(rows):
        body = {"records": [{"fields": row} for row in batch], "typecast": typecast}
        created += call("POST", f"{API}/{base_id}/{table}", json=body)["records"]
    return created

def update_records(base_id, table, updates: list[dict]) -> list:
    """updates = [{"id": "rec...", "fields": {...}}]; PATCH leaves other fields untouched."""
    updated = []
    for batch in batches(updates):
        updated += call("PATCH", f"{API}/{base_id}/{table}", json={"records": batch})["records"]
    return updated

def upsert_records(base_id, table, rows: list[dict], merge_on: list[str], typecast=False):
    """Update rows that match on the merge_on fields and create the rest."""
    created, updated = [], []
    for batch in batches(rows):
        body = {"performUpsert": {"fieldsToMergeOn": merge_on}, "typecast": typecast,
                "records": [{"fields": row} for row in batch]}
        data = call("PATCH", f"{API}/{base_id}/{table}", json=body)
        created += data["createdRecords"]
        updated += data["updatedRecords"]
    return created, updated

def delete_records(base_id, table, record_ids: list[str]) -> list:
    deleted = []
    for batch in batches(record_ids):
        params = [("records[]", record_id) for record_id in batch]
        deleted += call("DELETE", f"{API}/{base_id}/{table}", params=params)["records"]
    return deleted
```

- A list page holds at most 100 records (`pageSize`); follow `offset` until it is absent. `maxRecords` caps the total. `GET /v0/{baseId}/{table}` takes the same options as query parameters, but the URL must stay under 16,000 characters.
- Fields whose value is empty (`""`, `[]`, `false`) are omitted from returned records — read with `record["fields"].get("Done", False)`.
- `sort` is a list such as `[{"field": "Due Date", "direction": "desc"}]`. Pass `"returnFieldsByFieldId": true` to key fields by ID, so a renamed column does not break the integration.
- `PUT` instead of `PATCH` clears every field that is not in the request.
- `fieldsToMergeOn` accepts one to three fields of type number, text, long text, single select, multiple select or date. If two existing records match, the request fails.

### Formula filtering

`filterByFormula` takes an Airtable formula; a record is returned when the result is not `0`, `false`, `""`, `NaN`, `[]` or `#Error!`.

```python
formulas = {
    "status_active": "{Status} = 'Active'",
    "high_priority_open": "AND({Priority} = 'High', {Status} != 'Done')",
    "due_in_7_days_or_overdue": "IS_BEFORE({Due Date}, DATEADD(TODAY(), 7, 'days'))",
    "name_contains": "FIND('invoice', LOWER({Name}))",       # case-insensitive search
    "has_project": "{Project} != ''",                        # linked record present
    "missing_email": "{Email} = ''",
}
```

### Schema

```python
tables = call("GET", f"{API}/meta/bases/{base_id}/tables")["tables"]      # schema.bases:read
table_id = next(t["id"] for t in tables if t["name"] == "Orders")

call("POST", f"{API}/meta/bases/{base_id}/tables/{table_id}/fields", json={   # schema.bases:write
    "name": "Region", "type": "singleSelect",
    "options": {"choices": [{"name": "EMEA"}, {"name": "APAC"}, {"name": "Americas"}]},
})
```

`GET /v0/meta/bases` lists the bases the token can reach. Creating a field needs the table ID, not its name.

### Attachments

Either write a public URL into the attachment field (`{"Photos": [{"url": "https://cdn.northwind.io/p/1042.jpg"}]}`) or upload the bytes, up to 5 MB, to the separate content host:

```python
def upload_attachment(base_id, record_id, field, file_path, content_type) -> dict:
    with open(file_path, "rb") as f:
        encoded = base64.b64encode(f.read()).decode()
    url = f"https://content.airtable.com/v0/{base_id}/{record_id}/{field}/uploadAttachment"
    body = {"contentType": content_type, "filename": os.path.basename(file_path), "file": encoded}
    return call("POST", url, json=body)
```

Writing an attachment field replaces its content: include `{"id": "att..."}` for each existing attachment you want to keep. Attachment URLs returned by the API expire after 2 hours; download the file if you need to keep it.

### Webhooks

A webhook sends a small ping to your HTTPS endpoint; the changes themselves are fetched from the payloads endpoint. Tables and fields in the specification must be given by ID.

```python
def create_webhook(base_id, table_id, notification_url) -> dict:
    spec = {"options": {"filters": {"dataTypes": ["tableData"], "recordChangeScope": table_id}}}
    body = {"notificationUrl": notification_url, "specification": spec}
    return call("POST", f"{API}/bases/{base_id}/webhooks", json=body)
    # {"id": "ach...", "macSecretBase64": "...", "expirationTime": "..."} — the secret is shown only once

def verify_ping(raw_body: bytes, mac_header: str, mac_secret_base64: str) -> bool:
    """mac_header is the X-Airtable-Content-MAC request header."""
    digest = hmac.new(base64.b64decode(mac_secret_base64), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"hmac-sha256={digest}", mac_header)

def read_payloads(base_id, webhook_id, cursor=1):
    """Call after each ping; store the returned cursor for the next call."""
    payloads = []
    while True:
        url = f"{API}/bases/{base_id}/webhooks/{webhook_id}/payloads"
        page = call("GET", url, params={"cursor": cursor})
        payloads += page["payloads"]
        cursor = page["cursor"]
        if not page["mightHaveMore"]:
            return payloads, cursor
```

- Optional filters: `fromSources` (`client`, `publicApi`, `formSubmission`, `automation`, `sync`…), `changeTypes` (`add`, `remove`, `update`), `watchDataInFieldIds`. Payloads carry only the cells that changed; add `"includes": {"includeCellValuesInFieldIds": "all"}` under `options` to get every field of a changed record.
- A webhook created with a PAT or OAuth token expires after 7 days. `POST …/webhooks/{id}/refresh` or reading its payloads extends it by 7 days; payloads are kept for a week.
- Limits: 10 webhooks per base, 2 per OAuth integration per base. Creating one requires creator permission on the base.
- Answer each ping with `200` or `204` within 25 seconds. A failed ping is retried with exponential backoff for about a day, then notifications are disabled until re-enabled with `POST …/webhooks/{id}/enableNotifications` and body `{"enable": true}`.

### OAuth 2.0

Register the integration at https://airtable.com/create/oauth. Airtable requires PKCE and a `state` value on every authorization request.

```python
import base64, hashlib, os, secrets, urllib.parse
import requests

def authorization_url(client_id: str, redirect_uri: str) -> tuple[str, str, str]:
    verifier = secrets.token_urlsafe(64)
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    state = secrets.token_urlsafe(32)
    query = urllib.parse.urlencode({
        "client_id": client_id, "redirect_uri": redirect_uri, "response_type": "code",
        "scope": "data.records:read data.records:write schema.bases:read",
        "state": state, "code_challenge": challenge, "code_challenge_method": "S256",
    })
    return f"https://airtable.com/oauth2/v1/authorize?{query}", verifier, state   # keep both in the session

def exchange_code(code: str, verifier: str, client_id: str, redirect_uri: str) -> dict:
    data = {"grant_type": "authorization_code", "code": code,
            "redirect_uri": redirect_uri, "code_verifier": verifier}
    secret = os.environ.get("AIRTABLE_CLIENT_SECRET")
    auth = (client_id, secret) if secret else None        # HTTP Basic when the integration has a secret
    if not secret:
        data["client_id"] = client_id
    return requests.post("https://airtable.com/oauth2/v1/token", data=data, auth=auth, timeout=30).json()
```

Access tokens last 60 minutes and refresh tokens 60 days. Renew with `grant_type=refresh_token`; each refresh invalidates the previous access and refresh tokens, so store the new pair. Compare the returned `state` with the stored one before exchanging the code.

### Field types

| Field | API type | Value written |
|---|---|---|
| Single line / long text | `singleLineText` / `multilineText` | String |
| Rich text | `richText` | Markdown string |
| Number, currency, percent | `number`, `currency`, `percent` | Number (percent as a fraction, 0.25 = 25%) |
| Single / multiple select | `singleSelect` / `multipleSelects` | Option name / list of names |
| Date / date and time | `date` / `dateTime` | ISO 8601 string |
| Checkbox | `checkbox` | Boolean |
| URL, email, phone | `url`, `email`, `phoneNumber` | String |
| Attachment | `multipleAttachments` | List of `{"url": ...}` objects |
| Linked record | `multipleRecordLinks` | List of record IDs |
| Formula, rollup, lookup, count, autonumber, created time | `formula`, `rollup`, `multipleLookupValues`, `count`, `autoNumber`, `createdTime` | Read-only |

## Examples

### Example 1: Import a CSV without creating duplicates

**User request:** "Load customers.csv into the Customers table of our CRM base. Rows whose email already exists should be updated, not duplicated."

```python
import csv, os
from airtable_client import upsert_records
BASE_ID = os.environ["AIRTABLE_BASE_ID"]          # appB7xK2mQ9vTnL4s
with open("customers.csv", newline="") as f:
    rows = [{"Email": r["email"], "Name": r["name"], "Plan": r["plan"], "MRR": r["mrr"]} for r in csv.DictReader(f)]

created, updated = upsert_records(BASE_ID, "Customers", rows, merge_on=["Email"], typecast=True)
print(f"{len(created)} created, {len(updated)} updated")
```

`typecast=True` converts the CSV strings (`"49"` to a number, a new plan name to a new select option). `Email` must be a single line text field: the email field type is not among the types `fieldsToMergeOn` accepts. For 1,240 rows the script sends 124 requests (at least 25 seconds at the throttled rate) and prints `38 created, 1202 updated`.

### Example 2: Query overdue tasks from the command line

**User request:** "Show me the open high-priority tasks that are past their due date."

```bash
curl -s -X POST "https://api.airtable.com/v0/appB7xK2mQ9vTnL4s/Tasks/listRecords" \
  -H "Authorization: Bearer $AIRTABLE_TOKEN" -H "Content-Type: application/json" \
  -d '{"filterByFormula": "AND({Priority} = \"High\", {Status} != \"Done\", IS_BEFORE({Due Date}, TODAY()))",
       "fields": ["Name", "Due Date", "Owner"], "sort": [{"field": "Due Date"}]}'
```

```json
{
  "records": [
    {"id": "recQ4mZ81xYtW0pLd", "createdTime": "2026-09-02T08:14:11.000Z",
     "fields": {"Name": "Renew SOC 2 audit contract", "Due Date": "2026-09-24", "Owner": "Priya Raman"}},
    {"id": "rec7HcVn3KsE9uTaB", "createdTime": "2026-09-10T13:40:52.000Z",
     "fields": {"Name": "Rotate payment gateway keys", "Due Date": "2026-09-29", "Owner": "Marcus Lindqvist"}}
  ]
}
```

No `offset` key in the response means this is the last page.

## Guidelines

- Rate limits: 5 requests per second per base and 50 per second across all PAT traffic of one user. Going over returns `429`, and requests keep failing until you have waited 30 seconds — back off, do not retry immediately.
- Free workspaces are capped at 1,000 API calls per month and Team workspaces at 100,000; schema calls count too. Batch writes (10 records per request) and cache reads instead of polling.
- `422` means the request data failed validation against the base: an unknown field name, an unknown select option without `typecast`, a string in a number field, or a write to a computed field. `403` means the token lacks the scope or the base; `502` and `503` are safe to retry.
- Use table and field IDs in long-lived integrations; names break when someone renames a column.
- Linked record fields take record IDs, not display values. A select value that is not an existing option fails with `INVALID_MULTIPLE_CHOICE_OPTIONS` unless `typecast` is true, in which case Airtable creates the option — convenient for imports, a source of typo options elsewhere.
- Give each token the narrowest scopes and only the bases it needs. Never put a PAT or the webhook MAC secret in client-side code or a repository.
- Verify the `X-Airtable-Content-MAC` header on every webhook ping, and treat the ping as a signal only: fetch payloads with your stored cursor.
- Deleting records and `PUT` updates cannot be undone through the API; confirm the record IDs with the user first.
- For a UI-level export or a one-off edit, the Airtable interface or a CSV import is simpler than the API.

Files in this skill

  • SKILL.md12.3 KB
  • _scores.json1.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…