SaaS ScalingFebruary 202610 min read

Lessons Learned Scaling a Multi-Tenant AI Product as a Meta Tech Provider.

Building Faigen — a WhatsApp and Instagram AI automation SaaS for Kerala businesses — as a solo founder and engineer. What I got wrong about multi-tenancy, AI prompt engineering, and cost control. And how I fixed it.

Author

Faize Muhammed Basheer

Product

Faigen AI — faigen.in

Stack

Node.js / Next.js / MongoDB

01 / What Faigen Is

Faigen is a multi-tenant SaaS that deploys AI agents on WhatsApp and Instagram for small and medium businesses in Kerala. A business signs up, configures their AI agent — product catalog, tone, FAQs, language — and their customers start getting instant AI replies on WhatsApp within 48 hours.

The AI handles order taking, appointment booking, product queries, and broadcast campaigns. It speaks Malayalam, Manglish, and English — auto-detecting what the customer writes in and replying in the same language.

I built the entire thing solo — backend, frontend, admin console, WhatsApp webhook infrastructure, AI integration, and the landing page. Getting registered as an official Meta Tech Provider was the first major milestone. Keeping it running reliably for multiple businesses simultaneously was the hard part.

02 / Becoming a Meta Tech Provider

To send messages via the WhatsApp Business Cloud API at scale — not just to verified test numbers — you need to be registered as a Tech Provider (ISV) on Meta's platform or work through a BSP (Business Solution Provider).

The Tech Provider route means you build directly on the Cloud API, manage your own WABA (WhatsApp Business Account), and are responsible for your own compliance. The process involves:

01

Business verification on Meta Business Manager

Your business entity needs to be verified — business documents, registered address, official email domain. For Faigen, this meant verifying Bancwise Technologies LLP as the entity behind the platform.

02

App review for the WhatsApp Business API

You submit your app for Meta review, demonstrating a legitimate business use case. The review checks that you are not using the API for spam and that your platform has proper privacy policies and terms.

03

Display name approval per phone number

Every WhatsApp number you add needs a display name approved by Meta. The name must accurately represent your business. "Faigen" was approved within hours. Generic or misleading names get rejected.

04

Webhook subscription per WABA

This is the part that caught me. When you add a new WhatsApp number to a different WABA, you need to re-subscribe your webhook to that WABA separately — it does not inherit the subscription from your app. Messages arrive at Meta but never reach your server until you run the subscribed_apps API call.

# Subscribe your app's webhook to a new WABA curl -X POST \ "https://graph.facebook.com/v18.0/{WABA_ID}/subscribed_apps" \ -H "Authorization: Bearer {PERMANENT_TOKEN}" # Response {"success": true} # Verify subscription curl "https://graph.facebook.com/v18.0/{WABA_ID}/subscribed_apps" \ -H "Authorization: Bearer {PERMANENT_TOKEN}"

I learned the WABA webhook subscription lesson the hard way — after migrating to a new phone number on a different WABA and spending an hour debugging why messages were arriving at Meta but producing zero logs on my server.

03 / The Multi-Tenancy Mistake I Made

The first version of Faigen had a fundamental multi-tenancy design flaw. I stored the AI system prompt and business configuration in environment variables and a single config object — assuming I would only ever run one AI agent at a time.

Single-tenant thinking in a multi-tenant product means the day you add your second client, you have to rewrite the most critical part of your system.

The correct design — which I moved to before onboarding any clients — stores all business configuration per company in MongoDB, scoped by a companyId on every document. The webhook handler looks up the company from the incoming phone number ID, loads their config, and runs their AI agent in isolation from every other company on the platform.

// WRONG — single tenant thinking const systemPrompt = process.env.SYSTEM_PROMPT const aiModel = process.env.AI_MODEL // RIGHT — multi-tenant from day one const processMessage = async (message, value) => { const phoneNumberId = value.metadata.phone_number_id // Every company has their own config in MongoDB const company = await Company.findOne({ whatsappPhoneNumberId: phoneNumberId, active: true }).select('+aiApiKey') if (!company) { console.log('No company for phoneNumberId:', phoneNumberId) return } // Everything scoped to this company const systemPrompt = company.systemPrompt const aiModel = company.aiModel const aiProvider = company.aiProvider const features = company.features // AI runs with this company's config only const response = await getAIResponse(company, conversation.messages, customerMessage) }

