In production2026-03 – nowSolo full-stack developer

FunilChat AI

One WhatsApp CRM, many paying clients, none of them allowed to see a byte of each other's data.

  • fastapi
  • postgresql
  • react
  • n8n
  • redis

The problem and constraints

FunilChat AI exists to answer a WhatsApp message before a human has time to open the app. The client, WA Financial Services, helps Brazilian immigrants in the United States navigate financial and legal paperwork: tax filings, entity formation, the kind of forms where a missed deadline has real consequences. Most first contact happens on WhatsApp, at hours when nobody is at a desk, and the founder, Wolney, was the only person answering it. Every minute a lead waited for a reply was a minute they might message a competitor instead.

The obvious answer (put an AI in front of WhatsApp) comes with a much less obvious set of constraints once the business is real and the AI is talking to real prospective clients about real money and real immigration status.

The first constraint was earning trust, which mattered here more than raw speed. A prospective client deciding whether to hand a stranger their tax documents is reading every signal for whether this operation is competent and careful, and a slightly-wrong AI answer costs far more than a shrug and a retry would. If the AI and a human both reply in the same thread, or the AI keeps talking after a human has already stepped in, that one visible stumble does more damage than a slow reply ever would. The handoff between AI and human had to be invisible from the customer’s side and unambiguous from the system’s side, never both replying, never neither.

The second constraint was that this had to work for more than one client without becoming more than one system. WA Financial Services was tenant one, but the plan from the start was to sell the same product to other small service businesses running their first contact over WhatsApp. That ruled out anything that assumed a single, hardcoded business (one WhatsApp number, one knowledge base, one set of users), because every one of those assumptions would have to be undone later, on a live system, for the second paying client. At the same time, there was no operations team and no budget for one: whatever “supports multiple tenants” meant, it had to be something one developer could run and reason about alone.

The third constraint was regulatory weight without a regulatory budget. The end customers here are submitting the kind of documents (SSN, ITIN, EIN-equivalent identifiers, immigration-adjacent paperwork) that a fintech or legal-services company would have a compliance team and a security budget for. This system has neither. Every security control (multi-factor authentication, session revocation, encryption of sensitive fields, malware scanning on uploaded documents, an audit trail of who changed what) had to be built into the product itself, because there was no separate security layer or team to bolt one on afterward.

The fourth constraint was operational: a single VPS, no staging environment, and a founder who is also the primary human agent answering the WhatsApp CRM every day. Every migration runs directly against the production database. There is no dress rehearsal environment where a schema change gets tried first. The safety net is careful review before running it; there is no copy of production to break instead.

The last constraint was vendor risk on the one dependency the whole product exists to talk to: WhatsApp itself. There is no official WhatsApp Business Cloud path that fits a self-hosted, cost-sensitive setup at this stage, which means the system depends on a WhatsApp automation provider whose terms, pricing or reliability are outside anyone’s control here. The system had already changed providers once. Whatever got built next had to assume it would happen again.

Architecture

A message starts on the customer’s phone and lands on a per-tenant WhatsApp module, one connected instance per client, so tenant A’s conversations never physically pass through tenant B’s session. From there it goes through Redis, which exists for one reason: people rarely send one WhatsApp message per thought. A real customer sends three or four short messages in a row, and answering after the first one means answering half a sentence. Redis holds and groups messages arriving in a short window so the AI responds to the whole thought instead of interrupting it.

The grouped message reaches an n8n workflow, which is the actual brain of the first-response layer: it holds the AI agent (backed by OpenAI), the knowledge base lookup, and the handoff logic, all as one orchestrated flow rather than scattered across services. Every time this workflow needs to read or write anything that belongs to a specific tenant (a customer record, a conversation, a task created from a conversation), it goes through the FastAPI backend rather than touching Postgres directly.

