splitforms.com
All articles/ INTEGRATIONS9 MIN READPublished May 2, 2026

How to Send Form Submissions to Airtable

Sync contact form submissions straight into an Airtable base. Personal access token setup, base and table IDs, field-name gotchas, single vs batch record creation, and a working splitforms-to-Airtable webhook proxy you can copy-paste.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Why use Airtable as a form backend

Airtable is a spreadsheet that thinks it's a database. For small ops teams it's perfect: structured columns, real types (single-select, linked records, attachments), filterable views, and a much friendlier UX than a raw SQL table. If you're running a content marketplace, a job board, a podcast guest pipeline, or anything where every inbound submission needs to be tracked through a few statuses, Airtable + a custom-domain form is the cheapest path that doesn't look amateur.

The catch is that Airtable's built-in form view is hideous and lives at airtable.com — fine for an internal team, awful as a public lead form. The standard fix: keep your form on your own domain, point the action at splitforms, and forward the webhook into Airtable via the API. This guide walks the full path including the proxy you'll deploy.

Step 1 — Create a Personal Access Token

Airtable retired API keys in 2024. The replacement is the Personal Access Token — same idea, scoped per base.

  1. Go to https://airtable.com/create/tokens while signed in.
  2. Click Create new token. Name it "splitforms webhook".
  3. Scopes: tick data.records:write. Add data.records:read if you want to dedupe by querying first. Add schema.bases:read if you want to fetch field metadata at runtime.
  4. Access: pick the base you'll be writing to. Do not grant access to all bases — there's no benefit and a leaked token would expose your whole workspace.
  5. Click Create token and copy the value (starts with pat). You won't see it again.

Step 2 — Find your base ID and table name

The fastest way is the API docs. Go to https://airtable.com/developers/web/api/introduction, click your base in the sidebar — the docs page URL contains the base ID:

https://airtable.com/app1234567890ABCD/api/docs
                       └────── base ID ──────┘

Base IDs always start with app followed by 14 alphanumeric characters. You can also find it in any base URL — the segment after airtable.com/.

The table name is just the human-readable name as displayed in the base — "Leads", "Submissions", etc. URL-encode any spaces. You can also use the table's ID (tbl...) instead, which is more stable if you rename tables.

# Base URL pattern
https://api.airtable.com/v0/{baseId}/{tableNameOrId}

# Concrete example
https://api.airtable.com/v0/appAbC123XyZ456D/Leads
https://api.airtable.com/v0/appAbC123XyZ456D/tbl9oZ2pQ4rT8wY1

Step 3 — The Airtable fields object

Airtable's API takes a fields object where keys are column names (exact case, exact spelling, including spaces) and values are typed primitives. The mappings you actually need:

{
  "fields": {
    "Name":       "Ada Lovelace",            // single-line text
    "Email":      "ada@example.com",         // email
    "Message":    "Hi, testing.",            // long text
    "Status":     "New",                     // single-select (must be an existing option)
    "Tags":       ["demo", "enterprise"],    // multi-select
    "Submitted":  "2026-05-02T12:00:00.000Z",// date / datetime (ISO 8601)
    "Subscribed": true,                      // checkbox
    "Score":      42,                        // number
    "Website":    "https://example.com",     // url
    "Phone":      "+1-555-0100",             // phone
    "Resume":     [{ "url": "https://splitforms-cdn.com/uploads/abc.pdf" }]
                                             // attachment — array, must use { url: ... }
  }
}

Two gotchas burn everyone the first time:

  1. Single-select options must exist. If your "Status" column has options [New, Qualified, Closed] and you POST "Status": "new" (lowercase), you get a 422. To auto-create new options, set typecast: true at the top level of the request.
  2. Linked-record fields take record IDs, not text. If "Source" links to a Campaigns table, you need "Source": ["recAbc123..."], not "Source": "Black Friday". Either look up the record ID first or use typecast: true to create-or-link by name.

Step 4 — The splitforms → Airtable proxy

