Airtable API skill for Claude Code
Field IDs, 10-record batches, and verified webhooks. Claude treats Airtable as an external system with a contract: it pins table and field IDs so UI renames cannot break sync, routes every call through one limiter that respects the per-base rate limit and 30-second lockout, writes with idempotent performUpsert batches, and captures changes through MAC-verified webhook pings, persisted payload cursors, and a refresh job.
- name
airtable-api - size212 lines · ≈2,500 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds reliable integrations on the Airtable Web API - personal access tokens and OAuth with minimal scopes, field IDs instead of names, 10-record batches, performUpsert, offset pagination, the 5 requests per second per base limit with 30-second 429 lockouts, webhooks with MAC verification and payload cursors, and schema changes that do not break sync. Use when reading or writing Airtable records from code, syncing Airtable with a database, using Airtable as a lightweight backend or CMS, or debugging 429s, renamed fields, or missed webhook changes. Try asking
- Sync our Postgres orders table into Airtable every few minutes without creating duplicates
- Our Airtable script keeps hitting 429 errors. Fix the client so it stays under the limit
- Set up Airtable webhooks so our app learns about record changes within a minute
Use it when
- Reading or writing Airtable records from a backend, script, or worker
- Using Airtable as a CMS or ops console behind an app
- Integration breaks after someone renames a field in the UI
- Webhook notifications stop arriving or changes are missed
Skip it when
- The workspace tool is Notion rather than Airtable; use notion-api instead
- You need general webhook receiver infrastructure for many providers; use webhook-handling instead
How to check the result
A schema check passes against the live base, 200 writes complete with zero 429 responses, a repeated upsert creates no duplicates, and a tampered webhook ping is rejected while a real one advances the cursor exactly once.
The common mistake it prevents
Referencing fields by display name, so the first time an ops teammate renames a column the sync silently writes nothing or fails.
Done well means: the integration addresses fields by ID so renames do not break it, every write is batched and idempotent, a single throttled client keeps each base under 5 req/s, change capture uses verified webhooks with a persisted cursor, and nobody in the Airtable UI can silently corrupt the data contract.
Ground rules
- Auth with a personal access token (PAT) or OAuth access token sent as
Authorization: Bearer .... Legacy API keys are gone. Store tokens in env or a secret manager (AIRTABLE_TOKEN); never in code, the base itself, or client-side JS. - Grant the fewest scopes and bases:
data.records:read,data.records:write,schema.bases:read(needed for field IDs),webhook:manageonly when used. A PAT for a service should belong to a service account, not a person who might leave. - Use base, table, and field IDs (
app...,tbl...,fld...) in code. Names are for humans and change without warning. - Airtable is not a transactional database. No multi-record transactions, no foreign-key enforcement, eventual reads after writes in other views. Design for that or move the system of record elsewhere.
Is Airtable the right backend?
| Situation | Verdict |
|---|---|
| Ops team edits data; app reads it (CMS, catalog, internal tools) | Good fit: read through a cache |
| Under ~50k records per base, low write rate | Fine (plan limits cap records per base) |
| Customer-facing writes at more than a few per second | Put a queue in front, or use a real DB and sync to Airtable |
| Need transactions, joins, row-level permissions per end user | Use Postgres; mirror to Airtable for ops visibility |
Workflow
- [ ] 1. Inventory the base: fetch schema, record table and field IDs and types
- [ ] 2. Create a scoped PAT or OAuth app; store the token as a secret
- [ ] 3. Build one throttled client per base (5 req/s, 429 handling)
- [ ] 4. Reads: fields by ID, filterByFormula, pagination via offset
- [ ] 5. Writes: 10-record batches, performUpsert on a natural key, typecast policy
- [ ] 6. Change capture: webhooks (verified) or incremental polling
- [ ] 7. Guard the schema contract and verify
1. Inventory the schema
curl -s "https://api.airtable.com/v0/meta/bases/$BASE_ID/tables" \
-H "Authorization: Bearer $AIRTABLE_TOKEN" \
| jq '.tables[] | {id, name, fields: [.fields[] | {id, name, type}]}'
Commit the resulting map as a typed constant (e.g. fields.ts), not as runtime name lookups:
export const ORDERS = {
table: "tblXXXXXXXXXXXXXX",
f: { orderNo: "fldAAAAAAAAAAAAAA", status: "fldBBBBBBBBBBBBBB", total: "fldCCCCCCCCCCCCCC" },
} as const;
3. Throttled client
Limits: 5 requests/second per base, shared across every token hitting that base (and the webhook API). A 429 means stop for 30 seconds, not retry immediately. There is also a per-token ceiling across bases for PAT traffic (check the current docs). One process-wide limiter per base beats per-request retries.
import Bottleneck from "bottleneck";
const limiter = new Bottleneck({ reservoir: 5, reservoirRefreshAmount: 5, reservoirRefreshInterval: 1000, maxConcurrent: 5 });
export async function at(path: string, init: RequestInit = {}, attempt = 0): Promise<any> {
const res = await limiter.schedule(() =>
fetch(`https://api.airtable.com/v0/${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.AIRTABLE_TOKEN}`, "Content-Type": "application/json", ...init.headers },
}));
if (res.status === 429 && attempt < 3) { await new Promise(r => setTimeout(r, 30_000)); return at(path, init, attempt + 1); }
if (!res.ok) throw new Error(`Airtable ${res.status}: ${await res.text()}`);
return res.json();
}
With several workers, use a shared limiter (Bottleneck with Redis, or a single queue
consumer per base). The official airtable npm package works but does not coordinate across
processes.
4. Reads
async function* listRecords(table: string, params: Record<string, string>) {
let offset: string | undefined;
do {
const q = new URLSearchParams({ ...params, pageSize: "100", returnFieldsByFieldId: "true" });
if (offset) q.set("offset", offset);
const page = await at(`${process.env.AIRTABLE_BASE_ID}/${table}?${q}`);
yield* page.records;
offset = page.offset; // absent on the last page
} while (offset);
}
- Max
pageSizeis 100. Restrict columns with repeatedfields[]=fld...parameters. filterByFormulauses formula syntax:AND({fldStatus}='paid', IS_AFTER(LAST_MODIFIED_TIME(), '2026-10-01')). Escape user input inside formulas (quotes and backslashes) or you have formula injection.- Long formulas or many fields exceed URL limits; use
POST /v0/{base}/{table}/listRecordswith a JSON body instead. view=applies that view’s filters and sort, which a UI user can change. Do not depend on views for correctness in automated jobs.- Offsets expire; restart the listing if you get an iterator error rather than resuming later.
5. Writes
- Create, update, and delete accept at most 10 records per request. Chunk and send chunks through the limiter.
PATCHupdates only the fields you send;PUTclears every field you omit. Default toPATCH.- Upsert by natural key instead of “search then create” (which races and duplicates):
await at(`${base}/${ORDERS.table}`, {
method: "PATCH",
body: JSON.stringify({
performUpsert: { fieldsToMergeOn: [ORDERS.f.orderNo] }, // up to 3 merge fields
returnFieldsByFieldId: true,
records: chunk.map(o => ({ fields: { [ORDERS.f.orderNo]: o.no, [ORDERS.f.status]: o.status, [ORDERS.f.total]: o.total } })),
}),
});
The response lists createdRecords and updatedRecords; store the returned rec... IDs
next to your own key.
typecast: truelets Airtable coerce strings into select options and linked records, and creates new select options on the fly. Leave it off by default so typos fail loudly instead of polluting the option list.- Linked records are arrays of record IDs; attachments are arrays of
{ url }(Airtable downloads them, the URL must be publicly fetchable for a while). Returned attachment URLs are temporary; download and re-host anything you need to keep. - Values: currency and number fields take numbers, dates take ISO 8601, checkboxes omit the field when false on reads (treat missing as false).
6. Change capture
Webhooks (preferred for near-real-time):
POST /v0/bases/{baseId}/webhookswithnotificationUrland aspecification(options.filters.dataTypes: ["tableData"], optionallyrecordChangeScope: "tbl..."). Save the returnedmacSecretBase64as a secret; it is shown once.- Airtable POSTs a small ping (base, webhook ID, timestamp) to your URL. It does not contain changes. Verify it, return 200 quickly, then enqueue a fetch.
- The worker calls
GET /v0/bases/{baseId}/webhooks/{webhookId}/payloads?cursor=N, applies each payload in order, and persists the newcursor; loop whilemightHaveMoreis true. - Webhooks created with PAT or OAuth tokens expire after 7 days. Listing payloads or
POST .../webhooks/{id}/refreshextends them; run a daily refresh job and alert on failure.
Verify the MAC over the raw body (HMAC-SHA256, key = base64-decoded secret, header
X-Airtable-Content-MAC: hmac-sha256=<hex>):
import base64, hashlib, hmac, os
def verify_airtable(raw_body: bytes, header: str) -> bool:
key = base64.b64decode(os.environ["AIRTABLE_WEBHOOK_MAC_SECRET"])
expected = "hmac-sha256=" + hmac.new(key, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
Pings are at-least-once and may coalesce many changes; payload transaction numbers are sequential per webhook, so dedupe on them. Failed pings are retried for about a day, then notifications are disabled; payloads keep accumulating, so re-enable and drain the cursor.
Polling fallback: a Last modified time field (restricted to the fields you sync) plus
filterByFormula on it, with an overlap window of a few minutes. Deletions are invisible to
polling; reconcile with a periodic full ID scan.
7. Schema contract
- On startup or in CI, fetch the schema and assert every field ID in your map exists with the expected type. Fail fast with a readable message when someone deletes or retypes a field.
- Lock critical fields in the UI (field editing permissions) and add a description saying which integration depends on them.
- Add fields freely; never repurpose a field. Migrate by adding a new field, backfilling, switching code, then deleting the old one.
Gotchas
- Formula fields, rollups, and lookups are read-only; writing them returns 422.
- Empty fields are omitted from responses. Code must treat a missing key as empty, not as “unchanged”.
- Single-select writes with an unknown option fail unless
typecastis on. - Record order without
sort[]is not guaranteed. - Primary field values are not unique; enforce uniqueness yourself via
performUpsert. - Large attachment uploads by URL are asynchronous on Airtable’s side; the field may be empty for a moment after the write.
- Sharing a base with a token’s owner removed breaks the token’s access silently: monitor 403s.
Verification
- Run the schema assertion against the real base; rename a field in a test copy of the base and confirm the integration still works (field IDs) and the assertion reports nothing.
- Load test the client with 200 writes: zero 429s, about 20 batched requests, throughput near 5 req/s.
- Upsert the same 10 records twice: second run reports only
updatedRecords, no duplicates. - Send a webhook ping with a modified body: 401. Replay a real ping: one fetch, cursor advances once.
- Confirm the refresh job ran within the last 24 hours (log or metric) so webhooks never hit the 7-day expiry.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.