That routing choice is what makes tenant isolation real instead of aspirational. The backend sets a session-scoped Postgres variable identifying the current tenant on every request it handles, and row-level security policies on all twenty-one tenant-scoped tables use that variable to silently exclude every row that doesn’t belong to the caller. A developer who forgets a WHERE tenant_id = ... clause somewhere in application code does not leak another tenant’s data, because the database itself refuses the row before the application ever sees it. The one place this boundary is not airtight is the automation role n8n’s workflow runs as: it does not set that session variable the way the API does, so it runs with a bypass instead, a narrow, deliberately visible exception to an otherwise fail-closed rule, discussed as its own decision below.

The React CRM is where a human agent sees the same conversation the AI is handling, in something close to real time. This is also where the handoff actually happens: a button labelled “Assume” flips a flag on that conversation, and every subsequent check the AI workflow makes before sending a reply sees that flag and stays silent. Nothing about this is negotiated between the AI and the human. It’s a single boolean the workflow reads before it is allowed to speak, which is also why it was cheap to make watertight. Thirty minutes after the last human reply with no further action, the flag clears itself and the AI resumes, so a conversation a human forgot to hand back doesn’t stay stuck in silence indefinitely.

Everything the AI or the human agent touches is written by the same backend, validated by the same schema, and constrained by the same row-level security. There is exactly one write path into the system of record, regardless of which side of the human/AI boundary the write came from.

A WhatsApp conversation in FunilChat AI where the AI agent Amanda answers a bookkeeping question, then the customer asks for a human; Wolney takes over via the panel, and later replies once more straight from his phone, each bubble labelled 'Amanda', 'Wolney' or 'Replied via WhatsApp' depending on who actually sent it. The header shows 'Waiting on agent' with a 'Return to AI' button, and the right panel shows the client's record: company, service, lead status, email and notes. Every message, photo and record field shown is fictional demo content.
The exact handoff described above, in the interface a customer actually sees: the AI answers, hands off on request, and the status badge is the same boolean the AI workflow checks before it's allowed to reply.

Architecture

FunilChat AI: architecture A customer sends a WhatsApp message. It reaches a per-tenant WhatsApp module, which forwards it through Redis message grouping into an n8n workflow running the AI agent. The workflow reads and writes tenant-scoped data in PostgreSQL, enforced by row-level security, through the FastAPI backend. A human agent using the React CRM can claim the conversation at any point, which silences the AI for that thread until released or after 30 minutes of inactivity. WhatsApp module one instance per tenant Redis message grouping n8n workflow AI agent + handoff logic React CRM human agent takes over FastAPI backend sets tenant context per request PostgreSQL 21 tenant tables row-level security, fail-closed Tenant A / B / C claims / releases conversation

Decisions

DECISION 01/04 · Tenant isolation

ChosenShared schema, one Postgres database, tenant boundary enforced by row-level security (RLS) set from a per-request session variable

Discarded insteadOne database (or one schema) per tenant

A new client has to be onboarded in minutes, without provisioning new infrastructure, and there's no ops team to manage a growing fleet of databases

Cost acceptedThe automation layer (n8n) can't easily set a session variable per request the way the API does, so its database role runs with BYPASSRLS, a real, permanent blind spot in the isolation model that has to be reviewed by hand every time a new workflow touches the database

DECISION 02/04 · WhatsApp provider

ChosenHide the WhatsApp provider behind a generic internal abstraction ("WhatsApp module"), so the vendor can change without the client-facing product changing

Discarded insteadIntegrate directly against one vendor's SDK and surface its name in code, docs and UI

The project moved providers once already; a system whose product identity is coupled to a specific vendor's API forces a rewrite every time that vendor's terms, price or reliability change

Cost acceptedAn extra translation layer between the real provider's webhook format and the internal event model, which is one more place a bug can hide, and one more thing to keep in sync when the underlying provider's API changes shape

DECISION 03/04 · Human takeover

ChosenA human agent can claim a conversation at any time; the AI checks a per-conversation flag before every reply and stays silent once claimed, releasing automatically after 30 minutes of human inactivity