splitforms can POST to any HTTPS endpoint, but the Airtable API needs a Bearer token in the Authorization header — and you don't want that token visible in your splitforms config. Drop a stateless proxy in front. Cloudflare Workers, Vercel Edge, Deno Deploy, or any Node/Bun server work; here's the Worker version (~40 lines):

export default {
  async fetch(req, env) {
    if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });

    const submission = await req.json();
    const f = submission.fields ?? submission.data ?? {};

    const fields = {
      Name:      f.name,
      Email:     f.email,
      Message:   f.message,
      Status:    "New",
      Submitted: submission.submitted_at || new Date().toISOString(),
      Source:    f.source,
    };

    // Strip undefined / empty so Airtable doesn't reject the column.
    Object.keys(fields).forEach(
      (k) => (fields[k] === undefined || fields[k] === "") && delete fields[k]
    );

    const url = `https://api.airtable.com/v0/${env.AIRTABLE_BASE_ID}/${encodeURIComponent(env.AIRTABLE_TABLE)}`;

    const res = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.AIRTABLE_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ fields, typecast: true }),
    });

    if (!res.ok) {
      const err = await res.text();
      console.error("Airtable error", res.status, err);
      return new Response(err, { status: 502 });
    }

    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

Set the secrets:

wrangler secret put AIRTABLE_TOKEN     # paste pat...
wrangler secret put AIRTABLE_BASE_ID   # paste app...
wrangler secret put AIRTABLE_TABLE     # e.g. "Leads"

wrangler deploy, copy the resulting URL, and paste it into your splitforms dashboard under the form's Webhooks → Add → Generic JSON. typecast: truetells Airtable to coerce strings into select options or linked records by name — so "New" gets created if it doesn't exist, and a campaign name gets linked-or-created instead of throwing.

Test from your terminal:

curl -X POST https://splitforms-airtable.your.workers.dev \
  -H "Content-Type: application/json" \
  -d '{
    "submitted_at": "2026-05-02T12:00:00Z",
    "fields": {
      "name":    "Ada Lovelace",
      "email":   "ada@example.com",
      "message": "Hi — testing.",
      "source":  "Black Friday"
    }
  }'

You should see {"ok":true}in 300-500ms and a new row in your Leads table. Most failures are field-name typos — Airtable's case-sensitivity is unforgiving. Use the API docs page for your base to copy field names exactly.

Single-record vs batch creation

The proxy above creates one record per submission, which is correct for a contact form. If you're importing a backlog or you have a high-traffic form (think: lead-gen funnel pushing 100 submissions/minute), Airtable's batch endpoint takes up to 10 records per request and counts as one against the rate limit.

POST /v0/{baseId}/{tableName}
Authorization: Bearer pat...
Content-Type: application/json

{
  "records": [
    { "fields": { "Name": "Ada", "Email": "ada@example.com" } },
    { "fields": { "Name": "Bob", "Email": "bob@example.com" } }
  ],
  "typecast": true
}

The 5 req/sec rate limit is per base, not per token. Hitting it returns a 429 with a Retry-Afterheader — splitforms' webhook delivery layer handles that automatically with exponential backoff, but if you're calling Airtable directly from your code, queue and respect the header.

The no-code alternative: Make / Zapier

If you don't want to deploy a proxy, the same flow works through Make.com or Zapier:

  • Trigger: "Webhooks" in Make, or "Webhooks by Zapier" — both give you a custom inbound URL you paste into splitforms.
  • Action: "Airtable → Create a Record". Map the splitforms fields onto the Airtable columns through the visual mapper.
  • Cost: Make's free tier (1,000 ops/mo) handles small forms. Zapier requires the $20/mo Starter plan for multi-step or filter logic.

The trade-off vs the proxy: Make and Zapier add 2-15 seconds of latency, can fail silently when their queue backs up, and become a recurring bill. The 40-line Worker is free up to 100k requests/day and runs in ~30ms. For one-off forms either path is fine; for anything mission-critical, write the proxy. The full webhook signature spec, retry behavior, and HMAC verification are in the splitforms docs.

And if you don't already have a contact form, grab a copy-paste starter from splitforms templates — works with React, Vue, plain HTML, and any framework that can render a <form>. Pair this Airtable wiring with the rest of your contact form stackand you've replaced a $24/user/mo Airtable Pro subscription with a free splitforms form, a free Worker, and a free Airtable base.

