A production-ready Telegram bot starter built on Cloudflare Workers and Telegraf. Zero servers, zero cold starts, deployed globally at the edge.
- Edge-first — Runs on Cloudflare's global network with near-zero latency
- Webhook-based — No polling; Telegram pushes updates to your Worker
- Auto webhook registration — Visit your Worker URL and the webhook is set up automatically
- TypeScript — Full type safety out of the box
- Cron-ready — Scheduled jobs support built in (commented out, ready to enable)
- KV-ready — Cloudflare KV namespace bindings pre-configured in comments
- Minimal dependencies — Only Telegraf; no Express, no Axios, no bloat
telebot/
├── worker.ts # Cloudflare Worker entry point (fetch + scheduled handlers)
├── src/
│ ├── bot/
│ │ ├── index.ts # Bot class — webhook setup, update handling
│ │ └── commands.ts # Command handlers (/start, /help, messages)
│ ├── config/
│ │ └── index.ts # Environment config factory
│ └── utils/
│ └── logger.ts # Structured logging utility
├── wrangler.toml # Cloudflare Workers configuration
├── tsconfig.json # TypeScript configuration
├── package.json # Dependencies and scripts
├── .dev.vars.example # Example local secrets file
└── .gitignore
- Node.js v18+ (or Bun)
- A Cloudflare account
- A Telegram bot token from @BotFather
git clone https://github.com/Adedoyin-Emmanuel/telebot.git
cd telebot
npm installCreate a .dev.vars file for local development:
cp .dev.vars.example .dev.varsEdit .dev.vars and add your bot token:
BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
npm run devWrangler starts a local dev server. Visit the URL shown in the terminal (e.g., http://localhost:8787) to auto-register the webhook.
Note: For local webhook testing, you'll need a tunnel like cloudflared or Outray to expose your local server to Telegram. Simply install Outray, and run
outray 8787, it generates a url tunnel, visit that tunnel url to auto register the webhook.
Set your bot token as a secret in Cloudflare:
npx wrangler secret put BOT_TOKENThen deploy:
npm run deployVisit your deployed Worker URL (e.g., https://telebot.<your-subdomain>.workers.dev) once to register the webhook. Done — your bot is live.
Telegram servers
│
▼
POST /webhook ──► Cloudflare Worker (worker.ts)
│
▼
Bot.handleWebhook()
│
▼
Telegraf routes to command handlers
│
▼
/start, /help, message handlers (commands.ts)
When you hit GET / or GET /webhook, the Worker:
- Checks the current webhook URL via
getWebhookInfo() - If it doesn't match the Worker URL, deletes the old webhook and sets the new one
- This is idempotent — safe to call multiple times
| Method | Path | Description |
|---|---|---|
| GET | / |
Register webhook + status page |
| POST | /webhook |
Receive Telegram updates |
| GET | /webhook |
Register webhook manually |
| GET | /health |
Health check endpoint |
Edit src/bot/commands.ts to add new commands:
import type { Context, Telegraf } from "telegraf";
export function registerCommands(bot: Telegraf): void {
bot.command("start", handleStart);
bot.command("help", handleHelp);
bot.command("ping", handlePing); // Add new commands here
bot.on("message", handleMessage);
bot.catch((err, ctx) => {
logger.error("Bot error", { error: err, update: ctx.update });
});
}
async function handlePing(ctx: Context): Promise<void> {
await ctx.reply("Pong!");
}import { Markup } from "telegraf";
async function handleLinks(ctx: Context): Promise<void> {
await ctx.reply("Check these out:", {
reply_markup: {
inline_keyboard: [
[
Markup.button.url("Website", "https://example.com"),
Markup.button.url("GitHub", "https://github.com"),
],
[
Markup.button.callback("Click me", "button_clicked"),
],
],
},
});
}
// Handle callback queries from inline buttons
bot.action("button_clicked", async (ctx) => {
await ctx.answerCbQuery("You clicked the button!");
await ctx.reply("Button was clicked.");
});async function handlePhoto(ctx: Context): Promise<void> {
// Send a single photo
await ctx.replyWithPhoto("https://example.com/image.jpg", {
caption: "Here's a photo!",
});
// Send a media group (album)
await ctx.replyWithMediaGroup([
{ type: "photo", media: "https://example.com/1.jpg", caption: "Photo 1" },
{ type: "photo", media: "https://example.com/2.jpg", caption: "Photo 2" },
]);
}async function handleFormatted(ctx: Context): Promise<void> {
// Markdown
await ctx.reply("*Bold* _Italic_ `Code` [Link](https://example.com)", {
parse_mode: "Markdown",
});
// HTML
await ctx.reply("<b>Bold</b> <i>Italic</i> <code>Code</code>", {
parse_mode: "HTML",
});
}To run tasks on a schedule (e.g., send daily digests, clean up data):
[triggers]
crons = ["0 9 * * *"] # Every day at 9:00 AM UTCasync scheduled(
event: ScheduledEvent,
env: Env,
ctx: ExecutionContext
): Promise<void> {
const config = getConfig(env);
const bot = new Bot(config);
switch (event.cron) {
case "0 9 * * *":
await bot.telegram.sendMessage(CHAT_ID, "Good morning!");
break;
}
},| Expression | Schedule |
|---|---|
* * * * * |
Every minute |
0 * * * * |
Every hour |
0 9 * * * |
Every day at 9 AM UTC |
0 9 * * 1 |
Every Monday at 9 AM UTC |
0 */6 * * * |
Every 6 hours |
0 9 1 * * |
First day of month, 9 AM |
KV lets you store and retrieve data across requests — useful for user state, caching, feature flags, etc.
npx wrangler kv namespace create MY_KV[[kv_namespaces]]
binding = "MY_KV"
id = "your-namespace-id-from-step-1"export interface Env {
BOT_TOKEN: string;
MY_KV: KVNamespace;
}// Store a value
await env.MY_KV.put("user:123:name", "Alice");
// Read a value
const name = await env.MY_KV.get("user:123:name");
// Store JSON
await env.MY_KV.put("user:123", JSON.stringify({ name: "Alice", score: 42 }));
const user = JSON.parse(await env.MY_KV.get("user:123") || "{}");
// Delete a value
await env.MY_KV.delete("user:123:name");
// Set with expiration (TTL in seconds)
await env.MY_KV.put("session:abc", "data", { expirationTtl: 3600 });To use KV inside command handlers, pass the
envobject through the config or directly to your Bot class.
| Variable | Type | Required | Description |
|---|---|---|---|
BOT_TOKEN |
Secret | Yes | Telegram bot token from @BotFather |
Plain variables go in wrangler.toml:
[vars]
APP_URL = "https://example.com"Secrets are set via the CLI:
npx wrangler secret put MY_SECRETAccess both the same way in your Worker:
export interface Env {
BOT_TOKEN: string;
APP_URL: string;
MY_SECRET: string;
}Run tests with Vitest:
npm testThe project is preconfigured with @cloudflare/vitest-pool-workers for testing Workers-specific APIs (KV, scheduled events, etc.).
After deploying, set the BOT_TOKEN secret in your Cloudflare dashboard:
Workers & Pages → telebot → Settings → Variables and Secrets → Add → Name: BOT_TOKEN, Value: your token.
Then visit your Worker URL once to register the webhook.
# Set the bot token secret
npx wrangler secret put BOT_TOKEN
# Deploy
npm run deploy
# Visit the Worker URL to register the webhook
curl https://telebot.<your-subdomain>.workers.dev- Telegraf Documentation
- Telegram Bot API
- Cloudflare Workers Docs
- Wrangler CLI
- Cloudflare KV
- Cron Triggers
MIT