# Send form submissions to Airtable

> Add a row to an Airtable table for every Ikiform submission, through a small relay that verifies the signature and calls the Airtable Web API.

Published 2026-10-23 · Guide, Airtable · https://ikiform.com/blog/form-submissions-to-airtable

Airtable is a good home for submissions you want to sort, filter and build views on. This guide adds one record per Ikiform submission, with each form field in its own column.

Diagram: How a submission gets from your form to Airtable

1. A visitor submits your form, and it posts to Ikiform.
2. Ikiform stores the submission and sends a signed webhook to your relay.
3. The relay checks the signature, maps fields to columns and creates a record with the Airtable Web API.
4. Airtable answers the relay, and the relay answers Ikiform.

The relay checks Ikiform's signature, then creates a record through the Airtable Web API.

## Two ways to connect

|   | Relay and Web API | Automation webhook trigger |
| --- | --- | --- |
| Checks Ikiform's signature | Yes | No. Anyone with the URL can add rows |
| Maps nested fields | Yes, in code | Limited. Ikiform's fields sit under `data.fields` |
| Size limit | Ikiform's 256 KB | 100 KB per request |
| Counts against automation runs | No | Yes |

This guide uses the relay. The automation option is covered [at the end](#automation-trigger), with its trade-offs.

## Before you start

- An Ikiform form that already receives submissions.
- An Airtable base where you're at least an editor.
- Somewhere to run the relay: a Cloudflare account with a domain on it, or a Next.js app on Vercel or any other host.

## What Ikiform sends

Every delivery is a `POST` with a JSON body. The body has the same shape for every endpoint, and you can't change it. A submission looks like this:

`submission.created`:

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-23T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-23T12:00:00.000Z",
    "truncated": false
  }
}
```

The form's values are in `data.fields`, keyed by the input `name`. A test from the dashboard sends an `endpoint.test` event instead, with `data.endpoint_id` and `data.message` and no fields. The full reference is on [Webhooks](https://ikiform.com/docs/webhooks.md#payloads).

## Set it up

### Step 1: Create the table

Create a table called `Submissions` with these fields. Names must match exactly.

| Field | Type | Filled with |
| --- | --- | --- |
| `Submission ID` | Single line text | `data.submission_id` |
| `Form` | Single line text | The form's name |
| `Submitted` | Date, with time | `data.created_at` |
| `Name` | Single line text | The `name` field |
| `Email` | Email | The `email` field |
| `Message` | Long text | The `message` field |

The relay only sends columns listed in its `COLUMNS` map, because Airtable refuses unknown field names. Add a column in Airtable, then add a line to the map.

### Step 2: Create a personal access token

1. Open [airtable.com/create/tokens](https://airtable.com/create/tokens) and choose **Create new token**.
2. Add the scope `data.records:write`.
3. Under access, add the base that holds the table.
4. Create the token and copy it.

The base ID is the part of the base's URL that starts with `app`. Check the token and table with one request:

`Terminal`:

```bash
curl https://api.airtable.com/v0/$AIRTABLE_BASE_ID/Submissions \
  -H "Authorization: Bearer $AIRTABLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"records":[{"fields":{"Name":"Test from the terminal"}}],"typecast":true}'
```

A JSON answer with a `records` array means it worked. Delete the test row.

### Step 3: Write the relay

Both versions verify the signature with `@ikiform/sdk`, map fields to columns, and ignore `endpoint.test` events so tests don't add rows.

**Cloudflare Worker** (`src/index.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  AIRTABLE_TOKEN: string;
  AIRTABLE_BASE_ID: string;
  AIRTABLE_TABLE: string;
}

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

const COLUMNS: Record<string, string> = {
  name: "Name",
  email: "Email",
  message: "Message",
};

function toAirtable(event: Extract<WebhookEvent, { type: "submission.created" }>) {
  const { fields, submission_id, created_at, form } = event.data;
  const record: Record<string, string> = {
    "Submission ID": submission_id,
    Form: form.name,
    Submitted: created_at,
  };
  for (const [field, column] of Object.entries(COLUMNS)) {
    if (fields[field] !== undefined) record[column] = valueText(fields[field]).slice(0, 100000);
  }
  return { records: [{ fields: record }], typecast: true };
}

async function createRecord(event: WebhookEvent, token: string, baseId: string, table: string) {
  if (event.type !== "submission.created") return new Response(null, { status: 204 });
  return fetch(`https://api.airtable.com/v0/${baseId}/${encodeURIComponent(table)}`, {
    method: "POST",
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
    body: JSON.stringify(toAirtable(event)),
  });
}

