# Send form submissions to Google Sheets

> Add a row to a Google Sheet for every Ikiform submission, with a verified relay in front of an Apps Script web app, or with n8n or Make.

Published 2026-10-23 · Guide, Google Sheets · https://ikiform.com/blog/form-submissions-to-google-sheets

A spreadsheet is often all a small team needs to track leads or sign-ups. This guide adds one row per Ikiform submission, adds a column when the form gets a new field, and never writes the same submission twice.

Diagram: How a submission gets from your form to Google Sheets

1. A visitor submits your form, and it posts to Ikiform.
2. Ikiform stores the submission and posts a signed webhook to the relay.
3. The relay checks the signature, then posts the event with a shared token to the Apps Script web app.
4. Apps Script checks the token and appends a row to the sheet.

The relay verifies Ikiform's signature, then hands the event to an Apps Script web app that owns the sheet.

## Why there's a relay

An Apps Script web app can receive a `POST`, but two things stop Ikiform from posting to it directly:

- `doPost` can read the body but not the request headers, so the script can't check Ikiform's signature.
- Apps Script answers every request to `/exec` with a `302` redirect to `script.googleusercontent.com`. Ikiform doesn't follow redirects and counts that as a failure, so it retries a delivery that already wrote a row. You'd get duplicates.

The relay fixes both. It checks the signature, posts to Apps Script with a shared token, follows the redirect, and answers Ikiform with a clean `204`.

Diagram: Why Ikiform can't post to Apps Script directly

1. Straight to Apps Script: the script writes the row, then answers with a 302 redirect. Ikiform doesn't follow redirects, counts a failure and retries, so the row is written again. The script also can't read the signature headers.
2. Through the relay: the relay checks the signature, posts the event with a shared token, follows the redirect to read the script's answer, and answers Ikiform with 204.

> Prefer no code? [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) and [Make](https://ikiform.com/blog/form-submissions-to-make.md) both have Google Sheets actions that append a row. Use one of those guides and add the Sheets step after the webhook.

## Before you start

- An Ikiform form that already receives submissions.
- A Google account and a spreadsheet you can edit.
- Somewhere to run the relay: a Cloudflare account with a domain on it, or a Next.js app.

## 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: Add the Apps Script

1. Open the spreadsheet and choose **Extensions** > **Apps Script**.
2. Replace the contents of `Code.gs` with the script below and save.

`Code.gs`:

```js
const SHEET_NAME = "Submissions";
const FIXED = ["delivery_id", "submission_id", "created_at", "form"];

function doPost(e) {
  const request = JSON.parse(e.postData.contents);
  const token = PropertiesService.getScriptProperties().getProperty("TOKEN");
  if (!token || request.token !== token) return reply({ ok: false, error: "unauthorized" });
  const event = request.event;
  if (event.type !== "submission.created") return reply({ ok: true, skipped: event.type });

  const lock = LockService.getScriptLock();
  lock.waitLock(10000);
  try {
    const book = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = book.getSheetByName(SHEET_NAME) || book.insertSheet(SHEET_NAME);
    const fields = event.data.fields;
    let header = sheet.getLastRow() > 0 ? sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0] : [];
    if (header.length === 0) header = FIXED.slice();
    const missing = Object.keys(fields).filter((name) => header.indexOf(name) === -1);
    if (missing.length > 0 || sheet.getLastRow() === 0) {
      header = header.concat(missing);
      sheet.getRange(1, 1, 1, header.length).setValues([header]);
    }

    const rows = sheet.getLastRow() - 1;
    if (rows > 0) {
      const ids = sheet.getRange(2, 1, rows, 1).getValues();
      if (ids.some((row) => row[0] === event.id)) return reply({ ok: true, duplicate: true });
    }

    const fixed = {
      delivery_id: event.id,
      submission_id: event.data.submission_id,
      created_at: event.data.created_at,
      form: event.data.form.name,
    };
    sheet.appendRow(header.map((name) => cell(FIXED.indexOf(name) === -1 ? fields[name] : fixed[name])));
    return reply({ ok: true });
  } finally {
    lock.releaseLock();
  }
}

function cell(value) {
  if (value === null || value === undefined) return "";
  if (Array.isArray(value)) return cell(value.map(String).join(", "));
  if (typeof value === "object") return cell(JSON.stringify(value));
  const text = String(value);
  return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
}

function reply(body) {
  return ContentService.createTextOutput(JSON.stringify(body)).setMimeType(ContentService.MimeType.JSON);
}
```

- The first row holds the column names. New fields get a new column at the end.
- Column A is the delivery id. A retry with the same id is skipped, so a row is never written twice.
- Values that start with `=`, `+`, `-` or `@` get a leading `'`, so a visitor can't put a formula in your sheet.
- The script lock stops two deliveries arriving together from writing over each other.

Diagram: How the relay maps an Ikiform submission to sheet row

1. id: dlv_… becomes column A: delivery_id.
2. submission_id becomes submission_id.
3. data.created_at becomes created_at.
4. form.name becomes form.
5. fields.name, email, message becomes one column per field, by name.
6. a new field adds a column at the end is added by the relay.

Column A is checked first, so a retry with the same delivery id writes nothing.

### Step 2: Set the shared token

Make a long random string:

`Terminal`:

```bash
node -e "console.log(crypto.randomUUID() + crypto.randomUUID())"
```

In the Apps Script editor, open **Project Settings**, add a script property named `TOKEN`, and paste the string. The relay sends the same value, and the script refuses anything else.

### Step 3: Deploy it as a web app

1. Choose **Deploy** > **New deployment**, and pick **Web app** as the type.
2. Run it as yourself, so the script can write to your sheet.
3. Give access to anyone, so the relay can call it without a Google sign-in. The token keeps other callers out.
4. Choose **Deploy**, authorize the script, and copy the **Web app** URL ending in `/exec`.

> Saving the script doesn't update a deployment. After an edit, use **Deploy** > **Manage deployments**, edit the deployment and pick a new version. The URL stays the same.

### Step 4: Write the relay

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

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

interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  APPS_SCRIPT_URL: string;
  SHEETS_TOKEN: string;
}

