Multichannel Messages
Open prototype
Case study · Full-stack messaging platform

One inbox for WhatsApp, Instagram & Messenger — run by many agents and AI, at scale.

A production platform that ingests Meta webhooks, routes conversations across projects and queues, sends through a resilient outbox, and layers AI agents, chatbots, templates, mass sends, calls and number-health analytics on top — built on four .NET 8 services and one SQL Server database.

Open the live prototype → Jump to architecture ↓
app.multichannel-messages.example/chats
Queue · Billing
VR
Valeria Ríos
charged twice for order…
WA
DK
Dana Kim
is the blue one in stock?
IG
MP
Marco Peña
bot: order status flow
FB
LF
Lucía Ferrer
AI · resolving delivery…
AI
Valeria Ríos WhatsApp · window 22h left
Hi — I was charged twice for order #58231.
I can see both charges. Refunding the duplicate now.
14:32 ✓✓
You'll see it in 3–5 business days.
14:33 ✓✓
Reply…
Lock
A. Serrano
Tags
billing refund
Notes
Duplicate charge confirmed in ops. Refund issued.
AI state
handed to human
3
Channels unified
4
.NET 8 services
9+
Projects / tenants
2.5M+
Messages modeled
<200ms
Webhook ack target
The problem

Five providers, one operation.

A support operation was spread across five legacy messaging providers, single-channel tools, and a database of 200+ tables where hot reads scanned tens of millions of log rows. Agents couldn't see WhatsApp, Instagram and Messenger in one place; there was no safe way to initiate conversations inside Meta's 24-hour window, no controlled mass sending, no number-health guardrails against Meta bans, and AI/classification lived in a separate database the runtime depended on.

The goal: consolidate everything onto Meta only, multi-project and multi-agent, without losing a single production value.

Constraints that shaped the design
  • Keep the existing Meta webhook receiver working — no breaking changes without a documented adapter.
  • Preserve every production value through backfill or a documented decision.
  • Outbound is Meta-only; legacy providers stay as historical input.
  • Hot reads must never scan legacy logs.
Architecture

Four .NET 8 services. One database. Clear boundaries.

Webhooks enter through a narrow ingestion boundary; the API reads and writes intent; the Worker is the only thing that talks outbound to Meta.

Meta Cloud
WA · IG · FB
Webhook API
HMAC · queue · batch insert
Core API
auth · chats · cache · tools
Front
Blazor · 3-panel console
SQL Server one database · 10 domain schemas
core meta chat bot classify mass calls internal ops legacy
OpenAI
direct · vision · Whisper
Worker
outbox pump · templates · bots · mass · health snapshots
AWS S3
media store
Webhook API ingestion boundary

ASP.NET Core (.NET 8), raw ADO.NET with stored procedures only (no ORM). GET /webhook answers Meta's hub verification; POST /webhook accepts events. A middleware captures the raw request bytes so X-Hub-Signature-256 HMAC can be validated and a SHA-256 event hash computed. The controller returns HTTP 200 in under ~200 ms; events land in an in-memory ConcurrentQueue and a background worker flushes them to SQL every 5 s or at 100 events via a batch insert. Idempotent on event hash + provider message id, so duplicate deliveries are ignored.

HMAC SHA-256 in-memory queue batch flush idempotent <200ms ack
Core API read/write brain

.NET 8 Web API. Owns authentication, roles and permissions, project / account / queue selection, chat reads and writes, notes / tags / locks / search, templates, calls, dashboards and AI tools. It never calls Meta directly for sends — it writes a request into the outbox. Reads are served by purpose-shaped stored procedures and cached aggressively; writes drop cache-invalidation rows. A SignalR hub pushes new messages, status changes and AI actions to the front in real time.

JWT auth RBAC SP-backed reads cache invalidation SignalR realtime
Worker everything outbound & scheduled

.NET 8 worker service. Drains the outbox and performs all outbound Meta calls: messages, media (downloaded/uploaded through the existing S3 store), template creation and status sync, chatbot runtime, classification jobs, and a dedicated mass-send loop separate from one-to-one traffic. It also snapshots number-health signals and purges ephemeral internal chat older than 5 days. Concurrency uses lease columns (LockedBy, LockedUntilUtc, AttemptCount, NextAttemptAtUtc) — no cursors, no table types, no triggers, no explicit transactions — with an explicit retry and dead-letter policy.

lease-based queue retry + dead-letter media → S3 dedicated mass loop health snapshots
Front the operator console

Blazor Server with a SignalR live connection. A dark-first design system (IBM Plex, AA-tuned in light and dark, compact operational density) drives a three-panel chat console plus dashboards, AI agents, chatbots, templates, mass sends, calls and internal chat. Module visibility is gated per project by feature flags, permission and readiness. (The prototype reproduces this UI as a static React app.)

Blazor Server SignalR design tokens role/feature gating realtime badges
Data model one database, ten schemas

Everything lives in a single SQL Server database, organized into schemas by domain so hot reads stay narrow and legacy logs stay out of the path: core (projects, users, roles, queues), meta (apps, tokens, WABA, accounts, raw + normalized events, templates), chat (contacts, conversations, messages, media, status history, notes, tags, locks), bot, classify, mass, calls, internal, ops (outbox, leases, health, dashboard snapshots) and legacy (id maps only). The Meta webhook tables were preserved as the ingestion boundary; new normalized tables were added additively with an audit-friendly backfill that keeps every legacy id.

