How to host a Telegram bot on Neon Functions
Telegram can send bot updates to an HTTPS webhook. A Neon Function can receive those updates, run bot commands and query Postgres from the same branch. The template verifies the secret token on each i...
Telegram can send bot updates to an HTTPS webhook. A Neon Function can receive those updates, run bot commands and query Postgres from the same branch.
The template verifies the secret token on each incoming update, handles messages and processes inline keyboard callbacks. See Telegram's Bot API when you add other update types.
Webhook delivery only
Section titled “Webhook delivery only”This guide uses Telegram webhooks. It doesn't run a polling process with getUpdates. Telegram disables getUpdates while a webhook is active.
Prerequisites
Section titled “Prerequisites”- A Neon project in AWS US East (Ohio) (
aws-us-east-2), AWS US East (N. Virginia) (aws-us-east-1), AWS Europe (Frankfurt) (aws-eu-central-1), or AWS Asia Pacific (Singapore) (aws-ap-southeast-1). Support is expanding toward all regions. See Get started with Neon Functions. - The latest Neon CLI, installed and authenticated. Upgrade with
npm install -g neon@latest, then see CLI login. - Node.js 24 (
node -v). Deployed functions run onnodejs24, so 24 locally is the closest match. Node.js 20+ works. - A Telegram account.
The template uses npm. Its scripts load Telegram values from .env.local, the same convention as Next.js and vercel env pull.
Create a Telegram bot
Section titled “Create a Telegram bot”Open BotFather in Telegram and send /newbot. BotFather asks for a display name and username. Usernames must be 5 to 32 characters and end in bot. They can contain Latin letters, numbers and underscores. For example, use example_neon_bot. You can't change the username later.
After you choose a username, BotFather sends you an authentication token and a link to the bot. Save both for later.
warning
Section titled “warning”Treat the bot token like a password. Anyone who has it can control your bot. Never commit it or paste it into screenshots, tickets or chat. If it leaks, use /token in BotFather to replace it.
For more BotFather options, see Creating a new bot.
Scaffold the project
Section titled “Scaffold the project”neon bootstrap copies the Telegram example into a new directory and prompts you to install dependencies and set up the project. Accept the prompts. Pass --no-link to skip linking for now; you'll link in the next step, after creating .env.local, so Neon writes its variables straight into that file. The template ID is telegram-bot-http:
neon bootstrap my-telegram-bot --template telegram-bot-http --no-link
cd my-telegram-botSee neon bootstrap for flags. Later commands in this guide assume you're in that directory.
To start from the source instead, copy bots/telegram-bot-http from the examples repo, cd into it, then install dependencies and run neon link.
neon.ts declares the telegram function and passes the bot token and webhook secret to it:
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
telegram: {
name: "Telegram webhook",
source: "./functions/telegram.ts",
env: {
TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN!,
TELEGRAM_WEBHOOK_SECRET: process.env.TELEGRAM_WEBHOOK_SECRET!,
},
dev: {
port: 8787,
},
},
},
});The key telegram is the function slug. It's permanent after the first deploy and appears in CLI commands and the invocation URL. See the neon.ts reference.
This walkthrough deploys the function before connecting Telegram. Telegram requires a public HTTPS webhook, so it can't reach localhost unless you use a tunnel.
Add Telegram secrets
Section titled “Add Telegram secrets”Bootstrap scaffolds a .env.example but no .env.local. Install dependencies, copy the example, then link. neon link loads neon.ts to pull the branch's variables, so the packages must be installed first:
npm install
cp .env.example .env.local
neon linkLinking merges DATABASE_URL, DATABASE_URL_UNPOOLED, NEON_BRANCH, and NEON_FUNCTION_TELEGRAM_BASE_URL (your function's public URL, ready before you deploy) into .env.local, leaving the Telegram keys untouched.
Generate a webhook secret:
openssl rand -hex 32Uncomment and set TELEGRAM_BOT_TOKEN (the BotFather token) and TELEGRAM_WEBHOOK_SECRET (the value you just generated). Leave TELEGRAM_WEBHOOK_URL empty until you deploy:
# Required. Add real values before deploying; a missing key throws at deploy, an empty one uploads "".
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_WEBHOOK_SECRET=
# Local only, used by the set:webhook script. Set after you deploy.
TELEGRAM_WEBHOOK_URL=
# Written into `.env.local` by `neon link`.
NEON_BRANCH=
DATABASE_URL=
DATABASE_URL_UNPOOLED=
NEON_FUNCTION_TELEGRAM_BASE_URL=The webhook secret can contain 1 to 256 characters from A-Z, a-z, 0-9, _ and -. The openssl command above produces a valid value.
Leave the pulled NEON_* and DATABASE_URL* values as written. See Environment variables.
Only the local webhook setup script reads TELEGRAM_WEBHOOK_URL. Neon doesn't pass it to the function.
Deploy the function
Section titled “Deploy the function”The deploy script runs neon deploy --env .env.local, which evaluates neon.ts with your Telegram secrets in process.env:
npm run deployThe CLI applies the neon.ts policy, bundles the function and waits for the deployment to finish. You'll see Applied changes and a Function URLs list. If the deployment fails, check function logs and Deploy and manage functions.
Apply the database schema
Section titled “Apply the database schema”npm run db:pushneon link wrote DATABASE_URL into .env.local, so you can apply the schema any time after linking. /ping works without the tables; /name and /profile need them.
Set the webhook
Section titled “Set the webhook”Telegram sends updates to /api/webhook. Your webhook URL is NEON_FUNCTION_TELEGRAM_BASE_URL (from .env.local) with /api/webhook appended. Set TELEGRAM_WEBHOOK_URL to it:
TELEGRAM_WEBHOOK_URL=https://br-cool-darkness-123456-telegram.compute.us-east-2.aws.neon.tech/api/webhookRegister that URL and your webhook secret with Telegram:
npm run set:webhookThe script calls Telegram's setWebhook method with the URL, webhook secret and the message and callback_query update types. Telegram returns true when it accepts the webhook.
The function URL must use HTTPS. Telegram includes your secret in the X-Telegram-Bot-Api-Secret-Token header on every update.
If you change the webhook secret, run npm run deploy first so the function has the new value. Then run npm run set:webhook again so Telegram sends the same value.
Open the webhook URL in a browser to check that the function is available. A GET request returns:
{
"ok": true,
"service": "telegram-webhook",
"webhookPath": "/api/webhook",
"webhookUrl": "https://br-cool-darkness-123456-telegram.compute.us-east-2.aws.neon.tech/api/webhook"
}ok: true confirms only that the function is reachable at that URL. webhookUrl echoes the public URL you requested; registering it with Telegram is still a separate step (npm run set:webhook). This GET doesn't verify the webhook secret or confirm webhook registration. A true result from npm run set:webhook confirms registration. Sending /ping verifies end-to-end delivery.
Register bot commands
Section titled “Register bot commands”Register the command menu with Telegram:
npm run register:commandsThe script calls Telegram's setMyCommands method with /ping, /info, /help, /buttons, /name and /profile. On success it prints true.
Run this command again after you change the command list.
Try /ping
Section titled “Try /ping”Open the bot link that BotFather gave you. If Telegram shows a Start button, click it. Then send /ping. The bot replies with Pong and an estimated webhook latency.
The template doesn't register a /start command, so tapping Start may return Unknown command. Send /ping instead.
If the bot doesn't reply:
- Confirm that
npm run set:webhookreturnedtrue. - Check that
TELEGRAM_WEBHOOK_URLends with/api/webhook. - Check that
TELEGRAM_WEBHOOK_SECRETmatches the value used in the latest deployment. - Redeploy after changing
TELEGRAM_BOT_TOKENorTELEGRAM_WEBHOOK_SECRET. - Check function logs for an invalid webhook secret, invalid update payload or Telegram API error.
Telegram retries webhook updates when your endpoint doesn't return a 2xx response.
The template doesn't deduplicate Telegram update_id values. If you add a command with side effects, make it safe to run more than once.
How it works
Section titled “How it works”functions/telegram.ts handles GET and POST requests. GET returns the webhook URL. POST checks the secret header, validates the Telegram update and dispatches messages or button callbacks.
Verify the webhook secret
Section titled “Verify the webhook secret”Check X-Telegram-Bot-Api-Secret-Token before processing an update. The template compares it with TELEGRAM_WEBHOOK_SECRET using Node.js timingSafeEqual.
The POST path verifies the secret before parsing the update:
const isVerified = verifyTelegramRequest(
getTelegramWebhookSecret(),
request.headers.get("x-telegram-bot-api-secret-token"),
);
if (!isVerified) {
return jsonResponse({ error: "invalid webhook secret" }, { status: 401 });
}
let payloadBody: unknown;
try {
payloadBody = await request.json();
} catch {
return jsonResponse({ error: "invalid json" }, { status: 400 });
}It handles callback queries, tracks recognized commands without delaying replies and sends Unknown command. Try /help. for unsupported commands.
View full code: functions/telegram.ts
import { TELEGRAM_WEBHOOK_PATH } from "../src/constants/telegram.js";
import { getTelegramBotToken, getTelegramWebhookSecret } from "../src/env.js";
import { telegramUpdateSchema } from "../src/schemas/telegram.js";
import { commandHandlers, trackTelegramCommandRun } from "../src/utils/telegramCommands.js";
import {
createButtonTestClickMessage,
parseButtonTestCallbackData,
} from "../src/utils/generalComponents.js";
import { jsonResponse } from "../src/utils/jsonResponse.js";
import {
answerTelegramCallbackQuery,
editTelegramMessageText,
sendTelegramMessage,
} from "../src/utils/telegramApi.js";
import { createErrorMessage } from "../src/utils/telegramResponses.js";
import { parseTelegramCommand } from "../src/utils/telegramText.js";
import { verifyTelegramRequest } from "../src/utils/verifyTelegramRequest.js";
const handleCallbackQuery = async (
botToken: string,
update: Awaited<ReturnType<typeof telegramUpdateSchema.parse>>,
): Promise<Response> => {
const callbackQuery = update.callback_query;
if (!callbackQuery) {
return jsonResponse({ ok: true });
}
const action = callbackQuery.data ? parseButtonTestCallbackData(callbackQuery.data) : undefined;
if (!action) {
await answerTelegramCallbackQuery({
botToken,
callbackQueryId: callbackQuery.id,
text: "That button is not handled by this bot.",
});
return jsonResponse({ ok: true });
}
if (!callbackQuery.message) {
await answerTelegramCallbackQuery({
botToken,
callbackQueryId: callbackQuery.id,
text: "The original message is not available.",
});
return jsonResponse({ ok: true });
}
const responseMessage = createButtonTestClickMessage(callbackQuery.message.chat.id, action);
await Promise.all([
answerTelegramCallbackQuery({ botToken, callbackQueryId: callbackQuery.id }),
editTelegramMessageText({
botToken,
chatId: responseMessage.chatId,
messageId: callbackQuery.message.message_id,
replyMarkup: responseMessage.replyMarkup,
text: responseMessage.text,
}),
]);
return jsonResponse({ ok: true });
};
export default async function handler(request: Request): Promise<Response> {
const url = new URL(request.url);
switch (request.method) {
case "GET":
return jsonResponse({
ok: true,
service: "telegram-webhook",
webhookPath: TELEGRAM_WEBHOOK_PATH,
webhookUrl: `${url.origin}${TELEGRAM_WEBHOOK_PATH}`,
});
case "POST":
break;
default:
return jsonResponse({ error: "method not allowed" }, { status: 405 });
}
const isVerified = verifyTelegramRequest(
getTelegramWebhookSecret(),
request.headers.get("x-telegram-bot-api-secret-token"),
);
if (!isVerified) {
return jsonResponse({ error: "invalid webhook secret" }, { status: 401 });
}
let payloadBody: unknown;
try {
payloadBody = await request.json();
} catch {
return jsonResponse({ error: "invalid json" }, { status: 400 });
}
const parsedPayload = telegramUpdateSchema.safeParse(payloadBody);
if (!parsedPayload.success) {
return jsonResponse({ error: "invalid telegram update payload" }, { status: 400 });
}
const update = parsedPayload.data;
const botToken = getTelegramBotToken();
if (update.callback_query) {
return handleCallbackQuery(botToken, update);
}
const message = update.message;
const command = parseTelegramCommand(message?.text);
if (!message || !command) {
return jsonResponse({ ok: true });
}
const commandHandler = commandHandlers[command.name];
if (commandHandler) {
void trackTelegramCommandRun(message, command.name);
const responseMessage = await commandHandler({
args: command.args,
botToken,
command: command.name,
message,
request,
update,
url,
});
await sendTelegramMessage({ botToken, ...responseMessage });
return jsonResponse({ ok: true });
}
await sendTelegramMessage({
botToken,
...createErrorMessage(message.chat.id, "Unknown command. Try /help."),
});
return jsonResponse({ ok: true });
}View it on GitHub.
Commands
Section titled “Commands”/ping: replies with Pong and an estimated webhook latency./info: Node.js version, platform, request method, function URL and Neon branch./help: lists the template's commands./buttons: inline keyboard with refresh, echo, time and confirm callbacks./name <your name>: stores a display name./namewithout an argument shows the stored name./profile: stored name, total command count and per-command usage.
The template stores Telegram user IDs, display names and usage counts in the profiles and command_usage tables. Usage tracking is best-effort and doesn't block replies. If the database misses a 2.5-second deadline, the bot replies "Warming up"; run the command again once the branch is warm.
Related templates
Section titled “Related templates”Example
Section titled “Example”Create a copy of the example with the Neon CLI:
neon bootstrap my-telegram-bot --template telegram-bot-http
cd my-telegram-botYou can find the example in bots/telegram-bot-http.
Need help?
Section titled “Need help?”Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.