Skip to main content

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_id and client_secret)
  • A backend server reachable from the internet with HTTPS (use ngrok for local development)
  • EVOLUTION_WEBHOOK_SECRET set 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 typesHow Ozzie handles it
spent $45 on groceriesLogged as a transaction under Groceries
paid $120 electricity billLogged 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 workAI 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.

tip

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​

ScenarioWhat to send to the user
Unknown phone numberTrigger onboarding flow
Ozzie INTAKE_REQUIREDAsk for income/goal to complete setup
Image unreadableAsk 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."
warning

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.