Discarded insteadA manual-only handback, with no automatic release

A customer deciding whether to trust a small firm with financial and immigration paperwork cannot see the AI and a human agent talk over each other in the same thread. That one moment breaks trust in a way no later message repairs

Cost acceptedA conversation can silently fall back to the AI mid-thought if the human agent gets pulled away for more than 30 minutes without meaning to hand it back, so the timer has to be tuned against real response-time data instead of guessed once and forgotten

DECISION 04/04 · Encryption of sensitive fields

ChosenApplication-level encryption (Fernet, versioned prefix) for the MFA secret and the tax-ID field, opt-in and backward-compatible with plaintext rows during rollout

Discarded insteadRely only on disk-level encryption provided by the hosting platform

The client's own customers are immigrants submitting SSN/ITIN/EIN-equivalent tax documents; disk encryption protects against a stolen drive but does nothing for a database dump or backup ending up somewhere it shouldn't

Cost acceptedLosing the encryption key destroys that data permanently (there is no recovery path by design), and the opt-in, dual-read rollout means plaintext and ciphertext rows coexist until every row is backfilled

Invariants

  • A query running under the API's database role never returns rows belonging to a different tenant, even if the application code forgets a WHERE clause

    Guaranteed byPostgreSQL row-level security policies on all 21 tenant-scoped tables, fail-closed by default, keyed off a session variable the backend sets on every request

  • The AI never sends a WhatsApp reply into a conversation a human agent has claimed

    Guaranteed bya per-conversation lock flag checked immediately before every AI response is dispatched from the n8n workflow

  • A revoked session stops working immediately, even if its JWT has not expired yet

    Guaranteed bya token_version claim compared against the stored value on every authenticated request

  • Once encryption is enabled for a field, that value is never written back to the database in plaintext again

    Guaranteed bythe write path only calls the Fernet-encrypting setter; the read path is the only place that still understands the legacy plaintext format

  • A migration that creates a table written by an n8n workflow ships with the automation role's sequence grant already included

    Guaranteed bya checklist item added to migration review after this exact class of bug reached production once (see below)

What broke

Symptom
A production n8n workflow failed to create a new handoff notification with a Postgres error naming a sequence rather than a table. The row-level insert itself looked like it should have been allowed
Root cause
The migration that created the notifications table granted INSERT on the table to both database roles, but only granted USAGE and SELECT on the underlying auto-increment sequence to the API's role. PostgreSQL treats table privileges and sequence privileges as separate grants, and the automation role's default privileges hadn't been set up to inherit sequence access the way the table access had. So every INSERT that needed a new auto-generated ID from that role failed, while everything else on the table worked
Fix
A follow-up migration added the missing explicit GRANT USAGE, SELECT ON SEQUENCE ... for the automation role, and the fix became a standing rule rather than a one-off patch
Prevention
Every subsequent migration that creates a table the automation workflows write to now includes the sequence grant in the same file as the table grant, checked as part of writing the migration rather than discovered later from a production error

Results

21tables under row-level securitygrep for ENABLE ROW LEVEL SECURITY across backend/migrations/*.sql, 2026-08-26
44database migrations appliedcount of files in backend/migrations/, 2026-08-26
307commitsgit log --oneline count on the main branch, 2026-08-26
~4,020source files (backend + frontend)count of .py/.ts/.tsx/.js files outside node_modules, 2026-08-26
since April 2026in continuous productionproject README, status section
2.21.0current release versiondocs/CHANGELOG.md, latest entry

Full stack

Backend

  • FastAPI (Python 3.11)
  • JWT + TOTP MFA
  • Fernet field-level encryption

Frontend

  • React
  • Vite
  • sessionStorage

Data

  • PostgreSQL with row-level security
  • Redis (message grouping)

Infra

  • n8n (self-hosted automation)
  • EasyPanel / VPS
  • ClamAV

AI

  • OpenAI (via n8n AI Agent)

Interested in a project like this? Get in touch.

Get in touch