We ran the same workload through 42 models via AI Gateway and compared costs
/Neon Functions/Telegram bot

How to host a Telegram bot on Neon Functions

new

Receive Telegram messages, run bot commands and store data in Postgres

Beta

The Neon Functions is in Beta. Share your feedback on Discord or via the Neon Console.

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

This guide uses Telegram webhooks. It doesn't run a polling process with getUpdates. Telegram disables getUpdates while a webhook is active.

Prerequisites

  • A Neon project in AWS US East (Ohio) (aws-us-east-2) or AWS Europe (Frankfurt) (aws-eu-central-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 auth.
  • Node.js 24 (node -v). Deployed functions run on nodejs24, 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

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

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

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-bot

See 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:

neon.ts
import { defineConfig } from "@neon/config/v1";

export default defineConfig({
  preview: {
    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

Bootstrap scaffolds a .env.example but no .env.local. Copy the example first, then link so Neon merges the branch's variables into it:

cp .env.example .env.local
neon link

Linking 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 32

Uncomment 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:

.env.local
# 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

If neon bootstrap installed dependencies, you can deploy now. If you copied the source by hand, run npm install first.

The deploy script runs neon deploy --env .env.local, which evaluates neon.ts with your Telegram secrets in process.env:

npm run deploy

The 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.

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:

.env.local
TELEGRAM_WEBHOOK_URL=https://br-cool-darkness-123456-telegram.compute.us-east-2.aws.neon.tech/api/webhook

Register that URL and your webhook secret with Telegram:

npm run set:webhook

The 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

Register the command menu with Telegram:

npm run register:commands

The 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

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:webhook returned true.
  • Check that TELEGRAM_WEBHOOK_URL ends with /api/webhook.
  • Check that TELEGRAM_WEBHOOK_SECRET matches the value used in the latest deployment.
  • Redeploy after changing TELEGRAM_BOT_TOKEN or TELEGRAM_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

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

Check X-Telegram-Bot-Api-Secret-Token before processing an update. The template compares it with TELEGRAM_WEBHOOK_SECRET using Node.js timingSafeEqual.

The handler verifies the secret before parsing an update. It handles callback queries, tracks recognized commands without delaying replies and sends Unknown command. Try /help. for unsupported commands. See the complete handler:

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 });
}

Next steps

The template includes more than /ping:

  • /info: shows the Node.js version, platform, request method, function URL and Neon branch.
  • /help: lists the commands defined by the template's shared command list.
  • /buttons: shows an inline keyboard with refresh, echo, time and confirm callbacks.
  • /name <your name>: stores a display name. /name without an argument shows the stored name.
  • /profile: shows the 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.

neon link wrote DATABASE_URL into .env.local, so create the tables any time after that:

npm run db:push

The template gives /name and /profile 2.5 seconds for a database response. If a cold Postgres compute misses that deadline, the bot returns Warming up. Wait a moment, then run the command again.

Sibling bot templates: Discord and WhatsApp.

Example

Create a copy of the example with the Neon CLI:

neon bootstrap my-telegram-bot --template telegram-bot-http
cd my-telegram-bot

You can find the example in bots/telegram-bot-http.

Need help?

Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.

Was this page helpful?
Edit on GitHub