Telegram8 min read
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.
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.
- A visitor submits your form, and it posts to Ikiform.
- Ikiform stores the submission and sends a signed webhook to your relay.
- The relay checks the signature, builds an HTML message and calls sendMessage with your bot's token.
- Telegram answers the relay, and the relay answers Ikiform.
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.
If you'd rather not deploy code, n8n, Zapier and Make 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:
{
"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
Create a bot
- In Telegram, open a chat with @BotFather and send
/newbot. - Give the bot a display name, then a username that ends in
bot, such asacme_leads_bot. - BotFather replies with a token like
110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw. Keep it secret. If it leaks, send/tokento BotFather to get a new one.
- In Telegram, open a chat with @BotFather and send
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 curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates"Response { "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.getUpdatesreturns 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 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"}'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.src/index.tsimport { 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("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } 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 }); }, };- form.name: Contact becomes bold line: New submission: Contact.
- fields.name: Ada becomes bold name, then Ada.
- fields.email: ada@example.com becomes bold email, then ada@example.com.
- fields.message: Hello becomes bold message, then Hello.
- 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_modeset toHTML:Telegram message <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 asparameters.retry_after. The relay passes it to Ikiform as a503withRetry-After. Any other Telegram error becomes a502, which Ikiform retries.
Deploy it
Cloudflare Worker
Terminal mkdir ikiform-telegram && cd ikiform-telegram npm init -y npm install @ikiform/sdk npm install -D wranglerSave the Worker code as
src/index.ts, then set the route inwrangler.jsonc:wrangler.jsonc { "$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 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.Add the secrets and deploy:
Terminal npx wrangler secret put IKIFORM_WEBHOOK_SECRET npx wrangler secret put TELEGRAM_BOT_TOKEN npx wrangler secret put TELEGRAM_CHAT_ID npx wrangler deployYour relay URL is
https://hooks.example.com/.Next.js
Add
@ikiform/sdkto the app, save the route handler, and setIKIFORM_WEBHOOK_SECRET,TELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDin your host's environment variables. On Vercel the relay URL ishttps://your-app.vercel.app/api/ikiform/telegram. Use the production domain: Vercel's deployment protection blocks requests to protected preview URLs.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.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_SECRETto thewhsec_secret. On Cloudflare runnpx wrangler secret put IKIFORM_WEBHOOK_SECRETagain; 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:
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.
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.
- Telegram accepts the request: the relay answers 204, and Ikiform marks the delivery succeeded.
- 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.
- 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_idto thesendMessagebody. - 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-idheader, as in Receiving webhooks.
| Telegram reference | Link |
|---|---|
sendMessage | core.telegram.org |
| HTML formatting | core.telegram.org |
getUpdates | core.telegram.org |
| BotFather | core.telegram.org |
| Rate limits | core.telegram.org |
Stuck on a step, or want a guide for another tool? Email hello@ikiform.com.