Chat commerce is quietly reshaping how people buy things online. Instead of navigating through pages of product listings, clicking through checkout flows, and filling out forms, customers simply say what they want and get it. This means no app downloads, no account creation screens, or cart abandonment anxiety. Just a conversation.
For developers, this shift is equally interesting. You're no longer building complex frontends with product grids, shopping carts, and multi-step checkout forms. Instead, you're wiring together an AI agent, a messaging platform, and a payment provider. Then letting the conversation be the interface.
In this tutorial, I'll walk you through how I built Scent House of Aromas, an AI-powered perfume shop that lives entirely inside Telegram. Customers browse perfumes, ask questions, place orders, and pay without ever leaving a chat window. The AI handles the conversation, Flutterwave handles the money, and there's literally no frontend to build.
What Makes Chat Commerce Different?
If you've built a traditional e-commerce platform before, you know how complex it can get. You need a product catalog UI, search and filtering, a shopping cart, a checkout flow, payment integration with redirect pages, order confirmation screens, email notifications, and the list goes on. That's a lot of surface area for bugs, design decisions, and user drop-off.
Chat commerce flips this model and strips away majority of this complexity:
- No UI Complexity: There's no frontend to design, build, or maintain. The messaging app is your UI. Telegram handles rendering, notifications, and cross-device sync for you.
- Works Across Every Device: If a customer has Telegram on their phone, tablet, laptop, or even their smart TV, your store works. No responsive design headaches.
- Users Already Know How to Use it: Nobody needs a tutorial for sending a text message. Your customers are already power users of the interface.
- Payments Feel Natural: Instead of redirecting users to a payment page (and hoping they come back), you generate a virtual account right in the chat. The customer opens their banking app, makes a transfer, and gets a confirmation message; all without context-switching.
- AI does the Heavy Lifting: An AI agent can understand natural language queries ("show me something fresh under 20k"), make product recommendations, and guide customers through checkout conversationally. It's like having a knowledgeable sales associate available 24/7.
For businesses, the conversion advantage is also massive. There's no cart to abandon or customers getting bored filling forms. The customer tells the bot what they want, the bot handles the rest, and payment is a simple bank transfer.
What we're Building
From a customer’s perspective, this is how Scent House of Aromas works:
- A customer opens the Telegram bot and says something like "What perfumes do you have?"
- The AI agent searches the product catalog and responds with options
- The customer asks for more details or says "I'll take the Velvet Rose"
- The agent creates an order and generates a virtual bank account via Flutterwave
- The customer transfers the exact amount from their banking app
- Flutterwave confirms the payment via webhook, the order is marked as paid, and the customer gets a confirmation message in the chat
The Tech Stack
- TypeScript + Express: Our server and API layer
- grammY: Telegram Bot framework for Node.js
- OpenAI GPT-4o: The AI brain with tool-calling capabilities
- Supabase: PostgreSQL database for users, products, and orders
- Flutterwave: Payment processing via dynamic virtual accounts
Architecture Overview
The AI agent sits at the center of everything. It takes the user’s natural language input, decides which tools to call, whether that is searching products, creating an order, or generating a payment account, and then responds in a conversational way.
Thanks to the tool-calling loop, the agent can chain multiple actions within a single interaction. For example, it can create an order and generate payment details all in one go.
Prerequisites
Before we start building, make sure you have:
- Node.js (v18 or later) installed
- A Telegram Bot Token: created via @BotFather on Telegram
- An OpenAI API key: from OpenAI or any other AI model provider like OpenRouter that supports OpenAI GPT-4o
- A Supabase project: free tier at Supabase
- A Flutterwave account: sign up at Flutterwave
Project Setup
Let's scaffold the project:
mkdir telegram_flw_agent_commerce
cd telegram_flw_agent_commerce
npm init -y
Install the dependencies:
npm install express cors dotenv grammy openai @supabase/supabase-js
npm install -D typescript tsx @types/node @types/express @types/cors
Initialize TypeScript:
npx tsc --init
Update your tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Update the scripts section of package.json:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
Create a .env file in the project root:
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
OPENAI_API_KEY=your_openai_api_key
SUPABASE_URL=your_supabase_project_url
SUPABASE_SERVICE_KEY=your_supabase_service_role_key
FLW_CLIENT_ID=your_flutterwave_client_id
FLW_CLIENT_SECRET=your_flutterwave_client_secret
FLW_WEBHOOK_SECRET_HASH=your_flutterwave_webhook_secret
FLW_BASE_URL=https://developersandbox-api.flutterwave.com
Note: The
FLW_BASE_URLpoints to Flutterwave's sandbox environment for development. When you're ready for production, you'll switch this to the live API URL.
Now let's create the folder structure:
src/
├── agent/
│ ├── index.ts # AI agent logic + tool-calling loop
│ ├── prompt.ts # System prompt for the AI
│ └── tools.ts # Tool definitions + execution
├── bot/
│ └── index.ts # Telegram bot handlers
├── database/
│ ├── schema.sql # Database schema
│ ├── seed.sql # Sample product data
│ └── supabase.ts # Supabase client
├── routes/
│ ├── orders.ts # Order creation endpoint
│ ├── payments.ts # Payment generation endpoint
│ ├── products.ts # Product catalog endpoints
│ └── webhooks.ts # Flutterwave webhook handler
├── services/
│ └── flutterwave.ts # Flutterwave API wrapper
└── server.ts # Express app entry point
Setting up the Database
Head over to your Supabase dashboard and run the following SQL in the SQL Editor. First, the schema:
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
telegram_id BIGINT UNIQUE NOT NULL,
first_name TEXT,
username TEXT,
flw_customer_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
brand TEXT NOT NULL,
description TEXT,
price NUMERIC(10,2) NOT NULL,
inventory INTEGER DEFAULT 100,
category TEXT,
notes TEXT,
image_url TEXT
);
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
product_id TEXT REFERENCES products(id),
amount NUMERIC(10,2) NOT NULL,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed')),
flw_reference TEXT,
virtual_account_number TEXT,
virtual_account_bank TEXT,
virtual_account_expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Three tables, each with a clear purpose:
- users: Tracks Telegram users and their linked Flutterwave customer IDs
- products: The perfume catalog with names, prices, descriptions, and categories
- orders: Purchase records that track payment status and virtual account details
Then seed it with some sample perfumes:
INSERT INTO products (id, name, brand, description, price, inventory, category, notes, image_url) VALUES
('p1', 'Bleu Classic', 'Maison Bleu', 'A refreshing everyday scent with citrus top notes and a warm woody base. Perfect for daytime wear.', 25000.00, 100, 'fresh', 'citrus, woody', 'https://images.unsplash.com/photo-1523293182086-7651a899d37f?w=400'),
('p2', 'Midnight Oud', 'Desert Essence', 'Rich and luxurious oud fragrance with deep amber undertones. A statement scent for evening occasions.', 45000.00, 50, 'luxury', 'oud, amber', 'https://images.unsplash.com/photo-1541643600914-78b084683601?w=400'),
('p3', 'Velvet Rose', 'Flora Atelier', 'Elegant rose perfume with a soft musk base. Romantic and timeless.', 22000.00, 80, 'floral', 'rose, musk', 'https://images.unsplash.com/photo-1588405748880-12d1d2a59f75?w=400'),
('p4', 'Ocean Mist', 'Coastal Labs', 'Light and breezy sea-inspired fragrance with citrus accents. Great for casual everyday use.', 18000.00, 120, 'fresh', 'sea salt, citrus', 'https://images.unsplash.com/photo-1592945403244-b3fbafd7f539?w=400'),
('p5', 'Noir Vanilla', 'Maison Noir', 'Warm and inviting vanilla perfume with subtle spice notes. Cozy and sophisticated.', 32000.00, 60, 'warm', 'vanilla, spice', 'https://images.unsplash.com/photo-1594035910387-fea081ccfcfa?w=400')
ON CONFLICT (id) DO NOTHING;
Now, create the Supabase client in src/database/supabase.ts:
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_KEY!;
export const supabase = createClient(supabaseUrl, supabaseKey);
We're using the service role key here because this is a server-side application. The bot needs full access to read and write user, product, and order data.
Integrating Flutterwave
Beyond hosted checkouts, Flutterwave also offers flexible APIs you can plug into any application. For our agentic commerce, we’ll use the dynamic virtual account API. The customer gets account details right in the chat, transfers the money from their banking app, and Flutterwave notifies us when the payment lands.
Authentication
Flutterwave uses OAuth2 client credentials for API authentication. We'll manage token refresh automatically:
Create src/services/flutterwave.ts:
let accessToken: string | null = null;
let expiresIn = 0;
let lastRefreshTime = 0;
const TOKEN_URL =
'https://idp.flutterwave.com/realms/flutterwave/protocol/openid-connect/token';
function getBaseUrl(): string {
return (
process.env.FLW_BASE_URL ||
'https://developersandbox-api.flutterwave.com'
);
}
async function refreshToken(): Promise<void> {
if (!process.env.FLW_CLIENT_ID || !process.env.FLW_CLIENT_SECRET) {
throw new Error('Missing FLW_CLIENT_ID or FLW_CLIENT_SECRET in .env');
}
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.FLW_CLIENT_ID,
client_secret: process.env.FLW_CLIENT_SECRET,
grant_type: 'client_credentials',
}),
});
const data = (await response.json()) as Record<string, unknown>;
if (!data.access_token) {
console.error('[FLW] Token refresh failed:', JSON.stringify(data));
throw new Error('Failed to obtain Flutterwave access token');
}
accessToken = data.access_token as string;
expiresIn = data.expires_in as number;
lastRefreshTime = Date.now();
console.log('[FLW] Token refreshed, expires in', expiresIn, 'seconds');
}
async function getAccessToken(): Promise<string> {
const elapsed = (Date.now() - lastRefreshTime) / 1000;
const remaining = expiresIn - elapsed;
if (!accessToken || remaining < 60) {
await refreshToken();
}
return accessToken!;
}
A few things to note here:
- We cache the access token in memory and only refresh when it's about to expire (less than 60 seconds remaining).
- The
getBaseUrl()function lets us switch between sandbox and production environments via theFLW_BASE_URLenvironment variable.
Generic API Request Helper
Next, let's build a reusable request function that handles auth headers, idempotency keys, and scenario keys (used for sandbox testing):
interface FlwRequestOptions {
method: string;
path: string;
body?: Record<string, unknown>;
idempotencyKey?: string;
scenarioKey?: string;
}
export async function flwRequest<T>(options: FlwRequestOptions): Promise<T> {
const token = await getAccessToken();
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (options.idempotencyKey) {
headers['X-Idempotency-Key'] = options.idempotencyKey;
}
if (options.scenarioKey) {
headers['X-Scenario-Key'] = options.scenarioKey;
}
const url = `${getBaseUrl()}${options.path}`;
const response = await fetch(url, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const result = (await response.json()) as T;
if ((result as Record<string, unknown>).status !== 'success') {
console.error(
`[FLW] ${options.method} ${options.path} failed:`,
JSON.stringify(result),
);
}
return result;
}
The X-Idempotency-Key header is important becuase it prevents duplicate charges if a request is accidentally sent twice. We generate a unique UUID for each payment operation.
Creating Customers and Virtual Accounts
Now, the two core Flutterwave operations we need:
interface FlwCustomerResponse {
status: string;
data: { id: string; email: string };
}
export async function createFlwCustomer(
firstName: string,
lastName: string,
email: string,
idempotencyKey: string,
): Promise<FlwCustomerResponse> {
return flwRequest<FlwCustomerResponse>({
method: 'POST',
path: '/customers',
body: {
name: { first: firstName, last: lastName },
email,
},
idempotencyKey,
});
}
interface VirtualAccountData {
id: string;
amount: number;
account_number: string;
reference: string;
account_bank_name: string;
account_type: string;
status: string;
account_expiration_datetime: string;
note: string;
customer_id: string;
}
interface FlwVirtualAccountResponse {
status: string;
data: VirtualAccountData;
}
export async function createDynamicVirtualAccount(
reference: string,
customerId: string,
amount: number,
currency: string,
narration: string,
expirySeconds: number = 3600,
idempotencyKey: string,
scenarioKey?: string,
): Promise<FlwVirtualAccountResponse> {
return flwRequest<FlwVirtualAccountResponse>({
method: 'POST',
path: '/virtual-accounts',
body: {
reference,
customer_id: customerId,
amount,
currency,
account_type: 'dynamic',
narration,
expiry: expirySeconds,
},
idempotencyKey,
scenarioKey,
});
}
The flow works like this: first, we create a Flutterwave customer. This happens only once per user, and we store the flw_customer_id in our database. Next, we generate a dynamic virtual account for each order. The virtual account is temporary, expires after a set time (we use 3600 seconds, or 1 hour), and is locked to the exact order amount.
Lastly, we need a function to verify charges when we receive webhooks:
interface FlwChargeResponse {
status: string;
data: {
id: string;
amount: number;
currency: string;
reference: string;
status: string;
};
}
export async function verifyCharge(
chargeId: string,
): Promise<FlwChargeResponse> {
return flwRequest<FlwChargeResponse>({
method: 'GET',
path: `/charges/${chargeId}`,
});
}
Building the AI Agent
This is the brain of the operation. We're using OpenAI's tool-calling feature, which lets the model decide when to call our functions and what arguments to pass. The agent doesn't just generate text, it takes actions.
The System Prompt
Create src/agent/prompt.ts:
export const SYSTEM_PROMPT = `You are Scent House of Aromas, a friendly and knowledgeable perfume shopping assistant for a boutique perfume store.
Your role:
- Help customers discover perfumes based on their preferences
- Provide details about specific perfumes when asked
- Guide customers through placing orders and completing payments
- Be warm, conversational, and use occasional perfume-related emojis
Rules:
- ALWAYS use the search_perfumes tool when a customer asks about perfumes. Never invent products.
- ALWAYS use the get_perfume tool to fetch details before describing a specific perfume.
- When a customer wants to buy, use create_order then create_payment to generate bank transfer details.
- When showing a single perfume's details, include the image using this exact format on its own line: [IMAGE:url] — the bot will render it as a photo.
- When showing multiple perfumes in a list, do NOT include images.
- Prices are in Nigerian Naira (₦). Format them with commas (e.g. ₦25,000).
- When showing payment details, clearly display the account number, bank name, amount, and expiry.
- Keep responses concise and formatted for Telegram (use Markdown).
- If you have no matching products, say so honestly and suggest broadening the search.
- When showing multiple perfumes, use a numbered list with name, brand, and price.
- After sharing payment details, remind the customer to transfer the exact amount and that they'll receive confirmation once payment is received.`;
The prompt is crucial. A few design decisions worth calling out:
- We tell the agent to never invent products; it must always use the search tool. This prevents hallucinated product names and prices.
- The
[IMAGE:url]convention is a simple protocol between the agent and the bot code. The agent outputs this marker, and the bot code detects it and sends a photo message instead of plain text. - We explicitly tell the agent the order of operations for purchases:
create_order→create_payment. The tool-calling loop handles this automatically.
Defining the Tools
Create src/agent/tools.ts. This file defines what tools the AI agent has access to and what happens when it calls them:
import { supabase } from '../database/supabase';
import {
createFlwCustomer,
createDynamicVirtualAccount,
} from '../services/flutterwave';
import crypto from 'crypto';
export const toolDefinitions = [
{
type: 'function' as const,
function: {
name: 'search_perfumes',
description:
'Search the perfume catalog by keyword. Matches against name, brand, category, notes, and description.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description:
"Search query (e.g. 'fresh', 'under 20000', 'floral', 'oud')",
},
},
required: ['query'],
},
},
},
{
type: 'function' as const,
function: {
name: 'get_perfume',
description:
'Get detailed information about a specific perfume by its product ID.',
parameters: {
type: 'object',
properties: {
product_id: {
type: 'string',
description: "The product ID (e.g. 'p1', 'p2')",
},
},
required: ['product_id'],
},
},
},
{
type: 'function' as const,
function: {
name: 'create_order',
description:
'Create a new order for a perfume. Use this when the customer confirms they want to purchase.',
parameters: {
type: 'object',
properties: {
product_id: {
type: 'string',
description: 'The product ID to order',
},
user_id: {
type: 'string',
description: "The user's database UUID",
},
},
required: ['product_id', 'user_id'],
},
},
},
{
type: 'function' as const,
function: {
name: 'create_payment',
description:
'Generate a virtual bank account for the customer to pay into. Use after create_order. Returns bank account details the customer should transfer to.',
parameters: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'The order UUID',
},
},
required: ['order_id'],
},
},
},
];
These four tools give the agent everything it needs to run the full shopping experience. Notice how the descriptions guide the model on when to use each tool. For example, create_payment says "Use after create_order," which helps the agent sequence the calls correctly.
Now for the tool execution logic:
export async function executeTool(
name: string,
args: Record<string, string>,
): Promise<string> {
switch (name) {
case 'search_perfumes': {
const query = args.query;
const priceMatch = query.match(/under\s+[₦]?(\d[\d,]*)/i);
let supabaseQuery = supabase.from('products').select('*');
if (priceMatch) {
const price = parseInt(priceMatch[1].replace(/,/g, ''));
supabaseQuery = supabaseQuery.lte('price', price);
}
const textQuery = query.replace(/under\s+[₦]?\d[\d,]*/i, '').trim();
if (textQuery) {
supabaseQuery = supabaseQuery.or(
`name.ilike.%${textQuery}%,brand.ilike.%${textQuery}%,category.ilike.%${textQuery}%,notes.ilike.%${textQuery}%,description.ilike.%${textQuery}%`,
);
}
const { data, error } = await supabaseQuery;
if (error) return JSON.stringify({ error: error.message });
if (!data || data.length === 0)
return JSON.stringify({
message: 'No perfumes found matching your search.',
});
return JSON.stringify(data);
}
case 'get_perfume': {
const { data, error } = await supabase
.from('products')
.select('*')
.eq('id', args.product_id)
.single();
if (error) return JSON.stringify({ error: 'Perfume not found' });
return JSON.stringify(data);
}
case 'create_order': {
const { data: product } = await supabase
.from('products')
.select('price')
.eq('id', args.product_id)
.single();
if (!product) return JSON.stringify({ error: 'Product not found' });
const { data: order, error } = await supabase
.from('orders')
.insert({
product_id: args.product_id,
user_id: args.user_id,
amount: product.price,
status: 'pending',
})
.select()
.single();
if (error) return JSON.stringify({ error: error.message });
return JSON.stringify(order);
}
case 'create_payment': {
const { data: order } = await supabase
.from('orders')
.select('*, users(*), products(*)')
.eq('id', args.order_id)
.single();
if (!order) return JSON.stringify({ error: 'Order not found' });
let customerId = order.users.flw_customer_id;
// Create a Flutterwave customer if this is the user's first purchase
if (!customerId) {
const customerRes = await createFlwCustomer(
order.users.first_name || 'Customer',
order.users.username || 'User',
`${order.users.telegram_id}@telegram.user`,
crypto.randomUUID(),
);
if (customerRes.status !== 'success')
return JSON.stringify({
error: 'Failed to create payment customer',
});
customerId = customerRes.data.id;
await supabase
.from('users')
.update({ flw_customer_id: customerId })
.eq('id', order.users.id);
}
// Generate a unique reference for this order
const reference = `ord${Date.now().toString(36)}${crypto
.randomUUID()
.replace(/-/g, '')
.slice(0, 8)}`;
const vaRes = await createDynamicVirtualAccount(
reference,
customerId,
order.amount,
'NGN',
`Payment for ${order.products.name}`,
3600,
crypto.randomUUID(),
);
if (vaRes.status !== 'success')
return JSON.stringify({
error: 'Failed to generate payment account',
});
const va = vaRes.data;
// Save virtual account details to the order
await supabase
.from('orders')
.update({
flw_reference: reference,
virtual_account_number: va.account_number,
virtual_account_bank: va.account_bank_name,
virtual_account_expiry: va.account_expiration_datetime,
})
.eq('id', args.order_id);
return JSON.stringify({
account_number: va.account_number,
bank_name: va.account_bank_name,
amount: order.amount,
reference,
expires_at: va.account_expiration_datetime,
});
}
default:
return JSON.stringify({ error: `Unknown tool: ${name}` });
}
}
The create_payment tool is where Flutterwave integration really comes together. Let's break down what happens:
- We look up the order along with its associated user and product data
- If the user doesn't have a Flutterwave customer ID yet, we create one (this only happens on their first purchase)
- We generate a unique payment reference and request a dynamic virtual account from Flutterwave
- We save the virtual account details back to the order record
- We return the account number, bank name, amount, and expiry to the agent, which presents them conversationally to the user
The Agent Loop
Create src/agent/index.ts. This is where we wire the AI model to the tools:
import OpenAI from 'openai';
import { SYSTEM_PROMPT } from './prompt';
import { toolDefinitions, executeTool } from './tools';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
type Message = OpenAI.ChatCompletionMessageParam;
const conversationHistory = new Map<string, Message[]>();
export async function handleAgentMessage(
userId: string,
telegramId: string,
userMessage: string,
): Promise<string> {
const history = conversationHistory.get(telegramId) || [];
history.push({ role: 'user', content: userMessage });
const messages: Message[] = [
{
role: 'system',
content: `${SYSTEM_PROMPT}\n\nCurrent user's database ID: ${userId}`,
},
...history,
];
let response = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
tools: toolDefinitions,
});
let choice = response.choices[0];
// Handle tool call loop — agent may chain multiple tools
while (choice.finish_reason === 'tool_calls' && choice.message.tool_calls) {
const assistantMessage = choice.message;
history.push(assistantMessage);
const toolCalls = assistantMessage.tool_calls!;
for (const toolCall of toolCalls) {
const fn = toolCall as {
id: string;
type: 'function';
function: { name: string; arguments: string };
};
const args = JSON.parse(fn.function.arguments);
const result = await executeTool(fn.function.name, args);
history.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result,
});
}
response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: `${SYSTEM_PROMPT}\n\nCurrent user's database ID: ${userId}`,
},
...history,
],
tools: toolDefinitions,
});
choice = response.choices[0];
}
const reply =
choice.message.content || "I couldn't process that. Please try again.";
history.push({ role: 'assistant', content: reply });
// Keep history manageable (last 20 messages)
if (history.length > 20) {
conversationHistory.set(telegramId, history.slice(-20));
} else {
conversationHistory.set(telegramId, history);
}
return reply;
}
The key pattern here is the tool-calling loop. Here's what's happening:
- We send the conversation to OpenAI with our tool definitions
- If the model decides to call a tool, the
finish_reasonwill be"tool_calls"instead of"stop" - We execute each tool call and push the results back into the conversation history
- We send the updated conversation back to OpenAI, which might call more tools or generate a final text response
- This loop continues until the model responds with text (no more tool calls)
This is what makes the agent powerful. In a single user message like "I want to buy the Velvet Rose", the agent can chain get_perfume → create_order → create_payment across multiple loop iterations and respond with a complete message containing the product details and payment instructions.
We also keep the conversation history capped at 20 messages per user to keep token usage reasonable.
Building the Telegram Bot
Create src/bot/index.ts:
import { Bot } from 'grammy';
import { supabase } from '../database/supabase';
import { handleAgentMessage } from '../agent';
export const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
bot.command('start', async (ctx) => {
const telegramId = ctx.from?.id;
if (!telegramId) return;
await supabase.from('users').upsert(
{
telegram_id: telegramId,
first_name: ctx.from?.first_name || null,
username: ctx.from?.username || null,
},
{ onConflict: 'telegram_id' },
);
await ctx.reply(
'Welcome to *Scent House of Aromas*\n\n' +
'I can help you discover perfumes and place orders.\n\n' +
'Try asking:\n' +
'- "Show me fresh perfumes"\n' +
'- "What do you have under 20,000?"\n' +
'- "Tell me about Velvet Rose"',
{ parse_mode: 'Markdown' },
);
});
bot.on('message:text', async (ctx) => {
const telegramId = ctx.from.id;
const userMessage = ctx.message.text;
let { data: user } = await supabase
.from('users')
.select('id')
.eq('telegram_id', telegramId)
.single();
if (!user) {
const { data: newUser } = await supabase
.from('users')
.insert({
telegram_id: telegramId,
first_name: ctx.from.first_name || null,
username: ctx.from.username || null,
})
.select('id')
.single();
user = newUser;
}
if (!user) {
await ctx.reply('Something went wrong. Please try /start again.');
return;
}
const response = await handleAgentMessage(
user.id,
telegramId.toString(),
userMessage,
);
// Check if the agent included an image marker
const imageMatch = response.match(/\[IMAGE:(https?:\/\/[^\]]+)\]/);
if (imageMatch) {
const imageUrl = imageMatch[1];
const textWithoutImage = response
.replace(/\[IMAGE:https?:\/\/[^\]]+\]\n?/, '')
.trim();
await ctx.replyWithPhoto(imageUrl, {
caption: textWithoutImage,
parse_mode: 'Markdown',
});
} else {
await ctx.reply(response, { parse_mode: 'Markdown' });
}
});
bot.catch((err) => {
console.error('Bot error:', err);
});
Two handlers, and the bot is complete:
- /start: Creates or updates the user in the database and sends a welcome message with example queries
-
Text messages: Looks up the user, passes the message to the AI agent, and sends back the response. If the agent's response includes an
[IMAGE:url]marker, we send it as a photo message with a caption instead of plain text
The image handling leans into how Telegram handles images. Since Telegram supports rich media but the AI only outputs text, we use a simple convention: the AI returns [IMAGE:url], and the bot code picks it up and switches to replyWithPhoto. The user sees a beautiful product image with description while the AI doesn't need to know anything about Telegram's API.
Setting up the REST API Routes
While the Telegram bot is the primary interface, we also expose REST endpoints. These are useful for admin tools, testing, or if you later want to add a web frontend.
Products Route
Create src/routes/products.ts:
import { Router } from 'express';
import { supabase } from '../database/supabase';
const router = Router();
router.get('/', async (_req, res) => {
const { data, error } = await supabase.from('products').select('*');
if (error) return res.status(500).json({ error: error.message });
res.json(data);
});
router.get('/search', async (req, res) => {
const query = (req.query.q as string) || '';
const { data, error } = await supabase
.from('products')
.select('*')
.or(
`name.ilike.%${query}%,brand.ilike.%${query}%,category.ilike.%${query}%,notes.ilike.%${query}%,description.ilike.%${query}%`,
);
if (error) return res.status(500).json({ error: error.message });
res.json(data);
});
router.get('/:id', async (req, res) => {
const { data, error } = await supabase
.from('products')
.select('*')
.eq('id', req.params.id)
.single();
if (error) return res.status(404).json({ error: 'Product not found' });
res.json(data);
});
export default router;
Orders Route
Create src/routes/orders.ts:
import { Router } from 'express';
import { supabase } from '../database/supabase';
const router = Router();
router.post('/', async (req, res) => {
const { product_id, user_id } = req.body;
const { data: product, error: productError } = await supabase
.from('products')
.select('price')
.eq('id', product_id)
.single();
if (productError || !product)
return res.status(404).json({ error: 'Product not found' });
const { data: order, error: orderError } = await supabase
.from('orders')
.insert({
product_id,
user_id,
amount: product.price,
status: 'pending',
})
.select()
.single();
if (orderError) return res.status(500).json({ error: orderError.message });
res.json(order);
});
export default router;
Payments Route
Create src/routes/payments.ts:
import { Router } from 'express';
import { supabase } from '../database/supabase';
import {
createFlwCustomer,
createDynamicVirtualAccount,
} from '../services/flutterwave';
import crypto from 'crypto';
const router = Router();
const sanitizeName = (name: string | null) => {
if (!name) return null;
const clean = name.replace(/[^a-zA-Z\s,.'-]/g, '').trim();
return clean.length >= 2 ? clean : null;
};
router.post('/', async (req, res) => {
const { order_id } = req.body;
const { data: order, error: orderError } = await supabase
.from('orders')
.select('*, users(*), products(*)')
.eq('id', order_id)
.single();
if (orderError || !order)
return res.status(404).json({ error: 'Order not found' });
let customerId = order.users.flw_customer_id;
if (!customerId) {
const firstName = sanitizeName(order.users.first_name) || 'Customer';
const lastName = sanitizeName(order.users.username) || 'User';
const customerRes = await createFlwCustomer(
firstName,
lastName,
`${order.users.telegram_id}@telegram.user`,
crypto.randomUUID(),
);
if (customerRes.status !== 'success')
return res
.status(500)
.json({ error: 'Failed to create Flutterwave customer' });
customerId = customerRes.data.id;
await supabase
.from('users')
.update({ flw_customer_id: customerId })
.eq('id', order.users.id);
}
const reference = `ord${Date.now().toString(36)}${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`;
const vaRes = await createDynamicVirtualAccount(
reference,
customerId,
order.amount,
'NGN',
`Payment for ${order.products.name}`,
3600,
crypto.randomUUID(),
);
if (vaRes.status !== 'success')
return res
.status(500)
.json({ error: 'Failed to create virtual account' });
const va = vaRes.data;
await supabase
.from('orders')
.update({
flw_reference: reference,
virtual_account_number: va.account_number,
virtual_account_bank: va.account_bank_name,
virtual_account_expiry: va.account_expiration_datetime,
})
.eq('id', order_id);
res.json({
account_number: va.account_number,
bank_name: va.account_bank_name,
amount: order.amount,
reference,
expires_at: va.account_expiration_datetime,
});
});
export default router;
Handling Payment Webhooks
This is the final critical piece. When a customer transfers money to the virtual account, Flutterwave sends a webhook to our server. We need to verify the payment, update the order, and notify the customer.
Create src/routes/webhooks.ts:
import { Router, Request, Response } from 'express';
import { supabase } from '../database/supabase';
import { verifyCharge } from '../services/flutterwave';
import { bot } from '../bot';
import crypto from 'crypto';
const router = Router();
router.post('/flutterwave', async (req: Request, res: Response) => {
const secretHash = process.env.FLW_WEBHOOK_SECRET_HASH;
const signature = req.headers['flutterwave-signature'] as string;
// Step 1: Verify the webhook signature
if (secretHash) {
if (!signature) {
return res.status(401).json({ error: 'Missing signature' });
}
const payloadBuffer = (req as any).rawBody || '';
const expectedHash = crypto
.createHmac('sha256', secretHash)
.update(payloadBuffer)
.digest('base64');
if (signature !== expectedHash) {
return res.status(401).json({ error: 'Invalid signature' });
}
}
const payload = req.body;
// Step 2: Only process successful completed charges
if (
(payload?.event !== 'charge.completed' &&
payload?.type !== 'charge.completed') ||
payload?.data?.status !== 'succeeded'
) {
return res.status(200).json({ status: 'ok' });
}
const { reference, id: chargeId } = payload.data;
// Step 3: Verify the charge with Flutterwave's API (don't trust the webhook alone)
try {
const verification = await verifyCharge(chargeId);
if (verification.data.status !== 'succeeded') {
return res
.status(400)
.json({ error: 'Charge verification failed' });
}
} catch (err) {
console.error('[WEBHOOK] Charge verification API error:', err);
}
// Step 4: Find the order and mark it as paid
const { data: order, error: orderError } = await supabase
.from('orders')
.update({ status: 'paid' })
.eq('flw_reference', reference)
.select('*, users(*), products(*)')
.single();
if (orderError || !order) {
console.error('[WEBHOOK] Order lookup failed:', orderError?.message);
return res.status(200).json({ status: 'ok' });
}
// Step 5: Send confirmation to the customer on Telegram
const amount = Number(order.amount).toLocaleString();
const message =
`*Payment received!*\n\n` +
`Your order for *${order.products.name}* has been confirmed.\n` +
`Order ID: \`${order.id}\`\n` +
`Amount: ${amount}`;
await bot.api.sendMessage(order.users.telegram_id, message, {
parse_mode: 'Markdown',
});
return res.status(200).json({ status: 'ok' });
});
export default router;
A few important security practices here:
- Signature Verification: We verify the webhook's HMAC signature against our secret hash. This ensures the webhook actually came from Flutterwave and wasn't spoofed.
- Charge Verification: Even after verifying the signature, we make an API call to Flutterwave to confirm the charge status.
Notice how we capture the raw request body in the Express middleware (see the server setup below). This is needed because JSON parsing alters the body, and we need the original bytes for HMAC verification.
Wiring it all Together
Finally, create src/server.ts:
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { bot } from './bot';
import productsRouter from './routes/products';
import ordersRouter from './routes/orders';
import paymentsRouter from './routes/payments';
import webhooksRouter from './routes/webhooks';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(
express.json({
verify: (req: any, _res, buf) => {
req.rawBody = buf.toString();
},
}),
);
app.use('/api/products', productsRouter);
app.use('/api/orders', ordersRouter);
app.use('/api/payments', paymentsRouter);
app.use('/webhooks', webhooksRouter);
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
bot.start();
console.log('Telegram bot started (long polling)');
});
The verify callback in express.json() captures the raw request body before it gets parsed. This is important for webhook signature verification because you need the exact bytes that were sent, not a re-serialized version of the JSON.
Running the Project
Start the development server:
npm run dev
You should see:
Server running on port 3000
Telegram bot started (long polling)
Now open Telegram, find your bot, and send /start. Try asking it about perfumes, request details on a specific one, and go through the purchase flow. The AI will guide the conversation naturally while the tools handle the actual business logic behind the scenes.
Setting Up the Webhook for Production
For Flutterwave to notify you about payments, it needs to reach your webhook endpoint. During development, you can use a tool like ngrok to expose your local server:
ngrok http 3000
Then update the Flutterwave dashboard with the webhook URL ngrok returned and a secret hash of your choice:
https://your-ngrok-url.ngrok.io/webhooks/flutterwave
For production, deploy your server to a cloud provider and use the public URL.
What Makes this Approach Powerful
Looking back at what we built, a few things stand out:
- Flutterwave’s virtual accounts are a game changer for chat commerce. The traditional payment flow, redirect to checkout, pay, then redirect back, breaks the conversation. Virtual accounts keep the customer right where they are. They receive an account number, open their banking app, transfer the money, and get a confirmation without leaving the chat.
- The AI agent pattern is incredibly flexible. Want to add order tracking? Define a new tool. Want to handle returns? Add another one. Since the agent understands natural language, there is no need for complex command parsing or button-heavy menus. Customers just type what they want, and the agent figures out the next step.
- The entire project comes in at around 400 lines of TypeScript. There is no frontend framework, no CSS, no state management library, and no client-side build pipeline. The messaging platform handles the interface, so you can focus purely on business logic.
Conclusion
Chat commerce is not just a trend. It is a different way to think about digital transactions. By combining an AI agent like OpenAI, a messaging platform like Telegram, and a flexible payment provider like Flutterwave, you can build an online store that feels more like a conversation than a website.
What makes this approach stand out is its simplicity. You focus on what matters, your products, your payment flow, and what happens after a purchase. The AI handles the conversation, the messaging platform handles the interface, and Flutterwave handles the payments.
If you are building for markets where mobile money and bank transfers are common, this pattern fits naturally. Flutterwave’s dynamic virtual accounts plug directly into the conversational flow, and webhook-based confirmations give customers instant feedback without the need to refresh or poll.
The full source code for this project is available on GitHub. Feel free to fork it, swap out the perfumes for your own products, and build your own chat commerce experience.

Top comments (0)