WhatsApp Expense Bot
This guide covers building a WhatsApp-first expense tracker and financial coach using Ozzie and Evolution API. Users simply send text messages or photos of receipts to a WhatsApp number β Ozzie parses the input, logs the transaction, and replies with a personalized response in the user's language.
How it worksβ
User sends WhatsApp message
|
v
Evolution API instance
|
| POST /api/webhooks/evolution
v
Your Backend (webhook handler)
|
|ββ Is this user registered?
| No β Send onboarding prompts, collect info, call POST /users
| Yes β Forward to Ozzie chat/transaction processing
v
Ozzie API processes message and generates reply
|
v
Your Backend sends reply back via Evolution API
|
v
User receives reply on WhatsApp
Prerequisitesβ
- Evolution API instance running and connected to a WhatsApp number (Evolution API docs)
- Ozzie API credentials (
client_idandclient_secret) - A backend server reachable from the internet with HTTPS (use ngrok for local development)
EVOLUTION_WEBHOOK_SECRETset to the same value in both your environment and your Evolution API instance config
Setup stepsβ
1. Deploy your webhook serverβ
Your server needs a public HTTPS endpoint at /api/webhooks/evolution. Here's a minimal Express setup:
// server.js
import express from 'express';
import { handleEvolutionWebhook } from './webhooks/evolution.js';
const app = express();
app.use(express.json({ limit: '10mb' })); // Allow larger payloads for image messages
app.post('/api/webhooks/evolution', handleEvolutionWebhook);
app.listen(3000, () => console.log('Webhook server running on port 3000'));
2. Configure Evolution APIβ
In your Evolution API dashboard, set the webhook URL for your instance:
https://your-server.com/api/webhooks/evolution
Set the apikey (webhook secret) to match your EVOLUTION_WEBHOOK_SECRET.
Subscribe to the messages.upsert event.
3. Set environment variablesβ
# .env
OZZIE_CLIENT_ID=ozz_client_a1b2c3d4
OZZIE_CLIENT_SECRET=sk_live_xK9mP2qR7tL...
EVOLUTION_WEBHOOK_SECRET=a7f3c8e1b2d4e5f60718293a4b5c6d7e8f901234
EVOLUTION_API_URL=https://your-evolution-instance.com
EVOLUTION_INSTANCE=your_instance_name
4. Implement the webhook handlerβ
// webhooks/evolution.js
import { findUserByPhone, createUser, updateUser } from '../db/users.js';
import { ozzieRequest } from '../lib/ozzie.js';
import { sendWhatsAppMessage } from '../lib/evolution.js';
export async function handleEvolutionWebhook(req, res) {
// Verify the request is from Evolution API
const apiKey = req.headers['apikey'];
if (apiKey !== process.env.EVOLUTION_WEBHOOK_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { event, data } = req.body;
// Only process incoming user messages
if (event !== 'messages.upsert' || data?.key?.fromMe) {
return res.sendStatus(200);
}
const { remoteJid, id: messageId } = data.key;
const phoneRaw = remoteJid.replace('@s.whatsapp.net', '');
const phone = `+${phoneRaw}`;
const messageType = data.messageType;
// Find the user by phone number
let user = await findUserByPhone(phone);
if (!user) {
// User not registered β start onboarding flow
await handleNewUser(phone, data);
return res.sendStatus(200);
}
// Process the message based on type
if (messageType === 'conversation') {
const text = data.message?.conversation;
await handleTextMessage(user, phone, text);
} else if (messageType === 'imageMessage') {
const image = data.message?.imageMessage;
await handleImageMessage(user, phone, image);
} else {
await sendWhatsAppMessage(phone, "Sorry, I can only process text messages and images right now.");
}
res.sendStatus(200);
}
5. Implement the Evolution API senderβ
// lib/evolution.js
export async function sendWhatsAppMessage(phone, text) {
const phoneDigits = phone.replace('+', '');
const response = await fetch(
`${process.env.EVOLUTION_API_URL}/message/sendText/${process.env.EVOLUTION_INSTANCE}`,
{
method: 'POST',
headers: {
'apikey': process.env.EVOLUTION_WEBHOOK_SECRET,
'Content-Type': 'application/json',
},
body: JSON.stringify({
number: phoneDigits,
text,
}),
}
);
if (!response.ok) {
throw new Error(`Failed to send WhatsApp message: ${response.status}`);
}
}
Supported input typesβ
Text messagesβ
Users can send natural language descriptions of expenses, questions, or commands:
| What user types | How Ozzie handles it |
|---|---|
spent $45 on groceries | Logged as a transaction under Groceries |
paid $120 electricity bill | Logged as a transaction under Utilities |
how am I doing this month? | AI coach responds with spending summary |
what's my savings goal? | AI coach responds with goal progress |
I got a $500 bonus at work | AI coach acknowledges and suggests allocation |
Receipt photosβ
Users can photograph a receipt and send it directly to WhatsApp. Ozzie's image parser extracts:
- Merchant name
- Total amount
- Date
- Line items (where legible)
The parsed transaction is logged and the coach sends a confirmation.
Remind users in your onboarding message that they can send receipt photos to log expenses hands-free. This is one of the most popular features and drives strong daily engagement.
Sample conversation flowβ
Here's a realistic WhatsApp conversation between a user and the Ozzie-powered bot:
User: Hi, I want to track my expenses
Bot: Hey! I'm Ozzie, your personal money coach π I can help you track spending, stick to your savings goal, and give you advice β right here on WhatsApp. To get started, can you tell me your name?
User: Sofia
Bot: Nice to meet you, Sofia! What's your monthly take-home income (after taxes)?
User: About $3,500
Bot: Got it. And roughly how much do you spend on fixed expenses each month? Things like rent, subscriptions, insurance.
User: Around $1,600
Bot: Perfect. Last question: what's your main financial goal right now β saving money or paying off debt?
User: Saving. I want to build an emergency fund.
Bot: Great goal, Sofia! I've set up your account and built your first financial plan. Based on your income and expenses, I recommend saving $262 per month (about 7.5% of your income). At that pace, you'd have a $3,000 starter emergency fund in about 11 months. I'll remind you twice a month with a small action to take. Ready to start?
User: Yes!
Bot: Awesome! Here's your first Money Move for this week: Transfer $131 to a separate savings account today. Even a basic savings account is fine to start β the habit matters more than the interest rate right now. You've got this, Sofia!
A few days later:
User: [sends a photo of a grocery receipt]
Bot: Got it! I parsed your receipt from Walmart: $67.82 logged under Groceries for May 7th. Your grocery total for May is now $134.82. You have about $165 left in your food budget for the month. You're doing great!
Later that week:
User: I went over budget on eating out π¬
Bot: Happens to everyone! Dining out is $89 so far in May β you budgeted $60. A $29 overage isn't a crisis, but you can offset it by cooking at home for your next 3 meals. That'll save roughly $25β30 and bring you back on track. Want me to suggest some quick meal ideas?
Onboarding via WhatsAppβ
When an unknown number sends a message, guide them through registration in the conversation:
// webhooks/evolution.js (onboarding portion)
const ONBOARDING_SESSIONS = new Map(); // In production, use Redis or your DB
async function handleNewUser(phone, data) {
const text = data.message?.conversation?.trim();
const session = ONBOARDING_SESSIONS.get(phone) ?? { step: 'start' };
if (session.step === 'start') {
await sendWhatsAppMessage(
phone,
"Hey! I'm Ozzie, your personal money coach π° I can help you track spending and reach your savings goals β right here on WhatsApp. What's your name?"
);
ONBOARDING_SESSIONS.set(phone, { step: 'name' });
return;
}
if (session.step === 'name') {
session.name = text;
await sendWhatsAppMessage(
phone,
`Nice to meet you, ${text}! What's your monthly take-home income (after taxes)? Just send me the number, like: 3500`
);
ONBOARDING_SESSIONS.set(phone, { ...session, step: 'income' });
return;
}
if (session.step === 'income') {
const income = parseFloat(text?.replace(/[^0-9.]/g, ''));
if (isNaN(income)) {
await sendWhatsAppMessage(phone, "Hmm, I didn't catch that. Please send just the number β like: 3500");
return;
}
session.income = income;
await sendWhatsAppMessage(
phone,
`Got it β $${income.toFixed(0)}/month. What's your main financial goal right now?\n\n1οΈβ£ Build savings\n2οΈβ£ Pay off debt`
);
ONBOARDING_SESSIONS.set(phone, { ...session, step: 'goal' });
return;
}
if (session.step === 'goal') {
const isDebt = text?.includes('2') || text?.toLowerCase().includes('debt');
session.goal = isDebt ? 'debt' : 'savings';
// Create the Ozzie user
try {
const { data: ozzieUser } = await ozzieRequest('POST', '/users', {
external_user_id: `whatsapp_${phone.replace('+', '')}`,
name: session.name,
language: 'en', // Detect from phone locale if possible
phone_e164: phone,
});
// Store user in your database
await createUser({
phone,
name: session.name,
ozzie_user_id: ozzieUser.id,
onboarding_data: session,
});
// Submit basic intake
await ozzieRequest('POST', `/users/${ozzieUser.id}/financial-intake`, {
monthly_net_income: session.income,
monthly_fixed_expenses: session.income * 0.45, // Default estimate
monthly_variable_expenses: session.income * 0.25,
total_debt: session.goal === 'debt' ? 5000 : 0, // Placeholder
monthly_debt_payment: session.goal === 'debt' ? 200 : 0,
primary_goal: session.goal,
});
// Generate plan
const { data: plan } = await ozzieRequest('POST', `/users/${ozzieUser.id}/plan`);
ONBOARDING_SESSIONS.delete(phone);
await sendWhatsAppMessage(
phone,
`You're all set, ${session.name}! I've built your first financial plan. ` +
`I'll check in with you twice a month with small money moves to keep you on track. ` +
`You can also ask me anything anytime β like "how am I doing?" or send me a receipt photo to log a purchase.`
);
} catch (err) {
console.error('Onboarding error:', err);
await sendWhatsAppMessage(phone, "Sorry, something went wrong setting up your account. Please try again in a moment.");
ONBOARDING_SESSIONS.delete(phone);
}
}
}
Handling text and image messagesβ
async function handleTextMessage(user, phone, text) {
if (!text) return;
try {
const { data } = await ozzieRequest(
'POST',
`/users/${user.ozzie_user_id}/chat/messages`,
{ message: text }
);
await sendWhatsAppMessage(phone, data.reply);
} catch (err) {
if (err.message.includes('INTAKE_REQUIRED')) {
await sendWhatsAppMessage(
phone,
"I need a bit more info about your finances before I can help. Reply with your monthly income to get started."
);
} else {
await sendWhatsAppMessage(phone, "I had trouble processing that. Can you try again?");
}
}
}
async function handleImageMessage(user, phone, image) {
if (!image?.url) {
await sendWhatsAppMessage(phone, "I received your image but couldn't read it. Please send a clear photo of your receipt.");
return;
}
try {
// Forward the image URL to Ozzie's transaction endpoint
const { data } = await ozzieRequest(
'POST',
`/users/${user.ozzie_user_id}/transactions`,
{
source: 'image',
image_url: image.url,
caption: image.caption ?? '',
}
);
await sendWhatsAppMessage(
phone,
`Got it! Logged $${(data.amount_cents / 100).toFixed(2)} at ${data.merchant} under ${data.category}.`
);
} catch (err) {
await sendWhatsAppMessage(
phone,
"I couldn't read that receipt clearly. Try taking the photo in better lighting, or type the expense manually (e.g., 'spent $45 on groceries')."
);
}
}
Error handlingβ
| Scenario | What to send to the user |
|---|---|
| Unknown phone number | Trigger onboarding flow |
Ozzie INTAKE_REQUIRED | Ask for income/goal to complete setup |
| Image unreadable | Ask for better photo or manual entry |
RATE_LIMIT_EXCEEDED | "I'm a bit busy right now β try again in a minute" |
INTERNAL_ERROR | "Something went wrong on my end. Give me a moment and try again." |
Never send raw error messages or stack traces to users via WhatsApp. Always translate API errors into friendly, actionable messages in the user's language.