Build a Telegram bot on Ploy with a webhook, memory, and cron

A guide to building a serverless Telegram bot on Ploy: a webhook route with @meetploy/start, replies through the Telegram Bot API with fetch, per-chat memory in a state binding, AI answers through the built-in gateway, and a cron trigger for scheduled messages.

Abstract indigo glow on a dark background representing a serverless Telegram bot on Ploy

A Telegram bot is a small HTTP server that Telegram calls whenever someone sends a message. That makes it a perfect serverless workload, except that a useful bot also needs memory, something to do on a schedule, and somewhere to run that does not cost money while nobody is chatting.

Ploy covers all of that in one worker. In this guide you build a serverless Telegram bot on Ploy with @meetploy/start: a validated webhook route, replies through the Telegram Bot API with plain fetch, per-chat memory in a state binding, answers from the built-in AI gateway, and a cron trigger that sends a daily message. No framework beyond @meetploy/start, and nothing runs when the bot is idle.

Project setup

pnpm add @meetploy/start @meetploy/types
pnpm add -D @meetploy/cli typescript
kind: worker
build: pnpm build
out: dist
state:
STATE: default
ai: true
cron:
MORNING_DIGEST: "0 9 * * *"
  • state is a durable key-value store; the bot keeps each chat's recent messages there.
  • ai: true gives the worker env.AI.run() for replies.
  • cron fires the scheduled handler at 09:00 every day.

Create the bot with @BotFather, then add two environment variables in the Ploy project settings (and in a local .env): TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET, the latter any random string. They are available as env.vars.TELEGRAM_BOT_TOKEN and env.vars.TELEGRAM_WEBHOOK_SECRET with nothing to declare. Run pnpm ploy types so PloyEnv knows about STATE, AI, and vars.

Talking to the Telegram Bot API

The Bot API is plain HTTPS, so a worker needs only fetch:

export async function sendMessage(
token: string,
chatId: number,
text: string,
): Promise<void> {
await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text }),
});
}

The webhook route

Telegram POSTs an Update object to your webhook. The route validates the parts it uses, checks the secret header Telegram sends with every call, appends the message to the chat's memory, asks the AI gateway for a reply, and sends it back:

import { ploy, z } from "@meetploy/start";
import { sendMessage } from "./telegram.js";
interface ChatMessage {
role: "user" | "assistant";
content: string;
}
interface ChatCompletion {
choices: { message: { content: string } }[];
}
const update = z.object({
message: z
.object({
chat: z.object({ id: z.number() }),
text: z.string().optional(),
})
.optional(),
});
const worker = ploy<PloyEnv>()
.post(
"/telegram/webhook",
{ body: update, response: z.object({ ok: z.boolean() }) },
async (ctx) => {
const secret = ctx.request.headers.get("X-Telegram-Bot-Api-Secret-Token");
if (secret !== ctx.env.vars.TELEGRAM_WEBHOOK_SECRET) {
return ctx.json({ ok: false }, 401);
}
const message = ctx.body.message;
if (!message?.text) {
return { ok: true };
}
const key = `chat:${message.chat.id}`;
const history =
(await ctx.env.STATE.get<ChatMessage[]>(key, "json")) ?? [];
history.push({ role: "user", content: message.text });
const completion = await ctx.env.AI.run<ChatCompletion>("auto", {
messages: [
{
role: "system",
content: "You are a concise assistant inside a Telegram chat.",
},
...history.slice(-20),
],
});
const reply = completion.choices[0].message.content;
history.push({ role: "assistant", content: reply });
await ctx.env.STATE.put(key, JSON.stringify(history.slice(-20)), {
expirationTtl: 60 * 60 * 24 * 7,
});
await sendMessage(
ctx.env.vars.TELEGRAM_BOT_TOKEN,
message.chat.id,
reply,
);
return { ok: true };
},
)
.scheduled(async (event, env) => {
const subscribers = await env.STATE.list({ prefix: "subscriber:" });
for (const { name } of subscribers.keys) {
const chatId = Number(name.slice("subscriber:".length));
await sendMessage(
env.vars.TELEGRAM_BOT_TOKEN,
chatId,
"Good morning. Here is your daily digest.",
);
}
})
.build();
export default worker;

A few details that matter:

  • Return 200 quickly. Telegram retries updates that do not get a 2xx, so the route answers { ok: true } even for messages it ignores.
  • Memory is a list per chat under chat:<id>, trimmed to the last 20 turns and expired after a week with expirationTtl. For larger histories, swap state for a db binding and a messages table.
  • The daily digest iterates subscriber:<chatId> keys. Add a /subscribe command that does env.STATE.put(`subscriber:${chatId}`, "1") and you have opt-in scheduled messages.

Register the webhook

Deploy first so you have a URL, then tell Telegram where to send updates, including the secret it should echo in the header:

curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-d "url=https://your-bot.ploy.app/telegram/webhook" \
-d "secret_token=$TELEGRAM_WEBHOOK_SECRET"

Push to GitHub, connect the repository in the Ploy dashboard, and every later push redeploys the bot. Preview branches get their own URL, so you can point a second test bot at a feature branch.

Local development

pnpm ploy dev runs the worker with a local state store, a cron scheduler that fires MORNING_DIGEST on time, and a dashboard at http://localhost:4000 where you can inspect state keys and trigger the cron by hand. To receive real Telegram updates locally, expose the dev port with any tunnel and register that URL as the webhook; switch it back to the production URL afterwards.

Frequently Asked Questions

Why a webhook instead of long polling?

Long polling needs a process that is always running. A webhook is one HTTP request per message, which is exactly what a serverless worker is built for, and it costs nothing while the bot is idle.

Can the bot run without the AI gateway?

Yes. Drop ai: true and replace the env.AI.run() call with your own logic; the webhook, memory, and cron parts are independent.

Where should long conversations go?

Use a db binding and a messages table with chat_id, role, content, and created_at columns. The state store is for small, hot values; SQLite is for history you want to query.

Does the cron run on the Free plan?

Yes. Cron, state, and the AI gateway are platform features on every plan; each cron run has a CPU-time budget that depends on the plan (10 s on Free). Self-hosting removes the plan limits.

Get started