# Send form submissions to Google Chat

> Post every Ikiform submission to a Google Chat space as a card, through a small relay that verifies the signature and formats the message.

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

Teams on Google Workspace can get new submissions where they already talk. This guide posts each Ikiform submission to a Google Chat space as a card, with the form name in the header and one paragraph per field.

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

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, builds a card and posts it to the space's webhook.
4. Google Chat answers the relay, and the relay answers Ikiform.

Ikiform signs the delivery. The relay checks it, builds a card, and posts it to the space's webhook.

## Why there's a relay

A Google Chat webhook expects a message with `text` or `cardsV2`. Ikiform sends a submission event shaped as `{ "type": ..., "data": ... }`, which Chat can't display.

> Ikiform sends its own JSON shape to every endpoint and can't send a custom or templated body. Google Chat expects its own shape, so pasting the Google Chat URL straight into Ikiform doesn't work. A small relay in between verifies the signature and reformats the message.

The relay also keeps the space's URL private and checks Ikiform's signature, so nobody else can post to your space through it. If you'd rather not deploy code, [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md), [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) and [Make](https://ikiform.com/blog/form-submissions-to-make.md) can post to Google Chat too.

## Before you start

- An Ikiform form that already receives submissions.
- A Google Workspace Business or Enterprise account. Webhooks aren't available on personal Gmail accounts, and a Workspace admin can turn them off.
- 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 a Google Chat webhook

1. Open Google Chat in a browser. Webhooks can't be added from the mobile app.
2. Open the space, click the arrow next to its name, and choose **Apps & integrations**.
3. Choose **Add webhooks**. Enter a name, such as `Ikiform`, and an avatar URL if you want one, then **Save**.
4. Next to the new webhook, choose **More**, then **Copy link**.

The URL looks like `https://chat.googleapis.com/v1/spaces/SPACE_ID/messages?key=...&token=...`. If **Add webhooks** is missing, your Workspace admin has turned webhooks off. Check it works before going further:

`Terminal`:

```bash
curl -X POST "$GOOGLE_CHAT_WEBHOOK_URL" \
  -H "Content-Type: application/json; charset=UTF-8" \
  -d '{"text":"Hello from the terminal"}'
```

Chat answers with the message it created, and it shows up in the space. Treat the URL as a secret: anyone who has it can post to that space.

### Step 2: Write the relay

Pick the platform you already use. Both versions verify the signature with `@ikiform/sdk`, build the card, and turn Chat errors into answers Ikiform retries.

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

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

interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  GOOGLE_CHAT_WEBHOOK_URL: 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);
}

function escape(text: string) {
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\n", "<br>");
}

function clip(text: string, max: number) {
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}

function bytes(text: string) {
  return new TextEncoder().encode(text).length;
}