This pattern — company lookup from the incoming phone number ID, then everything scoped to that company — is what makes the platform genuinely multi-tenant. Adding a new client means creating a new Company document in MongoDB. Zero code changes.

04 / The Message Queue Problem

WhatsApp sends webhook events asynchronously. If a customer sends three messages quickly — "Hi", "I want to order", "2 coir mats" — all three webhooks arrive at the server within milliseconds.

Without a queue, three concurrent AI requests fire simultaneously. The AI sees each message in isolation, without the context of the previous message. The third message ("2 coir mats") arrives with no context that the customer already said they want to order. The AI responds with a greeting — completely wrong.

The solution is a per-phone message queue. Messages from the same customer are processed sequentially — each one waits for the previous to complete before the AI runs.

// Per-phone queue — messages process one at a time per customer class MessageQueue { constructor() { this.queues = new Map() // phoneNumberId -> queue array this.processing = new Map() } async add(phoneNumberId, handler) { if (!this.queues.has(phoneNumberId)) { this.queues.set(phoneNumberId, []) } return new Promise((resolve, reject) => { this.queues.get(phoneNumberId).push({ handler, resolve, reject }) this.process(phoneNumberId) }) } async process(phoneNumberId) { if (this.processing.get(phoneNumberId)) return const queue = this.queues.get(phoneNumberId) if (!queue || queue.length === 0) return this.processing.set(phoneNumberId, true) while (queue.length > 0) { const { handler, resolve, reject } = queue.shift() try { resolve(await handler()) } catch (err) { reject(err) } } this.processing.set(phoneNumberId, false) } } const messageQueue = new MessageQueue() // In the webhook handler await messageQueue.add(phoneNumberId, async () => { await processMessage(message, value) })

05 / Prompt Engineering is Real Engineering

The hardest part of building an AI agent that serves real businesses is not the infrastructure — it is getting the AI to behave consistently across thousands of conversations with customers who write in unpredictable ways.

The Menu Loop Problem

Early versions of the system prompt produced a consistent bug: the AI would show the main menu (Our Products / Place Order / Contact Us) on every single message — even mid-conversation. The customer would ask "how much is the coir mat?" and get a menu instead of an answer.

The fix was explicit instruction in the system prompt:

// BAD — AI shows menu repeatedly "When customers message, show them the menu options." // GOOD — explicit single-show rule "IMPORTANT: Show the main menu buttons ONLY on the very first greeting message. After that, continue the conversation naturally. Do NOT show the menu again mid-conversation."

Getting Malayalam to Work Without a Malayalam Model

Gemini and Groq do not have dedicated Malayalam language models. But they understand Malayalam reasonably well because it appears in their training data. The trick is making the language detection and response explicit in the prompt rather than hoping the model figures it out.

LANGUAGE RULES — FOLLOW STRICTLY: - Detect what language the customer is writing in - Malayalam script → reply in Malayalam script - Manglish (Malayalam in English letters) → reply in Manglish - English → reply in English - Never switch languages mid-conversation unless the customer does - Auto-detect on every message — do not assume from previous messages

Keeping the AI On-Topic

Without explicit restrictions, the AI would answer anything — geography questions, recipe requests, general knowledge. For a business AI agent this is wrong. The agent should only discuss the business.

STRICT RULES: - ONLY discuss topics related to [company name]'s products and services - If someone asks something unrelated, say: "I can only help with questions about [company name] 😊" - Do not provide general knowledge, opinions, or off-topic answers - Do not pretend to be a general AI assistant

06 / Cost Control at Scale

Every AI response costs money. Gemini Flash is cheap — roughly ₹0.02-0.05 per response — but at scale with multiple businesses and active customers, it adds up. More importantly, a single abusive user could generate hundreds of messages per day and run up significant costs.