Tech support and troubleshooting

Five errors account for almost every Airtable webhook failure we see in the wild. Quick fixes:

  • 422 INVALID_VALUE_FOR_COLUMNField name casing or the value type doesn't match. Copy the exact column header from the API docs page and re-check single-select option strings.
  • 401 AUTHENTICATION_REQUIREDPersonal Access Token expired or wasn't granted access to that base. Generate a new PAT and re-scope it to the target base only.
  • 429 rate-limit hitAirtable caps each base at 5 req/sec. Switch the proxy to batch mode (10 records per call) or let splitforms' built-in retry queue back off.
  • Attachments uploaded as broken URLsAirtable downloads attachment URLs server-side. The CDN link must be public and reachable; signed URLs that expire in seconds will not import.
  • Linked-record cell stays blankLinked fields require an array of recXXX IDs, not text. Either pre-fetch the record ID or pass typecast: true so Airtable can match-or-create by name.

Still stuck? The full webhook payload schema, HMAC signature header, and retry timing are in the splitforms docs, and the raw endpoint reference is in the API reference. For account or billing questions, see splitforms FAQ.

FAQ

What's wrong with Airtable's own forms?

Nothing, if you don't care about your own brand. Airtable forms force visitors onto an airtable.com URL, ship with an Airtable logo unless you pay for the Pro plan ($24/user/mo), can't be styled beyond a banner image and a color, and don't support custom domains. For a real product page, an inline form on your own domain that POSTs to splitforms and forwards to Airtable looks 10x more credible and converts noticeably better.

Do I need a paid Airtable plan to use the API?

No. The Airtable API is on every plan including Free. Free workspaces are capped at 1,000 records per base, which is the real ceiling — at scale you'll need Team ($20/user/mo, 50,000 records) or higher. For most contact forms 1,000 records is enough for a year, especially if you archive old leads.

Personal access token vs OAuth — which one?

Use a Personal Access Token for any setup where you control the destination base. PATs are simpler (one token, no refresh dance), they support fine-grained scopes (data.records:write only), and you can scope them to a single base. OAuth is only the right call if you're shipping a multi-tenant product where end users connect their own Airtable.

Why does my request get a 422 INVALID_VALUE_FOR_COLUMN error?

Airtable field names in the API request are case-sensitive and must match the column name exactly — including spaces. 'email' will fail if the column is 'Email'. Single-select fields require the option name verbatim ('New', not 'new'). Linked-record fields take an array of record IDs (recXXX...), not strings. The API error message tells you which column choked, but the underlying cause is almost always one of these three.

Can I send file uploads to an Airtable attachment field?

Yes, but the API doesn't accept binary uploads — you have to give Airtable a public URL it can fetch. splitforms hosts uploaded files at a CDN URL that's included in the webhook payload; pass that URL into the attachment field as { url: "https://..." }. Airtable will download and re-host the file within a few seconds.

How fast is the Airtable API?

Single-record creates land in 200-500ms typically. The hard limit is 5 requests per second per base. If you have a high-traffic form, batch up to 10 records per request — that's still one API call, well within the rate limit, and gives you ~5,000 submissions/sec of headroom on paper.

Can I dedupe submissions by email automatically?

The Airtable API doesn't support upsert as a single operation on every plan, but you can simulate it: GET /records?filterByFormula=({Email}='ada@example.com'), then PATCH the existing record if found, or POST a new one if not. The proxy code below shows the pattern. Heads up — filterByFormula has a 16,000-character limit, which matters at scale but never for a single lookup.

Should I use Make or Zapier instead?

If you don't want to write code, yes. Make.com's Airtable module is the cheapest at $9/mo for 10,000 ops; Zapier's Airtable integration is more polished but costs $20+/mo. The trade-off is one more vendor in the chain and a few seconds of latency vs the proxy below, which runs in 30ms on Cloudflare's free tier.

Next steps

You now have a typed Airtable pipeline behind your form. From here, the highest-leverage follow-ups:

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open