# Send form submissions to Telegram

> Get every Ikiform submission as a Telegram message from your own bot, through a small relay that verifies the signature and calls the Bot API.

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

A Telegram bot is a quick way to get new submissions on your phone, in a private chat or in a group with your team. This guide creates a bot, finds the chat to post in, and sends each Ikiform submission there as a formatted message.

Diagram: How a submission gets from your form to Telegram

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 an HTML message and calls sendMessage with your bot's token.
4. Telegram answers the relay, and the relay answers Ikiform.

Ikiform signs the delivery. The relay checks it and calls the Telegram Bot API with your bot's token.

## Why there's a relay

Telegram has no incoming webhook URL for a chat. A bot sends messages by calling `sendMessage` with a `chat_id` and a `text`, and the bot token sits in the URL. Ikiform's submission event has neither field, and the token must never be part of a URL you hand to another service.

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

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) all have Telegram actions that use the same bot token and chat id.

## Before you start

- An Ikiform form that already receives submissions.
- A Telegram account, on the phone or desktop app.
- 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 bot

1. In Telegram, open a chat with [@BotFather](https://t.me/BotFather) and send `/newbot`.
2. Give the bot a display name, then a username that ends in `bot`, such as `acme_leads_bot`.
3. BotFather replies with a token like `110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw`. Keep it secret. If it leaks, send `/token` to BotFather to get a new one.

### Step 2: Find the chat id

Decide where messages go. For yourself, open the bot and press **Start**. For a team, add the bot to a group and send any message in the group. Then ask Telegram for the bot's recent updates:

`Terminal`:

```bash
curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates"
```

`Response`:

```json
{
  "ok": true,
  "result": [
    {
      "update_id": 100000001,
      "message": {
        "message_id": 1,
        "chat": { "id": -1001234567890, "title": "Leads", "type": "supergroup" },
        "text": "hello"
      }
    }
  ]
}
```

Copy `message.chat.id`. A private chat has a positive id; a group's id is negative, so keep the minus sign. Updates are kept for 24 hours, so send a fresh message if the list is empty.

> `getUpdates` returns nothing while the bot has its own webhook set. Use a bot that only sends messages, or read the chat id from your existing webhook handler.

Check the bot can post there:

`Terminal`:

```bash
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
  -H "Content-Type: application/json" \
  -d '{"chat_id":"-1001234567890","text":"Hello from the terminal"}'
```

### Step 3: Write the relay

Pick the platform you already use. Both versions verify the signature with `@ikiform/sdk`, build an HTML message, and turn Telegram 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;
  TELEGRAM_BOT_TOKEN: string;
  TELEGRAM_CHAT_ID: 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;");
}

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

function toTelegram(event: WebhookEvent) {
  if (event.type === "endpoint.test") return "Ikiform test event received. Submissions will appear here.";
  const { form, fields, submission_id, truncated } = event.data;
  const head = `<b>${escape(clip(`New submission: ${form.name}`, 200))}</b>`;
  const foot = truncated ? "Too large to show here. Open it in Ikiform." : `<code>${submission_id}</code>`;
  const entries = Object.entries(fields);
  let text = head;
  let shown = 0;
  for (const [name, value] of entries) {
    const line = `\n\n<b>${escape(clip(name, 100))}</b>\n${escape(clip(valueText(value), 1000))}`;
    if (text.length + line.length + foot.length + 40 > 4000) break;
    text += line;
    shown += 1;
  }
  const more = entries.length - shown;
  if (more > 0) text += `\n\n${more} more ${more === 1 ? "field" : "fields"} in Ikiform.`;
  return `${text}\n\n${foot}`;
}

async function sendTelegram(token: string, chatId: string, text: string) {
  const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ chat_id: chatId, text, parse_mode: "HTML", link_preview_options: { is_disabled: true } }),
  });
  if (response.status !== 429) return response;
  const body = (await response.json().catch(() => ({}))) as { parameters?: { retry_after?: number } };
  const retryAfter = String(body.parameters?.retry_after ?? 30);
  return new Response(null, { status: 429, headers: { "retry-after": retryAfter } });
}

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

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

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

