Send Form Submissions to Telegram (Without Leaking Your Bot Token)
Most tutorials tell you to call the Telegram Bot API from your form's JavaScript. That publishes your bot token to everyone who views source. Here are two routes that do not, and how to find the chat ID.
The ShipMyForm team
· 5 min read
Something server-side has to hold the bot token. That is the whole problem, and every working solution is some version of solving it. A Telegram bot token goes in the request URL, so calling the Bot API from your page's JavaScript publishes it to anyone who opens devtools — and a bot token is not a restricted key, it is full control of the bot.
Full disclosure: ShipMyForm is our product, and route A below uses it. Route B is free and works with any backend, including none of ours.
The mistake almost every tutorial makes
Search for this and most of the results will show you something like:
// Do not do this.
fetch(`https://api.telegram.org/bot${TOKEN}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: CHAT_ID, text: message }),
});It works, which is why it spreads. It also means TOKEN is in your bundle, in your page source, and in the network tab of every person who ever visits. On a static site there is nowhere to hide it: environment variables at build time are inlined into the output, so process.env.TELEGRAM_TOKEN ends up in the JavaScript just the same.
What someone can do with a found token is worth being specific about, because "leaked credential" is vague enough to ignore:
- Read every message your bot can see
- Post as your bot into every chat and channel it belongs to
- Remove it from those chats, or change its name and description
- Keep doing all of that until you notice and revoke with
@BotFather
Unlike some APIs there is no public-safe variant of the credential. One token, full control.
This is the clearest example of the general case. A form on a static site needs to reach an API that requires a secret, and the browser is not a place secrets can live. A form backend is the server-side piece that holds the credential so your HTML does not have to.
The two routes at a glance
| Route A: Telegram connector | Route B: webhook + your own function | |
|---|---|---|
| Plan | Paid (Starter, $6/mo) | Free plan works |
| Setup | Paste token and chat ID | Deploy a small function |
| Who holds the token | ShipMyForm | You |
| Message formatting | Fixed layout | Whatever you write |
| Good for | Getting it done today | Full control, or avoiding a paid plan |
Finding the chat ID (the step everyone gets stuck on)
Both routes need a chat ID, and it is not shown anywhere in the Telegram interface.
- Create a bot: message
@BotFather, send/newbot, follow the prompts. It gives you a token shaped like123456789:ABCdef…. - Send at least one message in the destination chat. If it is a group or channel, add the bot first, then post something.
- Open this in a browser, with your real token:
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates - Read
result[].message.chat.idfrom the JSON.
The number's shape tells you what you are looking at:
| Chat type | Example | Note |
|---|---|---|
| Private (you) | 123456789 | Positive |
| Group | -987654321 | Negative |
| Supergroup / channel | -1001234567890 | Negative, -100 prefix |
Keep the minus sign and the -100 prefix exactly as they appear. Dropping them is the most common reason a correct-looking setup silently posts nowhere.
If getUpdates returns an empty result, the bot has not seen a message yet. Bots run with privacy mode on and only receive messages that mention or reply to them, so post something that mentions your bot, or turn privacy off with /setprivacy in @BotFather. (For sending notifications privacy mode is irrelevant — the bot only posts.)
Route A: the Telegram connector
- Point your form at ShipMyForm:
<form action="https://shipmyform.com/f/YOUR_FORM_ID" method="POST">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>-
In your form's Connectors tab, add Telegram, and paste the bot token and chat ID. The token is stored encrypted and is never sent to the browser.
-
Hit Send test. If it does not arrive, it is almost always the chat ID: check the minus sign, and check the bot is actually in the chat.
Each submission arrives as a plain message with the form name and every submitted field.
Route B: free, via the webhook connector
The webhook connector is on the free plan, so this costs nothing beyond wherever you run the function. The shape is: ShipMyForm posts the submission to your function, your function holds the token and calls Telegram.
// A Cloudflare Worker, Vercel function, or anything that runs server-side.
export default {
async fetch(request, env) {
const submission = await request.json();
const lines = Object.entries(submission.data ?? {})
.map(([k, v]) => `${k}: ${v}`)
.join("\n");
let text = `📝 ${submission.formName}\n\n${lines}`;
// Telegram rejects anything over 4096 characters with a 400 rather than
// trimming it, so an untruncated long submission fails to deliver at all.
if (text.length > 4096) text = text.slice(0, 4060) + "\n\n… truncated";
await fetch(`https://api.telegram.org/bot${env.TELEGRAM_TOKEN}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: env.TELEGRAM_CHAT_ID,
text,
disable_web_page_preview: true,
}),
});
return new Response("ok");
},
};Set TELEGRAM_TOKEN and TELEGRAM_CHAT_ID as secrets in your host, add the Webhook connector in ShipMyForm, and point it at the function's URL.
Two things worth doing in a real version: verify the request actually came from your form backend before acting on it, and return a non-2xx status on failure so delivery retries rather than being recorded as a success.
The limits that bite
4,096 characters per message. The one that catches people out, because Telegram returns 400 Bad Request: message is too long rather than trimming. A single long submission then fails to deliver and you get nothing at all. Truncate before sending — the snippet above does, and our connector does too, with a note so a clipped message is not mistaken for a short one.
Markdown parsing is opt-in, and strict. The examples here send plain text deliberately. If you set parse_mode: "MarkdownV2", Telegram requires a long list of characters to be escaped, and a stray . or - in someone's message fails the whole send. Plain text has no such failure mode.
Rate limits. Roughly 30 messages a second overall and about 20 per minute to a single group. Fine for form notifications; worth knowing if you ever fan out to many chats.
Which should you use?
Route A if you want it working in two minutes and you are paying for a backend anyway. The token sits encrypted, deliveries are retried and logged, and there is nothing to maintain.
Route B if you are on the free plan, want a custom message layout, or would rather hold the credential yourself. It is one small function and it is genuinely free.
What neither should be is the token in your page source. If you take one thing from this: the credential belongs somewhere a visitor cannot read, and that is the only part that is not negotiable.
Related: sending submissions to Discord, to Slack, and what a form backend actually is.
Frequently asked questions
- Can I call the Telegram Bot API directly from my form's JavaScript?
- You can, and you should not. The bot token has to be in the request URL, which means it is in your page source and in every visitor's devtools. Anyone who finds it can read your bot's messages, post as it in every chat it belongs to, and remove it. There is no restricted or public variant of a Telegram bot token the way there is with some APIs — it is one credential with full control. Something server-side has to hold it.
- How do I find my Telegram chat ID?
- Send any message to the chat, then open https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates in a browser and read `result[].message.chat.id`. A private chat is a positive number, a group is negative, and a supergroup or channel is negative and starts with -100. If getUpdates comes back empty, the bot has not seen a message yet: add it to the group, post something, and reload.
- Why does my bot not see messages in a group?
- Bots have privacy mode on by default, so they only receive messages that mention them or reply to them. For sending notifications this does not matter at all — the bot is only posting. It matters if you expected getUpdates to show group chatter while looking for the chat ID; send a message that mentions the bot, or disable privacy mode via @BotFather with /setprivacy.
- Is there a message length limit?
- Yes — 4,096 characters, and Telegram rejects an over-length message with a 400 error rather than trimming it. That matters because a single long submission then fails to deliver entirely. If you build this yourself, truncate before sending; ShipMyForm's Telegram connector truncates and says it did, so you lose the tail of a long message rather than the whole notification.
- Do I need a paid plan to send form submissions to Telegram?
- With ShipMyForm's built-in Telegram connector, yes — it is on the Starter plan at $6/mo. The free plan includes the webhook connector, so you can do it without paying by pointing a webhook at a small function of your own that holds the token and calls the Bot API. That route is more work and costs nothing; both are described in the article.
Related guides
How to Send Form Submissions to Discord
Get each submission into a Discord channel as a clean embed. Covers the managed connector, the free webhook + automation route, and the embed limits that silently truncate long messages.
How to Send Form Submissions to Slack
Get each form submission into Slack as a clean, formatted message. Use the managed Slack connector, or wire it up free with the webhook connector and an automation.
What Is a Form Backend? (And When You Need One)
The plain-language definition, how form endpoints work, and when a hosted backend beats rolling your own.