24-Hour Message Limit

Every conversation has a maximum number of customer messages per 24-hour rolling window. The limit is configurable per company — defaulting to 15 for the Faigen demo and higher for paid clients.

When the limit is hit, instead of going silent (which confuses the customer) or throwing an error, the system sends a single warm handoff message with contact details — converting the limit hit into a sales touchpoint.

const countCustomerMessagesLast24h = (conversation) => { const windowStart = Date.now() - 24 * 60 * 60 * 1000 return conversation.messages.filter(m => m.role === 'user' && new Date(m.timestamp).getTime() >= windowStart ).length } // In the message handler const limit = company.aiContext?.maxMessagesPer24h || 40 const msgCount = countCustomerMessagesLast24h(conversation) + 1 if (msgCount > limit) { // Send handoff message ONCE every 24 hours — not on every message const THROTTLE_MS = 24 * 60 * 60 * 1000 const notifiedRecently = conversation.lastLimitNoticeAt && (Date.now() - new Date(conversation.lastLimitNoticeAt).getTime() < THROTTLE_MS) if (!notifiedRecently) { await sendTextMessage(phoneNumberId, customerPhone, company.aiContext.limitReachedMessage) conversation.lastLimitNoticeAt = new Date() } // Save message but skip AI — user already notified return }

Broadcast Credit System

Broadcast campaigns — sending WhatsApp messages to hundreds of customers at once — cost real money. Meta charges per message: ₹0.15 for Authentication templates, ₹0.88 for Marketing templates.

Every company has a broadcastCredits balance in MongoDB. Credits are deducted before sending — if the balance is insufficient for the full campaign, the broadcast is rejected with a clear error rather than sending partial messages and leaving the company confused.

// Check credits before starting broadcast const totalCost = cleanPhones.length * costPerMsg if ((company.broadcastCredits || 0) < totalCost) { return res.status(400).json({ success: false, error: `Insufficient credits. Need ₹${totalCost}, available ₹${company.broadcastCredits.toFixed(2)}` }) } // Deduct on success await Company.findByIdAndUpdate(company._id, { $inc: { broadcastCredits: -costPerMsg } })

07 / WhatsApp Template Approval

Every outbound WhatsApp message that is not a reply within a 24-hour customer-initiated window must use a pre-approved message template. Templates are categorised by Meta into three types — Authentication, Utility, and Marketing — each with different pricing and approval criteria.

Authentication templates (for OTPs) are approved fastest — usually within minutes. The format is fixed by Meta:

{your_code} is your verification code. For your security, do not share this code. [Copy Code button]

Marketing templates get the most scrutiny. They cannot contain misleading claims, cannot promise guaranteed returns, and must clearly identify the business sending them. I have had templates rejected for being too generic ("Hello, check out our offers") and approved for being specific ("Your Onam offer from [Business]: 20% off all products this week").

The practical lesson: be specific in template content, use the business name prominently, and avoid superlatives like "best" or "guaranteed." Approval times range from minutes to 24 hours.

08 / What Solo Founder Engineering Taught Me

01

Multi-tenancy must be the starting architecture, not a refactor

Every model, every query, every API route needs companyId scoping from day one. Adding multi-tenancy after the fact means touching every file in the codebase. I caught this before onboarding clients — barely.

02

Prompt engineering deserves the same rigour as system design

I spent as much time iterating on the system prompt as I did on the database schema. The AI's behaviour in production depends on instruction precision, not just model capability. Vague prompts produce inconsistent behaviour at scale.

03

Cost control mechanisms are product features

The 24-hour message limit with a warm handoff message is not just a cost-control measure — it is a conversion mechanism. Turning a limit hit into a human touchpoint is product thinking applied to an engineering constraint.

04

Build the observability before you need it

Every message in and out is logged with company, direction, billing category, and timestamp. When something goes wrong at 2am for a client, the logs tell you exactly what happened. Structured logging is not optional in a production AI system.