Blog
Practical notes on design, product, and shipping for startups.
Practical notes on design, product, and shipping for startups.
Build a Telegram ops bot for your startup with BotFather, a secured Next.js webhook, idempotent update_id storage, a schema-checked LLM step, and approval buttons for risky actions. Includes TypeScript code, a risk table, and common failure fixes.
To build a Telegram bot that automates ops for a startup, create the bot with BotFather, receive updates through a secured HTTPS webhook, store each update_id once, acknowledge fast, and do the real work in a background step. Use an LLM only to turn messy messages into structured actions, and keep a human approval button on anything risky.
Last updated: October 22, 2026.
An ops bot is a small service that sits in the chat your team already uses and turns messages into actions. It is not a chatbot that makes small talk. Good first jobs are the repetitive ones:
| Ops job | What the team types | What the bot does |
|---|
| Expense logging | "Paid 49 USD for Figma, card ending 1234" | Extracts amount, vendor, and category, writes a row, asks for a receipt photo |
| Task capture | "@opsbot remind Sam to renew the domain Friday" | Creates a task in your tracker with an owner and due date |
| Approvals | "Refund order 8812, customer was double charged" | Posts an Approve / Reject button to a manager, then calls the payment API only after approval |
| Daily digest | Nothing, it runs on a schedule | Posts yesterday's signups, failed payments, and open tickets at 9:00 |
| Lookups | "/order 8812" | Fetches the order from your database and replies with status |
Pick one job that happens at least daily and annoys someone. A bot that does one thing reliably gets used. A bot that tries to do ten things gets muted.
Telegram's Bot API is free, well documented, and works over plain HTTPS with no SDK required. Bots can join groups, send inline keyboards, receive files and photos, and run on a webhook you host anywhere. If your team lives in Slack instead, the same architecture applies, with Slack events and Block Kit in place of Telegram updates and inline keyboards.
Telegram --HTTPS POST--> /api/telegram (Next.js route)
| 1. check secret header
| 2. insert update_id (unique)
| 3. return 200 immediately
v
background step
| 4. parse or classify the message
| 5. risky? ask for approval button
| 6. run the action (DB, tracker, payments)
v
sendMessage back to the chatEach piece has a reason. The secret header stops strangers from posting fake updates. The unique update_id stops Telegram retries from running the same action twice. The fast 200 keeps Telegram from retrying in the first place. The background step lets slow work, like an LLM call, happen without holding the webhook open.
/newbot, and pick a name and a username ending in bot. BotFather returns a token. Treat it like a password./setprivacy to BotFather and disable privacy mode. With privacy mode on, the bot only sees commands, mentions, and replies to it, which is often what you want anyway.curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-d "url=https://yourapp.com/api/telegram" \
-d "secret_token=$TELEGRAM_WEBHOOK_SECRET" \
-d 'allowed_updates=["message","callback_query"]'Telegram then includes your secret in the X-Telegram-Bot-Api-Secret-Token header on every request. Webhooks must use HTTPS on port 443, 80, 88, or 8443, which any normal host like Vercel already gives you.
Here is a minimal Next.js App Router handler in TypeScript. It verifies the secret, records the update once, and hands the work off after responding.
// app/api/telegram/route.ts
import { after } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { handleUpdate } from "@/lib/bot/handle-update";
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
export async function POST(req: Request) {
if (req.headers.get("x-telegram-bot-api-secret-token") !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return new Response("forbidden", { status: 403 });
}
const update = await req.json();
const { error } = await db
.from("telegram_updates")
.insert({ update_id: update.update_id, payload: update });
if (error?.code === "23505") return new Response("ok"); // duplicate delivery, already handled
if (error) return new Response("db error", { status: 500 }); // let Telegram retry
after(() => handleUpdate(update)); // runs after the response is sent
return new Response("ok");
}The table behind it is small:
create table telegram_updates (
update_id bigint primary key,
payload jsonb not null,
status text not null default 'received',
received_at timestamptz not null default now()
);after() is available in recent Next.js versions and works on Vercel. If your host cannot run work after the response, write the row and let a queue or a cron worker pick up rows where status = 'received'. That version is also easier to retry and inspect.
You only need one small helper for most bots:
// lib/bot/telegram.ts
const API = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}`;
export async function tg(method: string, body: Record<string, unknown>) {
const res = await fetch(`${API}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) throw new Error(`Telegram ${method} failed: ${data.description}`);
return data.result;
}
export const reply = (chatId: number, text: string, extra: object = {}) =>
tg("sendMessage", { chat_id: chatId, text, ...extra });An approval request is a message with an inline keyboard:
await reply(managerChatId, `Refund 42.00 USD on order 8812?\nReason: double charge`, {
reply_markup: {
inline_keyboard: [[
{ text: "Approve", callback_data: `refund:approve:${actionId}` },
{ text: "Reject", callback_data: `refund:reject:${actionId}` },
]],
},
});Keep callback_data short, because Telegram caps it at 64 bytes. Store the full action in your database and put only its id in the button. When the button is pressed you receive a callback_query update. Call answerCallbackQuery so the spinner stops, check that the presser is allowed to approve, then run the action.
Use the model for one job: turning a free-text message into a structured, validated action. Do not let it call your payment API directly.
// lib/bot/classify.ts
import { z } from "zod";
export const Action = z.discriminatedUnion("type", [
z.object({ type: z.literal("expense"), amount: z.number(), currency: z.string().length(3), vendor: z.string() }),
z.object({ type: z.literal("task"), title: z.string(), assignee: z.string().nullable(), due: z.string().nullable() }),
z.object({ type: z.literal("refund"), orderId: z.string(), amount: z.number(), reason: z.string() }),
z.object({ type: z.literal("unknown") }),
]);
export async function classify(text: string) {
const raw = await callModelForJson({
system: "Convert the ops message into one JSON action. If unsure, return {\"type\":\"unknown\"}. Never guess amounts.",
user: text,
});
const parsed = Action.safeParse(raw);
return parsed.success ? parsed.data : { type: "unknown" as const };
}callModelForJson is whatever structured-output call your provider supports. The important parts are the schema check and the "unknown" escape hatch. Then route by risk:
| Action | Risk | Rule |
|---|---|---|
| Lookup, digest | Read only | Run immediately |
| Expense log, task create | Reversible write | Run, then reply with an Undo button |
| Refund, payout, deleting data, emailing a customer | Hard to reverse | Always ask a named approver with a button |
| Unknown | None | Reply "I could not tell what to do" and log the message as a future test case |
This keeps the model useful without making it the last line of defense.
chat.id and from.id against a table of known team members before doing anything. Anyone can find and message a public bot.| Symptom | Cause | Fix |
|---|---|---|
| The same task is created twice | Webhook retries or a slow handler | Unique update_id, return 200 fast |
| Bot ignores group messages | Privacy mode is on | Mention the bot, use commands, or disable privacy in BotFather |
| Nothing arrives at all | Wrong URL, bad TLS, or a stale webhook | Call getWebhookInfo and read last_error_message |
| Buttons spin forever | No answerCallbackQuery call | Answer every callback, even on errors |
| Amounts are wrong | The model guessed | Require explicit amounts in the schema and ask a follow-up when missing |
A single-job bot, such as expense logging or a daily digest, is usually a few days of focused work for someone comfortable with TypeScript and a hosted database. Approvals, multiple jobs, and integrations with tools like Linear, Notion, or Stripe add time, mostly in permissions and edge cases rather than in the Telegram parts.
Luminix Studio builds web and mobile apps and the AI automations around them for startups worldwide, including Telegram and Slack bots, content pipelines, internal tools, and agent loops. The shape above is the one we reach for: a verified webhook, idempotent updates, a schema-checked model step, and human approval on anything that moves money or data. If you want an ops bot for your team, or want an existing one made reliable, see luminixstudio.com.