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.
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.
- A visitor submits your form, and it posts to Ikiform.
- Ikiform stores the submission and posts a signed webhook to the relay.
- The relay checks the signature, then posts the event with a shared token to the Apps Script web app.
- Apps Script checks the token and appends a row to 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:
doPostcan read the body but not the request headers, so the script can't check Ikiform's signature.- Apps Script answers every request to
/execwith a302redirect toscript.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.
- 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.
- 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.
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:
{
"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
Add the Apps Script
- Open the spreadsheet and choose Extensions > Apps Script.
- Replace the contents of
Code.gswith 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.
- id: dlv_… becomes column A: delivery_id.
- submission_id becomes submission_id.
- data.created_at becomes created_at.
- form.name becomes form.
- fields.name, email, message becomes one column per field, by name.
- 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. 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.Deploy it as a web app
- Choose Deploy > New deployment, and pick Web app as the type.
- Run it as yourself, so the script can write to your sheet.
- Give access to anyone, so the relay can call it without a Google sign-in. The token keeps other callers out.
- 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.Write the relay
src/index.tsimport { 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 }); }, };fetchfollows 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
503withRetry-After.
Deploy the relay
Cloudflare Worker
Terminal mkdir ikiform-sheets && cd ikiform-sheets npm init -y npm install @ikiform/sdk npm install -D wranglerwrangler.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 refusesworkers.devURLs, so the Worker needs a hostname on a domain you have on Cloudflare, likehooks.example.com. Thecustom_domainroute creates the DNS record and certificate for you on deploy.Set
APPS_SCRIPT_URLto the/execURL andSHEETS_TOKENto 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 deployNext.js
Add
@ikiform/sdk, save the route handler, and setIKIFORM_WEBHOOK_SECRET,APPS_SCRIPT_URLandSHEETS_TOKENin your host's environment variables. Use the production domain, not a protected preview URL.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.createdfor every accepted submission on the forms you chose, andendpoint.testwhen you send a test. A workspace on Pro can have up to 3 endpoints.Add the secret to the relay
Set
IKIFORM_WEBHOOK_SECRETon the relay to thewhsec_secret. On Cloudflare,npx wrangler secret put IKIFORM_WEBHOOK_SECRETapplies 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:
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.
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 |
| Download older submissions as CSV | Exporting submissions |
| See every header and retry rule | Webhooks |
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.