Building a Telegram Bot Bridge for Your Next.js App
Updated · 18 min read
Step-by-step, using SpendFlow (a real personal-finance app) as the working example. By the end you'll have a Telegram bot that lets a user add expenses, check budgets, and pull reports — straight from a chat, backed by your existing Next.js + Prisma app.
Why a bot bridge, not a rebuild
A lot of tutorials treat "add a Telegram bot" as its own little app with its own database. That's the wrong mental model once you already have a real product. The bot should be a new delivery channel for logic you already have — same database, same tenancy rules, same domain code. Telegram just becomes another way to trigger a transaction insert.
In SpendFlow, the bot reuses the exact same lib/data repositories the web app uses. The only new pieces are: a way to map a Telegram user to an app user, a webhook endpoint, and a tiny text parser. Everything else — category matching, money formatting, budget math — is already written and tested.
Tip: Rule of thumb: if you're duplicating business logic to support a new channel (bot, CLI, cron job), stop — you're building the wrong layer. Put logic in a shared domain module and give each channel a thin adapter.
Architecture overview
Here's the full request path, from a user typing in Telegram to a row in Postgres:
User types "50 coffee" → Telegram servers → POST /api/telegram → verify secret header → dedupe update_id → handleUpdate() → parseEntry() → transactionsRepo.createForUser()
Three new pieces you'll build, mirroring SpendFlow's actual folder layout:
| Piece | Path | Job |
|---|---|---|
| Route handler | app/api/telegram/route.ts | Verify + dedupe + hand off (fast, <100ms) |
| Domain module | modules/telegram/domain/ | Routing, parsing, formatting — pure, unit-testable |
| Data layer | lib/data/repos/telegram.ts | Link tokens, telegram_user_id ↔ profile mapping |
Heads up: The webhook route is a deliberate exception to "everything goes through Server Actions" — Telegram can't call a Server Action, only a plain HTTP endpoint. Keep this the only reason you reach for a route handler.
1. Create the bot with BotFather
Everything starts in Telegram itself — no code yet.
- Open Telegram, search for @BotFather, start a chat.
- Send
/newbot, give it a display name, then a username ending inbot(e.g.spendflows_bot). - BotFather replies with an API token — looks like
123456789:AAH.... This is a secret, treat it like a password. - Optional:
/setdescriptionand/setuserpicto make it look finished.
Save the token as an env var. You'll also generate your own random webhook secret — Telegram will echo it back on every request so you can verify the call really came from Telegram:
TELEGRAM_BOT_TOKEN="123456789:AAH..."
TELEGRAM_BOT_USERNAME="spendflows_bot"
TELEGRAM_WEBHOOK_SECRET="$(openssl rand -hex 32)"2. Install grammY
You could hand-roll HTTP calls to the Telegram Bot API, but grammY gives you a typed client with none of the framework baggage — no polling loop forced on you, which matters because Vercel functions don't keep a process alive to poll.
npm install grammy
npm install @vercel/functions@vercel/functions gives you waitUntil() — it lets a serverless function return a response immediately while finishing work in the background. You need this because Telegram expects a webhook reply within seconds, but your actual bot logic (DB writes, formatting) can take a bit longer.
3. Design the linking schema
The core problem a bot bridge has to solve: Telegram gives you a telegram_user_id, your app knows users by their own profiles.id. You need a mapping table, plus a safe way to create that mapping (never by asking the user to paste credentials into a chat bot).
SpendFlow uses three small tables:
// One-time deep-link tokens for account linking (10-min expiry, single use).
model TelegramLinkToken {
token String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
user Profile @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("telegram_link_tokens")
}
// telegram_user_id → profile mapping. This lookup IS the bot's auth.
model TelegramLink {
telegramUserId BigInt @id @map("telegram_user_id")
userId String @unique @map("user_id") @db.Uuid
chatId BigInt @map("chat_id")
linkedAt DateTime @default(now()) @map("linked_at") @db.Timestamptz(3)
user Profile @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("telegram_links")
}
// Webhook idempotency — Telegram can redeliver the same update_id.
model TelegramProcessedUpdate {
updateId BigInt @id @map("update_id")
processedAt DateTime @default(now()) @map("processed_at") @db.Timestamptz(3)
@@map("telegram_processed_updates")
}Tip: Why telegramUserId is a BigInt: Telegram user IDs can exceed the 32-bit signed integer range for newer accounts — use BigInt/bigint everywhere you touch them, not number/int.
Run the migration:
npx prisma migrate dev --name telegram_bot4. Write the repo layer
Follow whatever data-access convention your app already has. SpendFlow splits every domain into an interface file (types + contract, safe to import anywhere) and a Prisma implementation file (server-only, the only place touching the DB client):
export type TelegramLink = {
telegramUserId: number;
chatId: number;
linkedAt: string; // ISO timestamp
};
export interface TelegramRepo {
/** Insert a one-time link token for the user (expires in 10 minutes). */
createLinkToken(userId: string): Promise<string>;
/**
* Atomically consume a link token: returns its userId when the token
* exists and has not expired, null otherwise. The token is deleted
* either way — a link token is single-use no matter what.
*/
consumeLinkToken(token: string): Promise<string | null>;
getLinkByTelegramId(telegramUserId: number): Promise<{ userId: string } | null>;
getLinkForUser(userId: string): Promise<TelegramLink | null>;
linkUser(userId: string, telegramUserId: number, chatId: number): Promise<void>;
unlink(userId: string): Promise<void>;
/** Record an update id. Returns false when it was already processed. */
markUpdateProcessed(updateId: number): Promise<boolean>;
}import "server-only";
import { prisma } from "@/lib/db";
import { UniqueConstraintError, handlePrismaError } from "../errors";
import type { TelegramLink, TelegramRepo } from "./telegram";
const TOKEN_TTL_MS = 10 * 60 * 1000;
export const telegramRepo: TelegramRepo = {
async createLinkToken(userId) {
const row = await prisma.telegramLinkToken.create({
data: { userId, expiresAt: new Date(Date.now() + TOKEN_TTL_MS) },
});
return row.token;
},
async consumeLinkToken(token) {
// Opportunistic cleanup of expired tokens (cheap, keeps the table tiny).
await prisma.telegramLinkToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
try {
const row = await prisma.telegramLinkToken.delete({ where: { token } });
return row.expiresAt.getTime() > Date.now() ? row.userId : null;
} catch (e) {
return handlePrismaError(e); // P2025 (already consumed) -> null
}
},
async getLinkByTelegramId(telegramUserId) {
const row = await prisma.telegramLink.findUnique({
where: { telegramUserId: BigInt(telegramUserId) },
select: { userId: true },
});
return row ? { userId: row.userId } : null;
},
async linkUser(userId, telegramUserId, chatId) {
// Replace any existing link on EITHER side: the user may re-link from a
// new Telegram account, or that Telegram account may already be linked
// to a different profile. A plain upsert on the pk misses the 2nd case.
await prisma.$transaction([
prisma.telegramLink.deleteMany({
where: { OR: [{ userId }, { telegramUserId: BigInt(telegramUserId) }] },
}),
prisma.telegramLink.create({
data: { userId, telegramUserId: BigInt(telegramUserId), chatId: BigInt(chatId) },
}),
]);
},
async markUpdateProcessed(updateId) {
try {
await prisma.telegramProcessedUpdate.create({ data: { updateId: BigInt(updateId) } });
return true;
} catch (e) {
try { handlePrismaError(e); }
catch (mapped) {
if (mapped instanceof UniqueConstraintError) return false; // duplicate delivery
throw mapped;
}
return true;
}
},
// ...getLinkForUser / unlink follow the same shape
};Gotcha: Gotcha: markUpdateProcessed uses the unique-constraint violation itself as the "already seen this update" signal, instead of a SELECT-then-INSERT. That avoids a race between two overlapping webhook deliveries — Postgres decides atomically, you don't.
5. Build the webhook route
This is the only HTTP entry point Telegram talks to. Three jobs, in this exact order: verify the secret header, dedupe the update, hand off to background work and return 200 immediately.
import { timingSafeEqual } from "node:crypto";
import { waitUntil } from "@vercel/functions";
import { Bot } from "grammy";
import {
accountsRepo, budgetsRepo, categoriesRepo, profilesRepo,
telegramRepo, transactionsRepo,
} from "@/lib/data";
import { logError } from "@/lib/log";
import type { BotApi, TgUpdate } from "@/modules/telegram/domain/handlers";
import { handleUpdate } from "@/modules/telegram/domain/handlers";
export const dynamic = "force-dynamic";
function secretMatches(header: string | null): boolean {
const expected = process.env.TELEGRAM_WEBHOOK_SECRET;
if (!expected || !header) return false;
const a = Buffer.from(header);
const b = Buffer.from(expected);
// Constant-time compare — a plain === leaks timing info an attacker
// could use to guess the secret byte-by-byte.
return a.length === b.length && timingSafeEqual(a, b);
}
function makeBotApi(): BotApi {
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN ?? "");
return {
async sendMessage(chatId, text, extra) {
await bot.api.sendMessage(chatId, text, extra);
},
// ...editMessageText, answerCallbackQuery
};
}
export async function POST(req: Request) {
if (!secretMatches(req.headers.get("x-telegram-bot-api-secret-token"))) {
return new Response("unauthorized", { status: 401 });
}
let update: TgUpdate;
try {
update = (await req.json()) as TgUpdate;
} catch {
return new Response("ok"); // malformed body — nothing to retry
}
if (typeof update?.update_id !== "number") return new Response("ok");
const fresh = await telegramRepo.markUpdateProcessed(update.update_id);
if (!fresh) return new Response("ok"); // duplicate delivery, ignore
const deps = {
repos: { telegram: telegramRepo, transactions: transactionsRepo,
categories: categoriesRepo, accounts: accountsRepo,
budgets: budgetsRepo, profiles: profilesRepo },
api: makeBotApi(),
};
// Reply "ok" to Telegram NOW; do the real work after the response is sent.
waitUntil(handleUpdate(deps, update).catch((e) => logError("telegram:webhook", e)));
return new Response("ok");
}Heads up: Why waitUntil matters: without it, a slow DB call inside handleUpdate would delay your response past Telegram's timeout, and Telegram would retry the same update — which your dedupe check would then correctly (but pointlessly) drop. waitUntil keeps the function alive just long enough to finish, after the response already went out.
6. The account-linking flow
Never let a user paste a password or API key into a bot chat. Use a deep link with a one-time token instead — generated inside your already-authenticated web app, consumed the instant the user taps "Start" in Telegram.
"use server";
import { getCurrentUserId } from "@/lib/auth/session";
import { telegramRepo } from "@/lib/data";
export async function generateTelegramLink() {
const userId = await getCurrentUserId();
if (!userId) return { error: "Not signed in" };
const botUsername = process.env.TELEGRAM_BOT_USERNAME;
if (!botUsername) return { error: "Telegram bot is not configured" };
const token = await telegramRepo.createLinkToken(userId);
return { url: `https://t.me/${botUsername}?start=${token}` };
}On the bot side, /start <token> is how Telegram delivers that deep-link parameter:
async function onStart(deps: Deps, chatId: number, tgUserId: number, text: string) {
const { repos, api } = deps;
const token = text.split(/\s+/)[1];
if (!token) return api.sendMessage(chatId, "Not linked yet — connect from the app.");
const userId = await repos.telegram.consumeLinkToken(token);
if (!userId) return api.sendMessage(chatId, "That link expired. Generate a new one.");
await repos.telegram.linkUser(userId, tgUserId, chatId);
await api.sendMessage(chatId, "✅ Linked!");
}The flow end to end:
App: "Connect Telegram" → createLinkToken(userId) → opens t.me/bot?start=TOKEN → user taps Start → consumeLinkToken → linkUser
Tip: This is the exact same trust pattern OAuth uses internally (one-time code, short expiry, single consume). You don't need a heavyweight auth library for a linking flow — a UUID token with a TTL column gets you 90% of the security for 5% of the code.
7. Parse free-text quick-add
The nicest UX win: no slash commands for the common case. A user just types 50 coffee and it becomes an expense. Keep the parser pure — no I/O, no DB — so it's trivially unit-testable.
// Grammar (loose): "[+]<amount> [categoryWord] [note...]" — the amount can
// appear anywhere; the token right after it is the category guess, everything
// else becomes the note. A leading "+" means income instead of expense.
const AMOUNT_RE = /^\+?\d[\d,]*(\.\d+)?$/;
export function parseEntry(text: string, currency: string) {
const trimmed = text.trim();
if (!trimmed) return null;
const income = trimmed.startsWith("+");
const tokens = trimmed.split(/\s+/);
const amountIdx = tokens.findIndex((t) => AMOUNT_RE.test(t));
if (amountIdx === -1) return null;
const amountMinor = toMinor(tokens[amountIdx].replace(/[+,]/g, ""), currency);
if (amountMinor <= 0) return null;
const rest = tokens.filter((_, i) => i !== amountIdx);
const categoryQuery = rest[0] ?? null;
const note = rest.length > 1 ? rest.slice(1).join(" ") : null;
return { type: income ? "income" : "expense", amountMinor, categoryQuery, note };
}
/** Match a category by title: exact → prefix → substring, all case-insensitive.
* Never guesses between ties — caller falls back to the default category. */
export function matchCategory<T extends { title: string }>(cats: T[], query: string | null) {
if (!query) return null;
const q = query.trim().toLowerCase();
const titles = cats.map((c) => c.title.trim().toLowerCase());
const exact = titles.findIndex((t) => t === q);
if (exact !== -1) return cats[exact];
const prefix = titles.findIndex((t) => t.startsWith(q));
if (prefix !== -1) return cats[prefix];
const sub = titles.findIndex((t) => t.includes(q));
return sub !== -1 ? cats[sub] : null;
}Because it's pure, the test needs zero mocking:
it("parses amount + category + note", () => {
const r = parseEntry("50 coffee with friends", "USD");
expect(r).toEqual({
type: "expense", amountMinor: 5000,
categoryQuery: "coffee", note: "with friends",
});
});
it("treats a leading + as income", () => {
expect(parseEntry("+2000 salary", "USD")?.type).toBe("income");
});8. Route updates to handlers
handleUpdate is the single entry point for both messages and button-tap callbacks. Keep it a plain function that takes dependency-injected repos + a bot API interface — never import Prisma or the real grammY Bot class inside this module. That's what makes the whole thing unit-testable without a database or a live bot.
export type Repos = {
telegram: TelegramRepo; transactions: TransactionsRepo;
categories: CategoriesRepo; accounts: AccountsRepo;
budgets: BudgetsRepo; profiles: ProfilesRepo;
};
export type Deps = { repos: Repos; api: BotApi };
export async function handleUpdate(deps: Deps, update: TgUpdate) {
try {
if (update.callback_query) return await onCallback(deps, update.callback_query);
if (update.message?.text) return await onMessage(deps, update.message);
} catch (e) {
logError("telegram:handleUpdate", e);
}
}
async function onMessage(deps: Deps, msg: TgMessage) {
const { repos, api } = deps;
const text = (msg.text ?? "").trim();
const tgUserId = msg.from?.id;
if (tgUserId === undefined) return;
if (text.startsWith("/start")) return onStart(deps, msg.chat.id, tgUserId, text);
const link = await repos.telegram.getLinkByTelegramId(tgUserId);
if (!link) return api.sendMessage(msg.chat.id, "Not linked. Connect from the app first.");
const profile = await repos.profiles.getForUser(link.userId);
if (!profile) return api.sendMessage(msg.chat.id, "Not linked.");
if (text.startsWith("/recent")) return onRecent(deps, profile, msg.chat.id);
if (text.startsWith("/budget")) return onBudget(deps, profile, msg.chat.id, text);
if (text.startsWith("/")) return api.sendMessage(msg.chat.id, "Unknown command.");
return onQuickAdd(deps, profile, msg.chat.id, text); // "50 coffee" lands here
}
async function onQuickAdd(deps: Deps, profile: Profile, chatId: number, text: string) {
const { repos, api } = deps;
const parsed = parseEntry(text, profile.currency);
if (!parsed) return api.sendMessage(chatId, "Couldn't read that. Try: 50 coffee");
const cats = await repos.categories.listForUser(profile.id, parsed.type);
const category = matchCategory(cats, parsed.categoryQuery) ?? cats.find((c) => c.isDefault) ?? cats[0];
const accounts = await repos.accounts.listForUser(profile.id);
const account = accounts.find((a) => a.isDefault) ?? accounts[0];
if (!category || !account) return api.sendMessage(chatId, "Set up a category/account first.");
const tx = await repos.transactions.createForUser(profile.id, {
type: parsed.type, amountMinor: parsed.amountMinor,
categoryId: category.id, accountId: account.id,
occurredOn: new Date().toISOString().slice(0, 10),
note: parsed.note,
});
await api.sendMessage(chatId, `Added: ${formatMoney(tx.amountMinor, profile.currency)} → ${category.title}`);
}Tip: Notice onQuickAdd is the ENTIRE feature — same transactionsRepo.createForUser your web app's "Add Expense" form calls. No duplicated validation, no duplicated business rules.
9. A settings UI to connect/disconnect
Small client component, reused pattern: fetch status on mount, one button that flips between Connect / Disconnect.
"use client";
export function TelegramCard() {
const [linked, setLinked] = useState<boolean | null>(null);
useEffect(() => { getTelegramStatus().then((s) => setLinked(s.linked)); }, []);
const connect = async () => {
const r = await generateTelegramLink();
if (r.url) window.open(r.url, "_blank", "noopener");
};
return (
<Card>
<ListRow
icon={<Send size={18} />}
title="Telegram Bot"
sub={linked ? "Connected" : "Not connected"}
onClick={linked ? disconnect : connect}
/>
</Card>
);
}10. Deploy and register the webhook
Push to Vercel, add the three env vars in the dashboard, then tell Telegram where to send updates — this is a one-time call, not something your app does at runtime:
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.vercel.app/api/telegram",
"secret_token": "'"$TELEGRAM_WEBHOOK_SECRET"'"
}'Verify it took:
curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"Expected response has your URL under "url" and "last_error_message" empty. Now open Telegram, find your bot, tap Start (or use the Connect button in your app), and send 50 coffee.
- Bot created via BotFather, token + username saved as env vars
- Webhook secret generated, stored as an env var
- Link/token/processed-update tables migrated
- Route handler verifies secret with a constant-time compare
- Route handler dedupes
update_idbefore doing anything else - Heavy work wrapped in
waitUntil - Parser is pure and unit-tested
setWebhookcalled once against the deployed URL
Gotchas & lessons learned
Gotcha: BigInt everywhere for Telegram IDs. Mixing number and BigInt for the same logical id is a classic source of "works locally, breaks in prod" bugs once a real-world ID exceeds 2^31. Pick BigInt at the DB layer and convert to number only at your domain-type boundary, consistently.
Gotcha: Telegram retries on timeout or non-2xx. If your webhook takes too long or throws, Telegram will redeliver the SAME update — sometimes minutes later. The dedupe table isn't optional polish, it's what stops a slow request from becoming a double-charged expense.
Gotcha: Never trust the chat as an auth channel. Don't accept "my user id is X" typed into the bot. The ONLY way a Telegram account gets linked is by consuming a token minted by your already-authenticated web session. Anyone who can message your bot could otherwise claim to be anyone.
Gotcha: Webhook route needs a middleware exemption. If your app has a global auth-gate middleware (redirecting unauthenticated requests to /login), add /api/telegram to its public-path allowlist — Telegram has no session cookie to send.
Heads up: Don't skip the secret-token check. Telegram's webhook endpoint is a public URL by definition. Without secret_token verification, anyone who finds your URL can POST fake updates and, depending on your handler logic, potentially trigger writes as an arbitrary linked user. Constant-time comparison matters too — a plain === on secrets is a (minor, but free-to-fix) timing side-channel.