TL;DR:
wpcf7_mail_senthands you the form object, not the data — read values withWPCF7_Submission::get_instance()->get_posted_data().- Airtable creates records at
POST https://api.airtable.com/v0/{baseId}/{tableIdOrName}with a personal access token as a bearer credential. - Every key you send must already exist as a field in the table. Airtable rejects the whole request otherwise — it never creates columns for you.
- The hard ceilings: 10 records per request, 5 requests per second per base, and a 30-second wait after a 429.
- Sending inline makes the visitor wait for Airtable and loses the entry if it is down. Queue it.
/ Overview
How do you send Contact Form 7 entries to Airtable?
Hook wpcf7_mail_sent, read the submitted values out of WPCF7_Submission, map each form-tag name onto the matching Airtable field name, and POST the result to Airtable's create-records endpoint with a personal access token. There is no official Contact Form 7 add-on for this and no Airtable-side listener — the whole integration is one hook and one HTTP request.
The part that catches people is not the request. It is that Airtable will not invent columns. A form tag called your-message does not become a field called your-message; if that field is not already in the table, the API answers 422 and nothing is stored. The mapping step is the integration.
/ The hook
Which Contact Form 7 hook gives you the submitted data?
wpcf7_mail_sent fires after the mail component has successfully sent, and it receives one argument — the WPCF7_ContactForm object. That object describes the form, not the submission, which is why reading $contact_form for field values returns nothing useful.
The submitted values live on the submission singleton instead. We cover the hook itself in depth in the wpcf7_mail_sent reference; the short version is below, verified against Contact Form 7 6.1.6.¹
PHP — reading the submission
add_action( 'wpcf7_mail_sent', function( $contact_form ) { $submission = WPCF7_Submission::get_instance(); if ( ! $submission ) { return; // no submission in scope — nothing to send } $data = $submission->get_posted_data(); // $data is a flat array keyed by form-tag name: // [ 'your-name' => 'Ada', 'your-email' => '[email protected]', ... ] // Only act on the form you mean — get_posted_data() is shared. if ( 42 !== $contact_form->id() ) { return; } my_queue_airtable_record( $data ); } );
Two details matter. get_posted_data() returns values that Contact Form 7 has already sanitised, but it returns every field including hidden ones and the CF7 internals — pass through only the keys you mean. And the callback runs for every form on the site, so gate on $contact_form->id() unless you genuinely want all of them.
/ The request
What does the Airtable create-records request look like?
One POST, one bearer token, one JSON body. The endpoint is https://api.airtable.com/v0/{baseId}/{tableIdOrName} — the base ID starts with app, and the table segment accepts either the table ID (starting tbl) or its display name, URL-encoded.²
PHP — creating the record
function my_send_to_airtable( array $fields ) { $base = 'appXXXXXXXXXXXXXX'; $table = rawurlencode( 'Leads' ); $response = wp_remote_post( "https://api.airtable.com/v0/{$base}/{$table}", [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . MY_AIRTABLE_PAT, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'records' => [ [ 'fields' => $fields ] ], 'typecast' => true, ] ), ] ); if ( is_wp_error( $response ) ) { return $response; // transport failure — retry this one } $code = wp_remote_retrieve_response_code( $response ); return 200 === $code ? true : $code; }
typecast: true is the flag worth understanding. With it off — the default — Airtable requires values in exactly the right shape, so posting the string "3" to a number field fails. With it on, Airtable does "best-effort automatic data conversion from string values", which is what you want when the source is a web form, because every value from an HTML form arrives as a string. It will also create new select options on the fly rather than rejecting them.
/ Mapping
How do you map form-tag names onto Airtable fields?
Explicitly, in one array, and nowhere else. The temptation is to name the CF7 form tags after the Airtable columns and pass the array straight through. That breaks the first time somebody renames a column in Airtable or adds a field to the form, and it fails silently in the direction that loses data.
PHP — an explicit map
// form-tag name => Airtable field name $map = [ 'your-name' => 'Name', 'your-email' => 'Email', 'your-subject' => 'Subject', 'your-message' => 'Message', ]; $fields = []; foreach ( $map as $tag => $column ) { if ( ! isset( $data[ $tag ] ) ) { continue; } $value = $data[ $tag ]; // CF7 gives arrays for checkboxes and multi-selects $fields[ $column ] = is_array( $value ) ? implode( ', ', $value ) : (string) $value; } $fields['Submitted'] = current_time( 'c' ); // ISO 8601 with offset
Checkbox and multi-select tags come back as PHP arrays. Airtable will accept an array for a multiple-select field, but a plain text field wants a string — flattening with implode() is the safe default until you know the column type.
Airtable never creates a column for you. If the field name in your payload is not already in the table, the entire record is rejected — not the field.
/ Limits
What are Airtable's real limits?
These are the numbers that decide your architecture, all from Airtable's own API reference.³
| Limit | Value | What happens at the boundary |
|---|---|---|
| Records per create request | 10 | Request rejected — split into batches of 10 |
| Requests per second, per base | 5 | 429 returned |
| Requests per second, per token | 50 | 429 returned, across every base |
| Wait after a 429 | 30 seconds | Earlier retries keep failing |
| Field must already exist | always | 422 — the whole record is dropped |
Run the arithmetic before you decide this is generous. Five requests per second per base is 5 × 60 = 300 records a minute if you send one record per request, or 3,000 a minute batched at 10. A contact form will never approach that. A WooCommerce store replaying 8,000 historical orders will hit it in the first 30 seconds — 8,000 ÷ 10 = 800 requests, and at 5/s that is 160 seconds of sustained traffic with no headroom for anything else touching the same base.
/ Gaps
What does Airtable not protect you from?
The API is well behaved. The gaps are all on your side of the wire:
- No idempotency key. There is no request header that says "this is the same record as before". Post twice and you get two records. Any retry you write must decide for itself whether the previous attempt landed — the cheapest fix is a deterministic key column you can search before inserting.
- The token is a bearer credential with broad reach. A personal access token scoped to a base can read every table in it, not just the one you write to. Scope it to the single base, give it only
data.records:write, and keep it out of the database and out of version control. - A 200 is not a validated record. With
typecaston, a malformed date can be coerced into something Airtable accepts but you did not mean. Validate before sending, not after. - Schema drift is silent until it is fatal. Nobody tells WordPress that a column was renamed. The first sign is a run of 422s in a log you are probably not reading.
/ Delivery
Should the request run inline or on a queue?
Queue it. The inline version — calling Airtable directly inside wpcf7_mail_sent — ties the visitor's thank-you message to a third party being fast and reachable. With 'timeout' => 15, an Airtable incident adds up to 15 seconds to a form submission, and if the request fails there is no second attempt: the entry is gone, because the only copy was in a PHP variable.
This is the same failure shape we walked through in why WordPress webhooks silently fail in production. The fix is not a longer timeout, it is moving the send off the request.
| Concern | Inline wp_remote_post | Queued delivery |
|---|---|---|
| Visitor wait | Blocks until Airtable answers | Returns immediately |
| Airtable down | Entry lost — no copy kept | Stays queued, retried later |
| 429 handling | One attempt, then gone | Backs off and retries |
| Evidence | Nothing unless you log it | Per-attempt request and response log |
/ Retries
How do you handle a 429 without losing the entry?
Treat 429 and 5xx as retryable and everything else as final. Airtable is explicit that after a rate-limit breach you must wait 30 seconds before requests succeed again, so a retry one second later is guaranteed to fail and burns an attempt.
A 422 is the opposite case: the field name is wrong or the value is invalid, and retrying the identical payload will fail identically forever. Send it to a dead-letter list a human reads. The general shape of a backoff that behaves — and why capping it matters — is covered in the retry policy and exponential backoff write-up.
Try it without writing the plumbing. If you would rather map the fields in an admin screen than maintain the hook, open the live preview — a full WordPress with Webhook Actions already installed, no signup and nothing to install — or install the free plugin.
do_action( 'wpcf7_mail_sent', $contact_form ) and the get_posted_data() / get_instance() methods verified directly in includes/submission.php of Contact Form 7 6.1.6. First-party documentation: contactform7.com/docs.typecast option: Airtable create records API.