function toTelegram(event: WebhookEvent) {
  if (event.type === "endpoint.test") return "Ikiform test event received. Submissions will appear here.";
  const { form, fields, submission_id, truncated } = event.data;
  const head = `<b>${escape(clip(`New submission: ${form.name}`, 200))}</b>`;
  const foot = truncated ? "Too large to show here. Open it in Ikiform." : `<code>${submission_id}</code>`;
  const entries = Object.entries(fields);
  let text = head;
  let shown = 0;
  for (const [name, value] of entries) {
    const line = `\n\n<b>${escape(clip(name, 100))}</b>\n${escape(clip(valueText(value), 1000))}`;
    if (text.length + line.length + foot.length + 40 > 4000) break;
    text += line;
    shown += 1;
  }
  const more = entries.length - shown;
  if (more > 0) text += `\n\n${more} more ${more === 1 ? "field" : "fields"} in Ikiform.`;
  return `${text}\n\n${foot}`;
}

async function sendTelegram(token: string, chatId: string, text: string) {
  const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ chat_id: chatId, text, parse_mode: "HTML", link_preview_options: { is_disabled: true } }),
  });
  if (response.status !== 429) return response;
  const body = (await response.json().catch(() => ({}))) as { parameters?: { retry_after?: number } };
  const retryAfter = String(body.parameters?.retry_after ?? 30);
  return new Response(null, { status: 429, headers: { "retry-after": retryAfter } });
}

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

Diagram: How the relay maps an Ikiform submission to Telegram message

1. form.name: Contact becomes bold line: New submission: Contact.
2. fields.name: Ada becomes bold name, then Ada.
3. fields.email: ada@example.com becomes bold email, then ada@example.com.
4. fields.message: Hello becomes bold message, then Hello.
5. submission_id becomes code line: sub_….

One text message with parse_mode HTML, up to 4,096 characters.

For the sample submission above, the relay sends this text with `parse_mode` set to `HTML`:

`Telegram message`:

```html
<b>New submission: Contact</b>

<b>name</b>
Ada

<b>email</b>
ada@example.com

<b>message</b>
Hello

<code>sub_01k7v2c9t3e8m5q4x6z0a1b2c3</code>
```

- Telegram's HTML mode needs `&`, `<` and `>` escaped outside tags. The relay escapes every value, so a visitor can't add tags or break the message.
- A message can be 4,096 characters. The relay cuts long values and lists how many fields it left out.
- Link previews are off, so a URL in a message field doesn't expand into a card.
- When Telegram's flood control answers `429`, the wait is in the JSON body as `parameters.retry_after`. The relay passes it to Ikiform as a `503` with `Retry-After`. Any other Telegram error becomes a `502`, which Ikiform retries.

### Step 4: Deploy it

### Cloudflare Worker

`Terminal`:

```bash
mkdir ikiform-telegram && cd ikiform-telegram
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-telegram",
  "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:

`Terminal`:

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

### 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 "Telegram leads group", 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; 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 Telegram.

> 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 sends a short line to the chat. A real submission sends the full message, 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 Telegram answers

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

Telegram allows about one message per second in a chat and 20 a minute in a group. A burst of submissions can hit that, and the relay passes Telegram's wait time back to Ikiform, 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 `Telegram answered 400` | Usually `chat not found` or a wrong chat id. Check `TELEGRAM_CHAT_ID`, including the minus sign for groups. If a group was upgraded to a supergroup, Telegram's error includes `migrate_to_chat_id`; use that id instead. |
| `502` with `Telegram answered 401` or `404` | The bot token is wrong or was replaced with `/token`. Update `TELEGRAM_BOT_TOKEN`. |
| `502` with `Telegram answered 403` | The bot can't post in that chat. It was removed from the group, or the user blocked it or never pressed **Start**. |
| 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

- Post into one topic of a forum group by adding `message_thread_id` to the `sendMessage` body.
- Send each form to a different chat: deploy the relay once per chat, or read the chat id from a map keyed by `event.data.form.id`.
- Skip duplicates by storing the `webhook-id` header, as in [Receiving webhooks](https://ikiform.com/docs/guides/webhook-handlers.md#duplicates).

| Telegram reference | Link |
| --- | --- |
| `sendMessage` | [core.telegram.org](https://core.telegram.org/bots/api#sendmessage) |
| HTML formatting | [core.telegram.org](https://core.telegram.org/bots/api#html-style) |
| `getUpdates` | [core.telegram.org](https://core.telegram.org/bots/api#getupdates) |
| BotFather | [core.telegram.org](https://core.telegram.org/bots/features#botfather) |
| Rate limits | [core.telegram.org](https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this) |