SQL Server 10 domain schemas additive migration legacy id maps idempotent backfill
Features

Everything a messaging operation needs, in one place.

Unified inbox & 3-panel console

One conversation list across every channel and queue, a live message thread with delivery/read ticks, and an operations rail for locks, tags, notes, classification, AI state and growing customer context. Optimistic sends, lazy media, pin-to-top, advanced search.

Conversation locks prevent two agents colliding; only the owner, an admin or a dev can revoke.

AI agents (OpenAI-direct)

Configurable autonomous agents per project, authored in a tabbed editor: persona/prompt, what to measure, tools, handoff rules and a RAG manual. Agents answer, classify, suggest and, when they hit a boundary, open a human action and hand the chat off. Multimodal input: vision, audio transcription (Whisper) and documents.

A strict tool-call envelope contract keeps model output structured and auditable.

Chatbots (node flows)

Migrated node-based chatbots activatable per Meta account, with reply buttons, expected answers, keyword→intent routing and per-chat sessions. If a human agent types into a chat with an active bot, the bot is disabled for that conversation immediately — an auditable state.

Templates & the 24-hour window

Project/account-scoped Meta templates with structured header/body/footer/button preview, live variable substitution, and AI autofill from customer context. When Meta's 24-hour service window is closed, free-form is blocked and only an approved template can continue the conversation — enforced in the composer.

Safe conversation initiation runs a pre-flight (window, opt-in, health, consent) before the first message.

Controlled mass sends

Campaigns scoped to a project/account/template, with audience upload, rate-limit policy, an approval gate and per-recipient results with the Meta message id. A dedicated worker loop runs independently — and stops automatically when a number's health hits critical risk.

WhatsApp calls & switchboard

Call permissions per agent/account/project, an active-call timeline of who's calling whom, presence, and a switchboard view restricted to admins and devs. Recording metadata is modeled with S3 links where feasible.

Internal agent chat (ephemeral)

A project-local, Meta-independent chat for agents, visually distinct (dashed bubbles, diagonal texture). Messages can carry a card that links straight to a specific customer conversation. Retention is 5 days, purged by the worker — never by a trigger.

Dashboards & number health (DIKW)

Metrics organized Data → Knowledge → Wisdom: per-agent configurable activity tiles, a sales funnel, per-channel comparison, and AI-generated insights over aggregates. A health score per Meta number combines delivery failures, read-rate trends, opt-outs, send volume and template quality — computed from snapshots, never by scanning millions of raw rows.

Security & operations

HTTPS + HMAC-validated webhooks, restricted admin endpoints, encrypted secrets kept out of source, an auditable command log for developer/admin actions, worker heartbeats and an error log. Secrets never appear in migration scripts.

Engineering decisions

Seven decisions that defined the system — and why.

01

Additive Meta bridge over a rewrite.

The live webhook receiver stayed untouched; new normalized tables were layered beside it so ingestion never broke during migration.

02

Lease columns instead of triggers/cursors/table types.

Queue work is claimed with LockedBy / LockedUntilUtc and retried with NextAttemptAtUtc, keeping the DB auditable and the logic in the app.

03

API writes to an outbox; the Worker owns Meta.

The API never blocks on Meta and never risks a partial send; the Worker is the single, retryable sender.

04

Hot reads never touch legacy logs.

Domain schemas + denormalized latest-message fields on the conversation keep the inbox fast over millions of historical rows.

05

Cache + invalidation, not chatty reads.

The API caches catalogs, chat pages and permissions; writes emit invalidation rows so the front stays fresh without re-querying everything.

06

Preserve every production value.

Backfills are idempotent and keep legacy ids, so new data traces back to old data and can re-run safely.

07

AI direct, config in-house.

Durable AI/classification config was brought into the platform database; the runtime calls OpenAI directly and logs tokens/latency, with no runtime dependency on an external DB.

Tech stack
Backend
.NET 8 ASP.NET Core Web API Worker Service ADO.NET + stored procedures SignalR
Data
SQL Server 10 domain schemas idempotent backfills
Frontend
Blazor Server SignalR client design tokens (IBM Plex) prototype: React + Vite
Integrations
Meta Graph API & Webhooks WhatsApp / Instagram / Messenger OpenAI (chat · vision · Whisper) AWS S3
Practices
HMAC signature validation lease-based concurrency cache invalidation RBAC + feature flags auditable ops
The result

A single Meta-only platform where multiple projects and many agents operate WhatsApp, Instagram and Messenger from one console — with AI agents and chatbots handling first contact, safe conversation initiation inside Meta's rules, controlled mass sends with health guardrails, calls, ephemeral internal chat, and dashboards that turn raw traffic into number-health decisions. Migrated from 200+ legacy tables without losing a production value, and without ever breaking the live webhook receiver.

3 channels
200+ legacy tables migrated
0 production values lost
Prototype

Explore the operator console.

A non-functional prototype of the operator console — 8 screens with sample data, dark theme, bilingual.

Open the live prototype →
Chats
Dashboard
AI Agents
Chatbots
Templates
Mass sends
Calls
Internal chat