+1 (415) 612-6492

Airtable Webhooks: Event-Driven Integrations Without Polling

Most Airtable integrations we inherit are polling loops. A script wakes up every five minutes, pulls every record modified since last time, and does something with the ones that changed. It works, until the base grows, the rate limit bites, and someone asks why the CRM took eleven minutes to hear about a closed deal.

The alternative is Airtable's webhooks API: you register interest in a base, Airtable pings your endpoint when something changes, and you fetch a compact list of exactly what changed. This tutorial walks the full loop, including the parts that trip people up in production — cursors, expiry, and the fact that the ping itself contains no data.

First: do you actually need the webhooks API?

Be honest about the requirement before you build a service. Three options, in increasing order of effort:

  1. A native automation with a "Send webhook" / "Send request" action. Trigger on record created or record matches conditions, POST a JSON body to your endpoint. No infrastructure, no cursors, no expiry. For "tell my system when a record hits Approved", this is the right answer and you should stop reading here.
  2. A sync or a scheduled job. If your consumer only needs data every hour and nobody cares about latency, a scheduled run against a filtered view is cheaper to operate than anything event-driven.
  3. The webhooks API. Use it when you need all changes in a base or table, including field-level edits, deletions, and schema changes — not just records crossing one condition. Data warehouse sync, audit logging, mirroring a base into another system, and "react to any edit" use cases live here.

The webhooks API is the only one that reliably tells you a record was deleted. That alone decides a lot of architectures.

The model in one paragraph

You create a webhook on a base with a specification describing what you care about. Airtable stores changes in a payload log for that webhook. When something happens, Airtable sends a small notification POST to your URL — it contains the webhook ID and a base ID, not the data. Your service then calls the API to list payloads from your last cursor, processes them, and stores the new cursor. Payloads are retained for a limited window (seven days) and the webhook itself expires after seven days unless it is refreshed or is receiving activity, so a long-lived integration must renew it.

Step 1: create the webhook

Authenticate with a personal access token or OAuth token that has webhook:manage scope plus read access to the base. (See our Airtable API quickstart if you have not set tokens up yet.)

curl -X POST "https://api.airtable.com/v0/bases/appXXXXXXXXXXXXXX/webhooks" \
  -H "Authorization: Bearer $AIRTABLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "notificationUrl": "https://hooks.example.com/airtable",
    "specification": {
      "options": {
        "filters": {
          "dataTypes": ["tableData"],
          "recordChangeScope": "tblYYYYYYYYYYYYYY"
        }
      }
    }
  }'

The response contains id, macSecretBase64, and an expiry timestamp. Store the MAC secret immediately — it is returned once, at creation, and you need it to verify notifications.

Things worth setting deliberately in the specification:

  • dataTypes: tableData for record changes, tableFields for field/schema changes, tableMetadata for table renames and view changes. Ask for only what you will process.
  • recordChangeScope: pin the webhook to one table. Without it you get the whole base, which is usually far more traffic than you want.
  • changeTypes: restrict to add, remove, update as needed.
  • watchDataInFieldIds / includeCellValuesInFieldIds: watch a small set of fields, and ask for the cell values you need inline so you do not have to re-fetch every record.

That last pair is the single biggest efficiency lever. A webhook that watches one status field and returns that field's value gives you a payload you can act on directly; a wide-open webhook gives you a change list you then have to hydrate with extra API calls.

Step 2: receive the notification (and verify it)

Airtable POSTs a small JSON body to your notification URL:

{
  "base": { "id": "appXXXXXXXXXXXXXX" },
  "webhook": { "id": "achXXXXXXXXXXXXXX" },
  "timestamp": "2026-02-11T09:12:44.000Z"
}

Every request carries an X-Airtable-Content-MAC header: an HMAC-SHA256 of the raw request body, keyed by the base64-decoded MAC secret, formatted as hmac-sha256=<hex>. Verify it before doing anything else, and verify against the raw bytes — if your framework parses and re-serialises JSON first, the signature will not match.

import crypto from "node:crypto";

