Building a Finance Bot on WhatsApp and Telegram With Claude AI
Last month I shipped Spendly โ an AI-powered expense tracker that lives inside WhatsApp and Telegram. No app to download, no forms to fill, just send a message.
This post is about how I built it, the architecture decisions I made, and the things that surprised me along the way.
The Core Idea
Most expense apps fail because of friction. Opening a dedicated app to log โน80 chai feels like too much work. But WhatsApp is already open 40 times a day. So why not use that?
User: coffee 80 Bot: ๐ธ Expense saved ยท โน80 ยท Food ยท Coffee
Behind that simple interaction is Claude AI parsing natural language, a Node.js backend, Supabase storing the data, and the whole thing deployed on Railway.
Tech Stack
AI: Claude Haiku (parsing) + Claude Sonnet (queries) Backend: Node.js + Express โ Railway Database: Supabase (PostgreSQL) Messaging: Telegram Bot API + WhatsApp Business API Frontend: React + Vite โ Vercel
The Two-Tier AI Approach
This was the most interesting architectural decision.
Every expense message goes through Claude Haiku โ fast and cheap, around $0.0003 per message. It extracts amount, category, date, payment method, and expense type as structured JSON.
When users ask questions โ "how much did I spend this month?" or "compare food vs transport" โ that goes through Claude Sonnet, which is smarter and handles complex conversational queries with context from previous messages.
Right model for the right job. Haiku for parsing is about 10x cheaper than Sonnet and plenty capable. Sonnet for analysis gives genuinely useful answers.
Pre-LLM Intent Classification
Here is something most AI app tutorials skip โ you do not need to call the LLM for every message.
Before touching Claude, I run a regex-based intent classifier:
if (GREETINGS.has(text)) return 'GREETING'; if (/remind me|reminder/.test(text)) return 'REMINDER'; if (/how much|compare/.test(text)) return 'QUERY'; if (/^\d+/.test(text)) return 'RECORD';
The Channel Adapter Pattern
Both Telegram and WhatsApp are separate adapters over a shared core:
telegram.js โโ
โโโ record.js, query.js, command.js
whatsapp.js โโ
Telegram supports inline keyboard buttons natively. WhatsApp only supports 3 reply buttons maximum โ so WhatsApp falls back to a numbered text list for menus with more options.
This pattern means adding a new platform tomorrow only requires writing one adapter file. All business logic stays untouched.
Privacy: Identity Hashing
I did not want to store real Telegram IDs or WhatsApp phone numbers in the database. If the database leaks, no one should be identifiable.
function deriveIdentityHash(id) {
return crypto
.createHmac('sha256', process.env.IDENTITY_SECRET)
.update(String(id))
.digest('hex');
}
The real phone number or Telegram ID is never written to Supabase. Only the hash. It is irreversible โ even I cannot figure out who is who from the database alone. First name is not stored at all โ it comes from the message payload on every request and stays in memory only.
Conversational Context
When users ask follow-up questions like "how about entertainment?" the bot needs to remember the previous exchange. I store the last 3 message pairs in memory per user with a 30-minute TTL:
const messages = [
{ role: 'user', content: dataMessage },
{ role: 'assistant', content: 'Got it...' },
...history, // last 3 exchanges
{ role: 'user', content: question }
];
No database needed. Simple in-memory Map with auto-expiry.
Magic Link Authentication
The web dashboard needed auth without passwords. Solution: magic links generated by the bot.
User: web Bot: Here's your dashboard link โ expires in 15 minutes
The token is stored in an in-memory Map. Clicking it creates a 7-day session. The Telegram ID bridges identity from bot to web dashboard seamlessly.
What Surprised Me
WhatsApp Business API is harder than Telegram. The Telegram Bot API is a joy to work with. WhatsApp has a lot of moving parts โ Meta Developer App, Business Account, System User, permanent token, webhook subscriptions. Took me 2 days just to get the first message through.
The pre-LLM classifier was the biggest performance win. I almost skipped it thinking "Claude is fast enough." Wrong mindset. Regex is microseconds. Claude is 500msโ2s. For a chat bot, latency matters a lot.
Prompt engineering is actual engineering. Getting Claude Haiku to reliably extract structured JSON from casual messages like "1088 fuel full take nite first refill in April" took real iteration. The rules in the system prompt matter a lot.
What I Would Do Differently
I would set up staging/production separation from day one. Making fixes directly on production while users are active is stressful.
I would also add more logging earlier. When something breaks in production, "undefined is not a function" in Railway logs is not very helpful.
Open Source
The entire codebase is on GitHub under AGPL-3.0: github.com/knamburi/spendly
Architecture details, design decisions, and privacy notes are all documented at spendly.info/architecture.
Try Spendly
Free to use. No signup. Open source.