async function sendToSheet(url: string, token: string, event: WebhookEvent) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ token, event }),
  });
  if (!response.ok) return response;
  const result = (await response.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
  if (result?.ok) return new Response(null, { status: 204 });
  return new Response(null, { status: result?.error === "unauthorized" ? 401 : 422 });
}

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 sendToSheet(env.APPS_SCRIPT_URL, env.SHEETS_TOKEN, event);
    if (response.status === 429) {
      const retryAfter = response.headers.get("retry-after") ?? "30";
      return new Response("Google Sheets rate limit", { status: 503, headers: { "retry-after": retryAfter } });
    }
    if (!response.ok) return new Response(`Google Sheets answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  },
};
```

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

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

async function sendToSheet(url: string, token: string, event: WebhookEvent) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ token, event }),
  });
  if (!response.ok) return response;
  const result = (await response.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
  if (result?.ok) return new Response(null, { status: 204 });
  return new Response(null, { status: result?.error === "unauthorized" ? 401 : 422 });
}

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 sendToSheet(process.env.APPS_SCRIPT_URL!, process.env.SHEETS_TOKEN!, event);
  if (response.status === 429) {
    const retryAfter = response.headers.get("retry-after") ?? "30";
    return new Response("Google Sheets rate limit", { status: 503, headers: { "retry-after": retryAfter } });
  }
  if (!response.ok) return new Response(`Google Sheets answered ${response.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

- `fetch` follows the Apps Script redirect by default and reads the script's JSON answer at the end of it.
- A wrong token or a script error returns an error to Ikiform, which retries. A rate limit becomes a `503` with `Retry-After`.

### Step 5: Deploy the relay

### Cloudflare Worker

`Terminal`:

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

`wrangler.jsonc`:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "ikiform-sheets",
  "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.

Set `APPS_SCRIPT_URL` to the `/exec` URL and `SHEETS_TOKEN` to the token, then deploy:

`Terminal`:

```bash
npx wrangler secret put IKIFORM_WEBHOOK_SECRET
npx wrangler secret put APPS_SCRIPT_URL
npx wrangler secret put SHEETS_TOKEN
npx wrangler deploy
```

### Next.js

Add `@ikiform/sdk`, save the route handler, and set `IKIFORM_WEBHOOK_SECRET`, `APPS_SCRIPT_URL` and `SHEETS_TOKEN` in your host's environment variables. Use the production domain, not a protected preview URL.

### Step 6: Add the endpoint in Ikiform

- In the Ikiform dashboard, open **Webhooks** and choose **Add endpoint**.
- Paste the relay URL into **URL**.
- Under **Forms**, pick one form, or **All forms** in the workspace.
- Add a **Description** if you like, such as "Google Sheets", 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 7: Add the secret to the relay

Set `IKIFORM_WEBHOOK_SECRET` on the relay to the `whsec_` secret. On Cloudflare, `npx wrangler secret put IKIFORM_WEBHOOK_SECRET` applies it right away.

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 sheet.

> 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 checks the whole chain, signature and token included, without adding a row. A real submission adds a `Submissions` tab on first use, writes the header row, then the data.

## 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.

Apps Script can take a few seconds to start when it hasn't run for a while. The relay waits for it, and the script holds its lock for at most 10 seconds, which keeps the whole round trip inside Ikiform's 15 seconds in normal use.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| `502` with `Google Sheets answered 401` | `SHEETS_TOKEN` on the relay doesn't match the `TOKEN` script property. Copy it again. |
| `502` with `Google Sheets answered 422` | Apps Script returned a sign-in or error page instead of JSON. Check the deployment gives access to anyone, and that `APPS_SCRIPT_URL` ends in `/exec`, not `/dev`. |
| Script changes don't take effect | Edit the existing deployment and pick a new version. Saving the file alone doesn't change what `/exec` runs. |
| Errors under **Executions** in Apps Script | Open the failed run to see the line. A `Lock timeout` means many deliveries arrived at once; Ikiform retries them. |
| `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. |
| 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. |

## Next steps

| Want to | Read |
| --- | --- |
| Also post each submission to a channel | [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md) |
| Download older submissions as CSV | [Exporting submissions](https://ikiform.com/docs/guides/export.md) |
| See every header and retry rule | [Webhooks](https://ikiform.com/docs/webhooks.md) |

Google's own reference: [Apps Script web apps](https://developers.google.com/apps-script/guides/web), [Content service redirects](https://developers.google.com/apps-script/guides/content), [deployments](https://developers.google.com/apps-script/concepts/deployments), [LockService](https://developers.google.com/apps-script/reference/lock/lock-service) and [quotas](https://developers.google.com/apps-script/guides/services/quotas).
