SpendFlow

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:

PiecePathJob
Route handlerapp/api/telegram/route.tsVerify + dedupe + hand off (fast, <100ms)
Domain modulemodules/telegram/domain/Routing, parsing, formatting — pure, unit-testable
Data layerlib/data/repos/telegram.tsLink 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.

  1. Open Telegram, search for @BotFather, start a chat.
  2. Send /newbot, give it a display name, then a username ending in bot (e.g. spendflows_bot).
  3. BotFather replies with an API token — looks like 123456789:AAH.... This is a secret, treat it like a password.
  4. Optional: /setdescription and /setuserpic to 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:

📄 .env.local
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:

📄 prisma/schema.prisma
// 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_bot

4. 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):

📄 lib/data/repos/telegram.ts — interface
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>;
}
📄 lib/data/repos/telegram.prisma.ts — implementation
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.

📄 app/api/telegram/route.ts
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.

📄 modules/telegram/actions/link.ts — Server Action
"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:

📄 modules/telegram/domain/handlers.ts
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.

📄 modules/telegram/domain/parser.ts
// 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:

📄 modules/telegram/__tests__/parser.test.ts
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.

📄 modules/telegram/domain/handlers.ts
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.

📄 modules/telegram/ui/TelegramCard.tsx (trimmed)
"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_id before doing anything else
  • Heavy work wrapped in waitUntil
  • Parser is pure and unit-tested
  • setWebhook called 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.