TL;DR: Writing WordPress data into Airtable is a small request with a monthly budget most tutorials never mention.
- Authenticate with a personal access token that has the
data.records:writescope and the base added as a resource. Legacy API keys stopped working on 1 February 2024. - Cell values go inside a
fieldsobject. UseperformUpsertwith a merge field so a retried delivery updates the row instead of duplicating it. - The rate limit is 5 requests per second per base, and a 429 costs you a 30-second wait.
- The limit that actually ends integrations is monthly: 1,000 API calls on Free, 100,000 on Team, per workspace.
/ Direction
What does a WordPress Airtable integration actually mean?
Two different jobs share the phrase, and they need different tools. Airtable into WordPress treats a base as a content source: rows become posts, products or directory listings. That is what sync plugins such as Air WP Sync do, on a schedule, by reading the base.¹
WordPress into Airtable is the reverse and the subject here: something happens on the site, an order, a registration, a published post, a form entry, and a row should appear in a base where the rest of the team works. No polling is involved. WordPress fires a hook, and the integration turns that hook into one HTTP request. If the source is a specific form plugin, the Contact Form 7 to Airtable walkthrough covers the form-side details; this article is about the Airtable side, which is the same whatever fired the event.
/ Auth
How do you authenticate to the Airtable API from WordPress?
With a personal access token in a bearer header. Airtable's authentication reference is blunt about the old way: the deprecation period for API keys ended on 1 February 2024, and passing a token through the legacy api_key URL parameter is not supported. Any tutorial that shows ?api_key= predates that and no longer works.
A token needs two things configured separately, and forgetting the second produces a confusing failure. The scope says what the token may do; creating and updating rows needs data.records:write. The resource says where; the base has to be added to the token explicitly. A token with the right scope and no base attached authenticates fine and then cannot see your base.
The token also acts as you. Airtable describes personal access tokens as acting as the user account that created them, limited by scope and resource, and that user needs editor access to the base. OAuth exists for products where other people connect their own Airtable accounts; for your site writing to your base, a personal access token is the intended path, and on Enterprise plans a service account keeps it from depending on one employee.
/ Request
What does the create-records request look like?
A POST to https://api.airtable.com/v0/{baseId}/{tableIdOrName}, with the cell values nested one level down. The create records reference accepts a single record as a top-level fields object, or up to 10 at once in a records array. Sending the values at the top level of the body, which is what a generic webhook produces by default, returns a 422.
Use the table id, tbl…, rather than its name. Both work, and Airtable recommends ids for the reason you would expect: someone renames the table in the UI and a name-based URL starts returning 404 with no change on your side. The same applies to field names, which you can swap for field ids once the integration is stable.
JSON — one record, values inside the fields object
{ "fields": { "Email": "[email protected]", "Name": "Anna Kowalska", "Order total": 129.5, "Source": "woocommerce" }, "typecast": true }
typecast is off by default, and the default is the right one for data you control. With it on, Airtable makes a best-effort conversion from strings, which includes creating a new single-select option when the value does not match an existing one. That is convenient for a status field fed by a form and a slow way to collect "Pending", "pending" and "PENDING" as three options. Turn it on knowingly. Numbers are the other trap: a number field rejects "129.50" as a string without typecast, so cast on the WordPress side.
/ Upsert
How do you stop retries from creating duplicate rows?
With performUpsert. Create is not idempotent: deliver the same event twice, because a timeout hid a success, and you get two rows. The update multiple records endpoint, a PATCH on the same table URL, has an upsert mode that closes that gap. You name one to three fields in fieldsToMergeOn, and Airtable uses them as an external id:
- no match: a record is created;
- one match: that record is updated;
- more than one match: the request fails.
The third case is the one to plan for. Upsert does not clean up duplicates that already exist; it refuses to choose between them. De-duplicate the merge field before you switch an existing base over. The merge fields cannot be computed fields, so no formulas, lookups or rollups, and must be a number, text, long text, single select, multiple select or date. An order id or a lowercase email in a plain text field is the usual choice.
PHP — upsert an order row, keyed on the order id
function at_upsert_order( $order ) { $url = 'https://api.airtable.com/v0/' . AIRTABLE_BASE_ID . '/' . AIRTABLE_TABLE_ID; $res = wp_remote_request( $url, [ 'method' => 'PATCH', // PATCH leaves unsent fields alone. PUT clears them. 'timeout' => 10, 'headers' => [ 'Authorization' => 'Bearer ' . AIRTABLE_PAT, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'performUpsert' => [ 'fieldsToMergeOn' => [ 'Order ID' ] ], 'records' => [ [ 'fields' => [ 'Order ID' => (string) $order->get_id(), 'Email' => strtolower( $order->get_billing_email() ), 'Order total' => (float) $order->get_total(), 'Status' => $order->get_status(), ], ] ], ] ), ] ); if ( is_wp_error( $res ) ) { return $res; } $code = wp_remote_retrieve_response_code( $res ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); // createdRecords / updatedRecords tell you which branch Airtable took. return [ 'code' => $code, 'created' => $body['createdRecords'] ?? [], 'updated' => $body['updatedRecords'] ?? [] ]; }
Mind the verb. On this endpoint PATCH updates only the fields you send, while PUT performs what Airtable calls a destructive update and clears every cell you left out. An integration that sends four fields with PUT will blank the notes column your colleague filled in by hand.
/ Limits
What are Airtable's API limits, and which one will you hit?
There are two, and the one in the API reference is not the one that ends integrations.
The rate limit is 5 requests per second per base, plus 50 per second across all traffic from one user's tokens. Exceed it and you receive a 429 and must wait 30 seconds before requests succeed again. For event-driven writes that is generous: one row per event at 5 a second is 300 a minute.
The monthly cap is the real constraint. Airtable's API call limits are set per workspace plan: 1,000 calls a month on Free and 100,000 on Team.² Put a real site against the Free number:
- 1,000 ÷ 30 days = 33 calls a day.
- A store with 40 orders a day, one upsert each, plus a status update when each order completes: 40 × 2 = 80 calls a day × 30 = 2,400 a month, 2.4 times the allowance.
- The same store on Team: 2,400 ÷ 100,000 = 2.4% of the budget.
What happens at the cap differs by plan, and the Free behaviour is the dangerous one. The first time a Free workspace goes over, a 30-day grace period starts and the API keeps working. That grace period is available once. After it, calls over the limit are blocked until the month resets on the first day of the calendar month. So the integration works through launch, works through the grace month, and then stops for the last third of some later month with nothing changed on your side. On Team, going over slows calls to 2 requests per second instead of blocking them.
Batching is the lever. One request can carry 10 records and still counts as one call, so a worker that drains a queue every few minutes and writes rows in tens turns 2,400 calls into a few hundred.
| Concern | Hand-rolled wp_remote_request | Webhook Actions |
|---|---|---|
| Reading Airtable back into WordPress | A different job; use a sync plugin | Not what it does. It sends events out; it does not import rows |
| Batching ten rows per call | Possible, if you build the buffer | Not available. One event is one request, so the monthly budget is spent one call per event |
| The fields envelope | Written in your array | Field mapping with dotted targets such as fields.Email, no code |
| performUpsert and other constants | Written in your array | A short pre-dispatch snippet; mapping moves values and cannot invent one |
| The token | A constant in wp-config.php | An encrypted Bearer credential, redacted in every log |
| A 429 | Lost unless you wrote retry logic | Retried; the first retry lands after a minute, outside the 30-second penalty |
| A 422 from a renamed field | Invisible until someone notices missing rows | Marked permanently failed with Airtable's error body in the delivery log, replayable once fixed |
Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.
/ Failure
Should the Airtable request run inside the WordPress request?
No, and the 30-second penalty is the reason it matters more here than with most APIs. An inline call that hits a 429 has two options, both bad: fail and lose the row, or sleep and hold a PHP worker, and the visitor, for half a minute. Record the event when the hook fires, return immediately, and let a background worker write to Airtable. The upsert makes the retry safe, so the worker can be simple.
Sort the failures before you retry them. A 429 and a 5xx are temporary; wait and resend. A 422 is a payload Airtable will reject identically forever, a 401 is a revoked token, and a 403 is a token without the base attached. Retrying those spends calls from a monthly budget and changes nothing. Whatever you build, keep the response body: Airtable's error objects name the field and the reason, and that one line is the difference between a two-minute fix and an integration that failed silently for weeks.
/ Exposure
What does Airtable not protect you from?
Schema drift by a colleague. The base is a spreadsheet to everyone else. Renaming a field or changing its type is one click, and your next request returns 422. Field and table ids survive renames; type changes do not, so log the response body and alert on the first 422 rather than the hundredth.
A public form is a write path into your base. If an unauthenticated form triggers the request, a bot can spend your monthly call budget in an afternoon, and on Free that also burns the one grace period you will ever get. Rate-limit and spam-filter before the event is queued, not after.
The token outlives its purpose. A personal access token has no expiry you did not set. Scope it to the one base, give it only data.records:write if the site never reads, and keep it out of the theme and out of the repository.
Record limits are separate. The call budget governs requests; each plan also caps records per base. An integration that only ever appends will reach that ceiling eventually, so decide early whether old rows are archived, and where.
The same event can feed other destinations with the same shape of request: Google Sheets for a flat log, Notion for a team wiki, or a published build that records newly published posts into Airtable without writing any of the code above.