Why notify a form to Telegram
Email is fine for archive. It is not fine for first response. Most contact forms need a tap on a phone, and Telegram delivers that tap in under a second across iOS, Android, desktop, and web — without a spam filter sitting between you and the lead.
For an agency owner triaging inbound work, a freelance dev pricing a project, or a small team running a side product, Telegram pushes are the cheapest possible "new lead" alert. The integration takes about ten minutes and costs zero dollars.
We'll wire up splitforms as the form backend (any contact form, any framework — see the templatesfor HTML, React, and Next.js starters), put a tiny webhook proxy in front of Telegram's Bot API, and send formatted messages to a chat of your choice.
Step 1 — Create a bot with @BotFather
In any Telegram client search for @BotFather — the official Telegram bot for managing other bots. Open the chat and run:
/newbot
# BotFather will ask:
# 1. A name (shown in chat headers): "Splitforms Notifier"
# 2. A username (must end in "bot"): "splitforms_notifier_bot"
# It will respond with a token like:
# 7283910482:AAH1xN9fK_qPbZyR8tLwQq3VxYpJzD0eBcMCopy the token. Treat it like a password — anyone holding it can post to any chat the bot is in. While you're still in BotFather, you can also run /setdescription, /setuserpic, and /setcommands to make the bot look real. None of that is required for notifications to work.
Step 2 — Get your chat ID
Telegram's API addresses every chat with a numeric ID, not a username. To find yours:
- Open the bot in Telegram (search for the username you picked in step 1) and tap Start. Send any message — "hello" is fine.
- In a browser, visit
https://api.telegram.org/bot<TOKEN>/getUpdates, replacing<TOKEN>with your bot token. - Look for the
"chat": { "id": ... }field in the response. That number is your personal chat ID.
# Example response (trimmed)
{
"ok": true,
"result": [{
"update_id": 502019384,
"message": {
"chat": { "id": 184729103, "first_name": "Raman", "type": "private" },
"text": "hello"
}
}]
}
# Your chat ID is 184729103 — save it.If result is an empty array, you forgot to send a message to the bot first. Send one and reload.
Step 3 — Build the webhook proxy
splitforms can post to any HTTPS endpoint. We'll deploy a small Cloudflare Worker that receives the splitforms payload, formats a Telegram message, and forwards it to sendMessage. Cloudflare Workers are free for the first 100k requests per day — far more than any contact form needs.
Create a new worker (npx wrangler init telegram-form-proxy) and replace src/index.ts with:
// Cloudflare Worker: splitforms → Telegram Bot API
// Env vars (set with: wrangler secret put TELEGRAM_TOKEN, etc.):
// TELEGRAM_TOKEN - bot token from @BotFather
// TELEGRAM_CHAT_ID - numeric chat ID from getUpdates
// SPLITFORMS_SECRET - shared HMAC secret (optional but recommended)
interface Env {
TELEGRAM_TOKEN: string;
TELEGRAM_CHAT_ID: string;
SPLITFORMS_SECRET?: string;
}
interface SplitformsPayload {
form_id: string;
submitted_at: string;
fields: Record<string, string>;
spam_score?: number;
}
const escapeHtml = (s: string) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.method !== "POST") return new Response("POST only", { status: 405 });
const raw = await req.text();
// Optional: verify splitforms HMAC signature
if (env.SPLITFORMS_SECRET) {
const sigHeader = req.headers.get("X-Splitforms-Signature") ?? "";
const parts = Object.fromEntries(
sigHeader.split(",").map((kv) => kv.split("=") as [string, string]),
);
const signed = `${parts.t}.${raw}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(env.SPLITFORMS_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const digest = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(signed),
);
const expected = [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (parts.v1 !== expected) return new Response("bad signature", { status: 401 });
}
const payload = JSON.parse(raw) as SplitformsPayload;
const { fields } = payload;
const lines = Object.entries(fields).map(
([k, v]) => `<b>${escapeHtml(k)}:</b> ${escapeHtml(String(v))}`,
);
const text = [`<b>New form submission</b>`, "", ...lines].join("\n");
const tgRes = 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,
parse_mode: "HTML",
disable_web_page_preview: true,
}),
},
);
if (!tgRes.ok) {
return new Response(`telegram error: ${await tgRes.text()}`, { status: 502 });
}
return new Response("ok", { status: 200 });
},
};Set the secrets and deploy:
wrangler secret put TELEGRAM_TOKEN
# paste 7283910482:AAH1xN9fK_qPbZyR8tLwQq3VxYpJzD0eBcM
wrangler secret put TELEGRAM_CHAT_ID
# paste 184729103
wrangler secret put SPLITFORMS_SECRET
# paste any 32+ char random string
wrangler deploy
# → https://telegram-form-proxy.<your-account>.workers.devThe Vercel Edge equivalent is identical — drop the same handler into app/api/telegram/route.ts, swap env for process.env, and the runtime is the same.
Step 4 — Wire splitforms to the proxy
Open the form in your splitforms dashboard, click Webhooks → Add, and paste the worker URL. Set the same shared secret you stored as SPLITFORMS_SECRET so signature verification works.
Webhook URL: https://telegram-form-proxy.<account>.workers.dev
Method: POST
Format: JSON
Secret: <same 32+ char string you put in the worker>
Events: submission.createdSave, click Send test event, and you should see a message appear in your Telegram chat within about 400ms. If nothing arrives, check the worker logs (wrangler tail) for the actual error — usually a missing secret or a bad chat ID.
Step 5 — Test with curl
Bypass splitforms entirely and pretend to be it:
curl -X POST https://telegram-form-proxy.<account>.workers.dev \
-H "Content-Type: application/json" \
-d '{
"form_id": "frm_test",
"submitted_at": "2026-05-02T12:00:00.000Z",
"fields": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Hi there — testing the Telegram pipeline."
}
}'
# Expect: HTTP 200, body "ok"
# Expect: message arrives in Telegram within ~400msIf you set SPLITFORMS_SECRET the curl call above will return 401, which is the correct behaviour — only signed requests get through. Comment out the verification block while debugging, or generate a valid signature:
# Generate a signature header for ad-hoc curl tests
TS=$(date +%s)
BODY='{"form_id":"frm_test","fields":{"name":"Ada","email":"ada@example.com"}}'
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SPLITFORMS_SECRET" | awk '{print $2}')
curl -X POST https://telegram-form-proxy.<account>.workers.dev \
-H "Content-Type: application/json" \
-H "X-Splitforms-Signature: t=${TS},v1=${SIG}" \
-d "$BODY"Posting to groups and channels
For team notifications you usually want a group chat instead of a private one.
- Add the bot to the group as a member. Telegram → group → menu → add member → search for your bot username.
- Disable the "privacy mode" setting in BotFather (
/setprivacy → Disable) so the bot can read messages, otherwisegetUpdatesstays empty for groups. - Send any message in the group, then call
getUpdatesagain. The chat ID will be a negative number, e.g.-1001234567890. - Use that ID in
TELEGRAM_CHAT_ID.
For broadcast channels (announcements, no replies), promote the bot to admin and use the channel's numeric ID — same getUpdates trick, after forwarding any channel message to @JsonDumpBot.
Need to send to multiple chats? Either deploy two webhooks pointing at two workers, or store an array of chat IDs in the worker and loop. The latter is cheaper at scale and reuses one Cloudflare KV namespace for routing rules — see the lead notifications use case for the full multi-channel pattern, and the splitforms docs for the webhook payload spec.
Tech support / troubleshooting
- getUpdates returns an empty result array. You forgot to send the bot a message first. In a private chat with the bot, type anything, then reload the URL.
- 401 Unauthorized from sendMessage. The bot token is wrong, expired, or has whitespace. Re-issue the token via @BotFather (
/token) and update the worker secret. - 403 chat not found when posting to a group. The bot is not a member of the group, or privacy mode is enabled. Add the bot to the group and run
/setprivacy → Disablein BotFather. - splitforms says "webhook delivery failed" but Telegram messages still arrive. The worker probably returned a non-2xx status. Check
wrangler taillogs and ensure your worker returns a 200 once Telegram acknowledges the message. - Markdown / HTML rendering broken. If you use MarkdownV2 you must escape
_ * [ ] ( )with backslashes. Switch to HTML mode for fewer escaping bugs.
Next steps and where to get help
- Other notification destinations: Slack, Discord.
- Webhook payload contract and HMAC verification: /docs and /api-reference.
- Plan limits and security questions: /faq.
- The full webhooks feature page.
FAQ
Why use Telegram for form notifications instead of email?
Telegram pushes arrive in under a second on every device with no spam folder, no rendering quirks, and no inbox-rules to debug. For a solo founder or small team that already lives in Telegram, a notification appears next to your friends and family chats — you read it instantly. Email is still a useful archive; Telegram wins for response time.
Do I need a paid Telegram account or business plan?
No. The Bot API is free, has no submission limits relevant to form traffic (30 messages/second per bot to one chat is the documented cap), and works on any free Telegram account. You only need a phone number to register the underlying Telegram account that creates the bot.
Can I post to a Telegram group or channel instead of a private chat?
Yes. Add the bot to the group as a member (or as an admin in a channel), send a dummy message to the group, then call getUpdates and read the chat.id field — group IDs are negative integers like -1001234567890. Use that ID instead of your personal chat ID and the bot will post to the group.
Is the Telegram bot token sensitive?
Treat it like a password. Anyone holding the token can post to any chat the bot is in and read messages addressed to it. Store it in an environment variable, never in client-side code, and rotate it via @BotFather if it leaks.
Why use a webhook proxy instead of pointing splitforms at Telegram directly?
Telegram's Bot API expects a specific JSON shape (chat_id, text, parse_mode) and the URL embeds the bot token. Putting that token in a splitforms webhook config is fine for a private dashboard, but a 30-line Cloudflare Worker (or Vercel Edge function) gives you somewhere to format the message — pretty Markdown, bold field labels, links — and somewhere to verify the splitforms HMAC signature so a leaked URL can't be abused.
How do I format Telegram messages with bold or links?
Set parse_mode to 'MarkdownV2' or 'HTML' in the sendMessage payload. With HTML you can use <b>, <i>, <a href='...'>, and <code>; with MarkdownV2 you must escape special characters like _, *, [, and ( with backslashes. The example proxy in this post uses HTML — fewer escaping bugs.
What happens if Telegram is down?
splitforms retries any non-2xx webhook response with exponential backoff for 24 hours and reuses the X-Splitforms-Delivery ID so you can dedupe. If your proxy returns 502 because Telegram itself is unreachable, splitforms will retry. Email is still delivered in parallel as a fallback.