function verify(rawBody, header, macSecretBase64) {
  const key = Buffer.from(macSecretBase64, "base64");
  const expected =
    "hmac-sha256=" +
    crypto.createHmac("sha256", key).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Then respond 200 immediately and do the work asynchronously. Airtable does not wait for your processing; slow or failing endpoints get notifications dropped, and repeated failures can disable notification delivery for the webhook. Push a job onto a queue and return.

Because notifications are hints rather than data, duplicates and occasional missing pings are survivable: your cursor is the source of truth, not the ping.

Step 3: fetch payloads with a cursor

On receiving a notification, list payloads starting from the cursor you stored last time:

curl "https://api.airtable.com/v0/bases/appXXXXXXXXXXXXXX/webhooks/achXXXXXXXXXXXXXX/payloads?cursor=$CURSOR" \
  -H "Authorization: Bearer $AIRTABLE_TOKEN"

The response gives payloads, a new cursor, and mightHaveMore. Loop while mightHaveMore is true, feeding the returned cursor back in.

Each payload describes changes in a shape like:

{
  "timestamp": "2026-02-11T09:12:43.000Z",
  "baseTransactionNumber": 4831,
  "actionMetadata": { "source": "client" },
  "changedTablesById": {
    "tblYYYYYYYYYYYYYY": {
      "changedRecordsById": {
        "recZZZZZZZZZZZZZZ": {
          "current": { "cellValuesByFieldId": { "fldAAA": "Approved" } },
          "previous": { "cellValuesByFieldId": { "fldAAA": "In review" } }
        }
      },
      "createdRecordsById": {},
      "destroyedRecordIds": []
    }
  }
}

Four habits that keep this sane:

  • Persist the cursor after successful processing, not before. If your worker dies mid-batch, you want to re-read and re-apply rather than skip.
  • Make processing idempotent. Use the record ID plus baseTransactionNumber as a dedupe key. You will replay.
  • Everything is keyed by field ID, not field name. That is a feature — renaming a field will not break you — but cache the table schema so you can map IDs to names, and refresh that cache when a tableFields payload arrives.
  • Check actionMetadata.source. It tells you whether the change came from a user, an automation, the API, or a sync. Filtering out your own writes prevents integration loops where your system's update triggers a notification that triggers another update.

Step 4: keep the webhook alive

Webhooks expire after seven days. Notification delivery can also be disabled after sustained endpoint failures. Neither is loud — the symptom is simply that updates stop arriving, usually on a Saturday.

Build three things before you go live:

  1. A daily refresh job calling POST /bases/{baseId}/webhooks/{webhookId}/refresh to push the expiry out, and re-enabling notifications if they were turned off.
  2. A reconciliation job. Once a day, do a plain list records pass filtered on last-modified time and compare against what you have. Webhooks are for latency; reconciliation is for correctness. Every serious integration we run has both.
  3. A staleness alarm. If no payload has been processed in N hours during business hours, alert. Silence is the failure mode.

Also remember the per-base webhook limit — there is a cap on how many webhooks can exist on a base — and clean up webhooks created during testing. Orphaned webhooks pointing at dead ngrok tunnels are a classic source of "we hit the limit and can't create the real one".

Step 5: local development

You cannot register localhost. Use a tunnel (ngrok, Cloudflare Tunnel) for the notification URL during development, and remember to delete the webhook when the tunnel dies. A cheap trick while building: skip notifications entirely and poll the payloads endpoint manually with your cursor. The payload format is identical, so you can develop and test all your processing logic before wiring up delivery at all.

Common failure patterns

Treating the notification as the data. The ping has no record in it. If your handler tries to read body.record, you built against a different product's docs.

Signature checks on parsed JSON. Capture the raw body. Express users: express.raw({ type: "application/json" }) on that route only.

No dedupe. Retries and replays are normal. If your handler sends an email per payload, someone gets forty emails.

Watching everything. A base-wide webhook on a busy base produces enormous payload volume and forces you to filter server-side. Scope it to a table and a field list.

Feedback loops. Your service writes back to Airtable, that write generates a payload, your service processes it and writes again. Filter on actionMetadata.source, or mark your own writes with a field you exclude from watchDataInFieldIds.

A quick decision table

RequirementUse
Notify an external system when a record hits a statusNative automation, send-request action
Hourly export to a warehouseScheduled job over a view
Mirror every change, including deletesWebhooks API
Audit trail of who changed whatWebhooks API with includeCellValuesInFieldIds
Two-way sync with another systemWebhooks API plus reconciliation, and a lot of care

Where this fits

Webhooks are the backbone of any Airtable integration that has to be both fast and complete. They are also more operational work than most teams expect: a queue, a cursor store, a refresh job, and a reconciliation pass. If the payoff is only saving five minutes of latency, use an automation instead.

BaseBrainers builds and operates event-driven Airtable integrations as part of our Airtable API integration work, and we are frequently called in when a polling integration has outgrown itself. If you are staring at a rate-limited cron job wondering how to make it real-time, tell us about it.