function toChat(event: WebhookEvent) {
  if (event.type === "endpoint.test") return { text: "Ikiform test event received. Submissions will appear here." };
  const { form, fields, submission_id, truncated } = event.data;
  const widgets: { textParagraph: { text: string } }[] = [];
  let budget = 24_000;
  for (const [name, value] of Object.entries(fields)) {
    const text = `<b>${escape(clip(name, 100))}</b><br>${escape(clip(valueText(value), 2000))}`;
    if (bytes(text) > budget) break;
    budget -= bytes(text);
    widgets.push({ textParagraph: { text } });
  }
  const more = Object.keys(fields).length - widgets.length;
  if (more > 0) widgets.push({ textParagraph: { text: `${more} more fields in Ikiform.` } });
  const footer = truncated ? "Too large to show here. Open it in Ikiform." : submission_id;
  widgets.push({ textParagraph: { text: `<font color="#80868b">${footer}</font>` } });
  return {
    text: "New form submission",
    cardsV2: [
      {
        cardId: "submission",
        card: {
          header: { title: clip(form.name, 200), subtitle: "New submission" },
          sections: [{ widgets }],
        },
      },
    ],
  };
}

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 fetch(env.GOOGLE_CHAT_WEBHOOK_URL, {
      method: "POST",
      headers: { "content-type": "application/json; charset=UTF-8" },
      body: JSON.stringify(toChat(event)),
    });
    if (response.status === 429) {
      const retryAfter = response.headers.get("retry-after") ?? "30";
      return new Response("Google Chat rate limit", { status: 503, headers: { "retry-after": retryAfter } });
    }
    if (!response.ok) return new Response(`Google Chat answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  },
};
```

**Next.js** (`app/api/ikiform/google-chat/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);
}

function escape(text: string) {
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\n", "<br>");
}

function clip(text: string, max: number) {
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}

function bytes(text: string) {
  return new TextEncoder().encode(text).length;
}

function toChat(event: WebhookEvent) {
  if (event.type === "endpoint.test") return { text: "Ikiform test event received. Submissions will appear here." };
  const { form, fields, submission_id, truncated } = event.data;
  const widgets: { textParagraph: { text: string } }[] = [];
  let budget = 24_000;
  for (const [name, value] of Object.entries(fields)) {
    const text = `<b>${escape(clip(name, 100))}</b><br>${escape(clip(valueText(value), 2000))}`;
    if (bytes(text) > budget) break;
    budget -= bytes(text);
    widgets.push({ textParagraph: { text } });
  }
  const more = Object.keys(fields).length - widgets.length;
  if (more > 0) widgets.push({ textParagraph: { text: `${more} more fields in Ikiform.` } });
  const footer = truncated ? "Too large to show here. Open it in Ikiform." : submission_id;
  widgets.push({ textParagraph: { text: `<font color="#80868b">${footer}</font>` } });
  return {
    text: "New form submission",
    cardsV2: [
      {
        cardId: "submission",
        card: {
          header: { title: clip(form.name, 200), subtitle: "New submission" },
          sections: [{ widgets }],
        },
      },
    ],
  };
}

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 fetch(process.env.GOOGLE_CHAT_WEBHOOK_URL!, {
    method: "POST",
    headers: { "content-type": "application/json; charset=UTF-8" },
    body: JSON.stringify(toChat(event)),
  });
  if (response.status === 429) {
    const retryAfter = response.headers.get("retry-after") ?? "30";
    return new Response("Google Chat rate limit", { status: 503, headers: { "retry-after": retryAfter } });
  }
  if (!response.ok) return new Response(`Google Chat answered ${response.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

Diagram: How the relay maps an Ikiform submission to Chat card

1. form.name: Contact becomes header title: Contact.
2. header subtitle: New submission is added by the relay.
3. fields.name: Ada becomes paragraph: name / Ada.
4. fields.email: ada@example.com becomes paragraph: email / ada@example.com.
5. fields.message: Hello becomes paragraph: message / Hello.
6. submission_id becomes grey paragraph: sub_….

Every value is escaped, so a visitor can't add tags, links or mentions.

For the sample submission above, the relay posts this to the space:

`Google Chat message`:

```json
{
  "text": "New form submission",
  "cardsV2": [
    {
      "cardId": "submission",
      "card": {
        "header": { "title": "Contact", "subtitle": "New submission" },
        "sections": [
          {
            "widgets": [
              { "textParagraph": { "text": "<b>name</b><br>Ada" } },
              { "textParagraph": { "text": "<b>email</b><br>ada@example.com" } },
              { "textParagraph": { "text": "<b>message</b><br>Hello" } },
              { "textParagraph": { "text": "<font color=\"#80868b\">sub_01k7v2c9t3e8m5q4x6z0a1b2c3</font>" } }
            ]
          }
        ]
      }
    }
  ]
}
```

- Card text paragraphs use a small set of HTML tags. The relay escapes `&`, `<` and `>` in every value, so a visitor can't add tags, links or mentions.
- A message can be 32,000 bytes. The relay cuts long values and stops adding fields at about 24 KB, then says how many it left out.
- The top-level `text` is what shows in notifications.
- A `429` becomes a `503` with Chat's `Retry-After`, or 30 seconds when there isn't one, so Ikiform waits before it tries again. Any other Chat error becomes a `502`, which Ikiform retries.

### Step 3: Deploy it

### Cloudflare Worker

`Terminal`:

```bash
mkdir ikiform-google-chat && cd ikiform-google-chat
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-google-chat",
  "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 both secrets and deploy:

`Terminal`:

```bash
npx wrangler secret put IKIFORM_WEBHOOK_SECRET
npx wrangler secret put GOOGLE_CHAT_WEBHOOK_URL
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` and `GOOGLE_CHAT_WEBHOOK_URL` in your host's environment variables. On Vercel the relay URL is `https://your-app.vercel.app/api/ikiform/google-chat`. Use the production domain: Vercel's deployment protection blocks requests to protected preview URLs.

### Step 4: 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 "Google Chat Leads space", 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 5: 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; it takes effect right away. On Vercel, 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 Google Chat.

> 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 posts a short line to the space. A real submission posts the card, with the submission id at the bottom.

## 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 Google Chat answers

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

Google Chat allows one request per second per space, shared by every webhook in it. A burst of submissions can hit that, and the relay tells Ikiform to wait, so the extra messages arrive a little later rather than not at all.

## 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 `Google Chat answered 400` | Chat refused the message. The relay's logs have Chat's error; check that the URL was copied in full, with both `key` and `token`. |
| `502` with `Google Chat answered 403` or `404` | The webhook was deleted, the space is gone, or an admin turned webhooks off. Create a new webhook and update `GOOGLE_CHAT_WEBHOOK_URL`. |
| 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. |
| The same submission arrives twice | A retry after a slow or failed answer. Every retry carries the same `webhook-id`, so skip ids you've already handled. |
| 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

To keep each form's submissions in one thread, set a `threadKey` and ask Chat to reply in that thread. Replace the `fetch` call's URL and body with these:

`Threaded replies`:

```ts
const url = new URL(env.GOOGLE_CHAT_WEBHOOK_URL);
url.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
const body = { ...toChat(event), thread: { threadKey: event.type === "submission.created" ? event.data.form.id : "test" } };
```

- Send each form to its own space: add one webhook per space and one Ikiform endpoint per form. Pro allows 3 endpoints.
- Skip duplicates by storing the `webhook-id` header, as in [Receiving webhooks](https://ikiform.com/docs/guides/webhook-handlers.md#duplicates).

| Google reference | Link |
| --- | --- |
| Incoming webhooks | [developers.google.com](https://developers.google.com/workspace/chat/quickstart/webhooks) |
| Formatting messages | [developers.google.com](https://developers.google.com/workspace/chat/format-messages) |
| Message limits and threads | [developers.google.com](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/create) |