export default {
  async fetch(request: Request, env: Env) {
    if (request.method !== "POST") return new Response(null, { status: 405 });
    const payload = await request.text();
    let event: WebhookEvent;
    try {
      event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
      throw error;
    }
    const response = await createRecord(event, env.AIRTABLE_TOKEN, env.AIRTABLE_BASE_ID, env.AIRTABLE_TABLE);
    if (response.status === 429) {
      const retryAfter = response.headers.get("retry-after") ?? "30";
      return new Response("Airtable rate limit", { status: 503, headers: { "retry-after": retryAfter } });
    }
    if (!response.ok) return new Response(`Airtable answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  },
};
```

**Next.js** (`app/api/ikiform/airtable/route.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

const COLUMNS: Record<string, string> = {
  name: "Name",
  email: "Email",
  message: "Message",
};

function toAirtable(event: Extract<WebhookEvent, { type: "submission.created" }>) {
  const { fields, submission_id, created_at, form } = event.data;
  const record: Record<string, string> = {
    "Submission ID": submission_id,
    Form: form.name,
    Submitted: created_at,
  };
  for (const [field, column] of Object.entries(COLUMNS)) {
    if (fields[field] !== undefined) record[column] = valueText(fields[field]).slice(0, 100000);
  }
  return { records: [{ fields: record }], typecast: true };
}

async function createRecord(event: WebhookEvent, token: string, baseId: string, table: string) {
  if (event.type !== "submission.created") return new Response(null, { status: 204 });
  return fetch(`https://api.airtable.com/v0/${baseId}/${encodeURIComponent(table)}`, {
    method: "POST",
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
    body: JSON.stringify(toAirtable(event)),
  });
}

export async function POST(request: Request) {
  const payload = await request.text();
  let event: WebhookEvent;
  try {
    event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  const response = await createRecord(event, process.env.AIRTABLE_TOKEN!, process.env.AIRTABLE_BASE_ID!, process.env.AIRTABLE_TABLE!);
  if (response.status === 429) {
    const retryAfter = response.headers.get("retry-after") ?? "30";
    return new Response("Airtable rate limit", { status: 503, headers: { "retry-after": retryAfter } });
  }
  if (!response.ok) return new Response(`Airtable answered ${response.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

Diagram: How the relay maps an Ikiform submission to Airtable record

1. submission_id becomes Submission ID.
2. form.name becomes Form.
3. data.created_at becomes Submitted.
4. fields.name becomes Name.
5. fields.email becomes Email.
6. fields.message becomes Message.

Only columns in the relay's COLUMNS map are sent. Airtable refuses unknown field names.

For the sample submission, the relay sends Airtable this body:

`POST /v0/{baseId}/Submissions`:

```json
{
  "records": [
    {
      "fields": {
        "Submission ID": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
        "Form": "Contact",
        "Submitted": "2026-10-23T12:00:00.000Z",
        "Name": "Ada",
        "Email": "ada@example.com",
        "Message": "Hello"
      }
    }
  ],
  "typecast": true
}
```

- `typecast: true` lets Airtable turn text into dates, numbers and select options, and add new select options when needed.
- Arrays, such as a group of checkboxes, are joined with a comma and a space.
- A `429` from Airtable becomes a `503` with a 30 second `Retry-After`, matching Airtable's 30 second pause. Any other error becomes a `502`, which Ikiform retries.

### Step 4: Deploy it

### Cloudflare Worker

`Terminal`:

```bash
mkdir ikiform-airtable && cd ikiform-airtable
npm init -y
npm install @ikiform/sdk
npm install -D wrangler
```

Save the Worker code as `src/index.ts`, then set the route in `wrangler.jsonc`:

`wrangler.jsonc`:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "ikiform-airtable",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "routes": [{ "pattern": "hooks.example.com", "custom_domain": true }]
}
```

> Ikiform refuses `workers.dev` URLs, so the Worker needs a hostname on a domain you have on Cloudflare, like `hooks.example.com`. The `custom_domain` route creates the DNS record and certificate for you on deploy.

Add the secrets and deploy. Set `AIRTABLE_TABLE` to `Submissions`, or to the table's ID:

`Terminal`:

```bash
npx wrangler secret put IKIFORM_WEBHOOK_SECRET
npx wrangler secret put AIRTABLE_TOKEN
npx wrangler secret put AIRTABLE_BASE_ID
npx wrangler secret put AIRTABLE_TABLE
npx wrangler deploy
```

Your relay URL is `https://hooks.example.com/`.

### Next.js

Add `@ikiform/sdk` to the app, save the route handler, and set `IKIFORM_WEBHOOK_SECRET`, `AIRTABLE_TOKEN`, `AIRTABLE_BASE_ID` and `AIRTABLE_TABLE` in your host's environment variables. Use the production domain as the relay URL.

### Step 5: Add the endpoint in Ikiform

- In the Ikiform dashboard, open **Webhooks** and choose **Add endpoint**.
- Paste your relay URL into **URL**.
- Under **Forms**, pick one form, or **All forms** in the workspace.
- Add a **Description** if you like, such as "Airtable", then choose **Add endpoint**.
- Copy the signing secret. It starts with `whsec_` and is shown in full only once.

There's no event picker. An endpoint gets `submission.created` for every accepted submission on the forms you chose, and `endpoint.test` when you send a test. A workspace on Pro can have up to 3 endpoints.

### Step 6: Add the secret to the relay

Set `IKIFORM_WEBHOOK_SECRET` to the `whsec_` secret. On Cloudflare run `npx wrangler secret put IKIFORM_WEBHOOK_SECRET` again. On other hosts, update the variable and redeploy.

Ikiform only delivers to public `https://` URLs on port 443. IP addresses, `localhost`, internal hostnames, `workers.dev` and `pages.dev` are refused when you save the endpoint.

## Test it

Open the endpoint in Ikiform and choose **Send test**. Ikiform sends one `endpoint.test` event with no retries, and the result shows up in the endpoint's delivery log within a few seconds. Then send a real submission from your form, or from a terminal:

`Terminal`:

```bash
curl https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada","email":"ada@example.com","message":"Hello from curl"}'
```

A `201` with `"ok": true` means Ikiform stored it. The delivery appears on the endpoint page, and the message lands in the Airtable table.

> If the form has a human check or allowed domains turned on, a post from curl is refused or marked as spam, and spam never sends a webhook. Test from your real site instead.

The test event returns `204` from the relay and adds nothing. The real submission adds a row.

## How delivery works

| The receiver answers | Ikiform |
| --- | --- |
| Any `2xx` within 15 seconds | Marks the delivery succeeded |
| A redirect | Doesn't follow it, and counts a failure |
| `429` or `503` with `Retry-After` | Waits that long, up to the next step of the schedule |
| `410 Gone` | Fails the delivery and disables the endpoint |
| Anything else, or no answer in 15 seconds | Retries |

Failed deliveries are retried 8 times over about 3 d 6 h, with the same `webhook-id` each time. If every delivery to an endpoint fails for 5 days, Ikiform disables it and emails the workspace owners.

Diagram: What the relay tells Ikiform when Airtable answers

1. Airtable accepts the request: the relay answers 204, and Ikiform marks the delivery succeeded.
2. Airtable is rate limited (429): the relay answers 503 with Retry-After (retry-after: 30), and Ikiform waits that long before it tries again.
3. Airtable answers with any other error: the relay answers 502, and Ikiform retries, 8 retries over about 3 d 6 h.

Airtable allows 5 requests a second per base. Over that it answers `429` and refuses requests for 30 seconds. The relay passes that back to Ikiform, so the row arrives a little later rather than not at all.

> If Airtable created the row but answered after Ikiform's 15 second limit, Ikiform retries and a second row appears. It's rare, and the `Submission ID` column makes duplicates easy to find.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| `400 no_matching_signature` | The secret is wrong, or the body was parsed and re-serialized before verifying. Use the `whsec_` secret of this endpoint and the raw body exactly as received. After **Roll secret**, update it within 24 hours. |
| `502` with `Airtable answered 422` | A column in `COLUMNS` doesn't exist in the table, or a value can't be converted. The relay's logs show Airtable's error, such as `UNKNOWN_FIELD_NAME`. |
| `502` with `Airtable answered 403` or `404` | The token lacks `data.records:write`, can't access this base, or the base ID or table name is wrong. |
| `502` with `Airtable answered 401` | The token is wrong or expired. Create a new one. |
| Delivery log shows `timeout` | Nothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away. |
| Endpoint is disabled | Every delivery failed for 5 days, the URL answered `410`, or its hostname started resolving to a private address. Fix the cause, then turn **Enabled** back on and **Retry now** the failed deliveries. |
| No delivery at all | Check that the endpoint covers this form, that it's enabled, and that the submission wasn't marked as spam or held because the workspace is over its monthly limit. |

## Without a relay: the automation webhook trigger

Airtable automations can start with **When webhook received**, which gives you a URL that accepts JSON. You can paste it straight into an Ikiform endpoint, because the trigger takes any JSON object. Know the trade-offs first:

- It can't check Ikiform's signature. Anyone who learns the URL can start the automation, so keep it private.
- Requests are limited to 100 KB and 5 a second, and every run counts toward your plan's automation runs.
- Ikiform's fields are nested under `data.fields`. Picking nested values in later steps is limited, so you may need a **Run a script** action that reads the body and sets the fields.
- You have to send a sample request, then choose **Run test**, before Airtable knows the body's shape. Send a real submission for that, not the dashboard test, which has no fields.

## Next steps

- Map more fields by adding columns and lines in `COLUMNS`.
- Build an interface or a kanban view grouped by a status field your team updates.
- Post each new row to a channel too: see [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md).

| Airtable reference | Link |
| --- | --- |
| Personal access tokens | [airtable.com](https://airtable.com/developers/web/guides/personal-access-tokens) |
| Create records | [airtable.com](https://airtable.com/developers/web/api/create-records) |
| Rate limits | [airtable.com](https://airtable.com/developers/web/api/rate-limits) |
| When webhook received | [support.airtable.com](https://support.airtable.com/docs/when-webhook-received-trigger) |
