Blog

Google Sheets8 min read

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.

Preet SutharFounder, Ikiform

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.

How a submission gets from your form to Google Sheetsyourformany htmlform,frameworkor serverIkiformstores it,signs awebhookrelayverifies,adds asharedtokenAppsScriptappends arow to thesheetpostsignedtoken + eventHow a submission gets from your form to Google Sheetsyour formany html form, framework orserverIkiformstores it, signs a webhookrelayverifies, adds a shared tokenApps Scriptappends a row to the sheetpostsignedtoken + event
  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.

Why Ikiform can't post to Apps Script directlystraight to apps scriptthrough the relayIkiformstores it, signs awebhookApps Script/execwrites the row,answers 302Ikiformdoesn't follow it,retries: a secondrowIkiformstores it, signs awebhookrelayverifies, followsthe 302, answers204Apps Scriptwrites the rowoncepost302signedtoken + eventWhy Ikiform can't post to Apps Script directlystraight to apps scriptthrough the relayIkiformstores it, signs a webhookApps Script /execwrites the row, answers 302Ikiformdoesn't follow it, retries: asecond rowIkiformstores it, signs a webhookrelayverifies, follows the 302,answers 204Apps Scriptwrites the row oncepost302signedtoken + event
  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 and Make 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
{
  "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.

Set it up

  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
    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.
    How the relay maps an Ikiform submission to sheet rowIkiform eventsheet rowid: dlv_…column A: delivery_idsubmission_idsubmission_iddata.created_atcreated_atform.nameformfields.name, email, messageone column per field, by namea new field adds a column at theendHow the relay maps an Ikiform submission to sheet rowIkiform eventsheet rowid: dlv_…column A:delivery_idsubmission_idsubmission_iddata.created_atcreated_atform.nameformfields.name,email, messageone column perfield, by namea new fieldadds a columnat the end
    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.
  2. Set the shared token

    Make a long random string:

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

  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.
  4. Write the relay

    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 });
      },
    };
    • 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.
  5. Deploy the relay

    Cloudflare Worker

    Terminal
    mkdir ikiform-sheets && cd ikiform-sheets
    npm init -y
    npm install @ikiform/sdk
    npm install -D wrangler
    wrangler.jsonc
    {
      "$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
    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.

  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.

  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
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 answersIkiform
Any 2xx within 15 secondsMarks the delivery succeeded
A redirectDoesn't follow it, and counts a failure
429 or 503 with Retry-AfterWaits that long, up to the next step of the schedule
410 GoneFails the delivery and disables the endpoint
Anything else, or no answer in 15 secondsRetries

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

ProblemWhat to do
502 with Google Sheets answered 401SHEETS_TOKEN on the relay doesn't match the TOKEN script property. Copy it again.
502 with Google Sheets answered 422Apps 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 effectEdit the existing deployment and pick a new version. Saving the file alone doesn't change what /exec runs.
Errors under Executions in Apps ScriptOpen the failed run to see the line. A Lock timeout means many deliveries arrived at once; Ikiform retries them.
400 no_matching_signatureThe 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 timeoutNothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away.
Endpoint is disabledEvery 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 allCheck 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 toRead
Also post each submission to a channelSend form submissions to Slack
Download older submissions as CSVExporting submissions
See every header and retry ruleWebhooks

Google's own reference: Apps Script web apps, Content service redirects, deployments, LockService and quotas.

Stuck on a step, or want a guide for another tool? Email hello@ikiform.com.