Architecture
How Pons works under the hood.
Overview
Pons is a Next.js application backed by Convex for real-time database, authentication, file storage, and background actions. It sits between WhatsApp's Cloud API and MCP clients.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ │ │ │ │
│ Claude / │────▶│ Pons MCP │────▶│ Convex │
│ Cursor │ MCP │ Endpoint │ │ Backend │
│ │ │ (Next.js) │ │ │
└──────────────┘ └──────┬───────┘ └──────┬───────┘
│ │
┌──────┴───────┐ ┌──────┴───────┐
│ │ │ │
│ Pons Web │ │ Meta Graph │
│ Dashboard │ │ API v22.0 │
│ │ │ │
└──────────────┘ └──────────────┘
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| Backend | Convex (database, auth, file storage, scheduled actions) |
| Auth | Better Auth + Facebook OAuth |
| MCP | @modelcontextprotocol/sdk — Streamable HTTP transport |
| UI | shadcn/ui + Tailwind CSS v4 |
| Hosting | Vercel (FRA1) + Convex Cloud (eu-west-1) |
Data Model
Account (WhatsApp Business Account)
├── AccountMember (owner / admin / member)
├── Contact (customer phone numbers)
│ └── Conversation (thread with a contact)
│ └── Message (text, media, location, reaction...)
├── Template (pre-approved message templates)
├── ApiKey (scoped MCP authentication)
├── WebhookLog (raw payloads for debugging)
├── WebhookTarget (per-number forwarding endpoints)
├── WebhookEvent (immutable forwarded event envelope)
└── WebhookDelivery (delivery attempts + retries)
Key design decisions
- Multi-tenant: Multiple WhatsApp Business Accounts per deployment, each with their own members
- Media in Convex storage: Meta media URLs expire after 5 minutes, so media is downloaded immediately via a Convex action and stored permanently
- 24h window tracking:
conversation.windowExpiresAttracks when you can send free-form vs. template-only messages - Webhook signature verification: Each account has its own
appSecret, verified per-request using HMAC-SHA256 - Durable webhook forwarding: Forwarding is persisted and retried with Convex scheduler until a
200response or max attempts
Request Flows
Incoming message (webhook)
- Meta sends POST to
/api/webhookwith HMAC signature - Next.js route parses the payload and verifies signature against the account's
appSecret - Calls
convex/webhook.ingestWebhookmutation to:- Upsert the contact
- Upsert the conversation (update window, preview, timestamp)
- Store the message
- If the message contains media, a Convex action downloads it from Meta's API and uploads to Convex file storage
- All subscribed clients (dashboard, etc.) update in real-time
Outbound message (MCP or dashboard)
- MCP client calls
send_texttool (or user types in dashboard) convex/whatsapp.sendTextMessageaction:- Stores a pending message in the database
- Calls Meta Graph API v22.0 to send
- Updates message status to
sent
- Meta sends webhook status updates (
delivered,read) which update the message status
Webhook forwarding (inbound + outbound)
- Messages/status updates are emitted as forwarding events (
message.inbound.received,message.outbound.sent,message.outbound.failed,message.status.updated) - Enabled webhook targets subscribed to those events receive a delivery job
- Convex action sends an HTTP
POSTwith signed headers (X-Pons-Signature,X-Pons-Timestamp) - Any response other than
200is retried with exponential backoff until max attempts - Delivery health (
lastSuccessAt,lastFailureAt, consecutive failures) is shown in account settings
MCP tool call
- Client sends HTTP request to
/api/mcpwith Bearer token - Route accepts either an API key or a Better Auth OAuth access token
- Creates a fresh
McpServerinstance scoped to that account - Executes the requested tool against Convex queries/mutations/actions
- Returns the result
Webhook Verification
Pons verifies every incoming webhook request:
- Parse the payload to extract the
phone_number_id - Look up the account by phone number ID
- Verify the
X-Hub-Signature-256header using the account'sappSecretwith HMAC-SHA256 - Reject with 401 if the signature is invalid
This is done per-account since each WhatsApp Business Account has its own app secret.
File Structure
pons/
├── convex/ # Backend
│ ├── schema.ts # Database schema
│ ├── auth.ts # Better Auth + MCP OAuth provider
│ ├── accounts.ts # Account + member management
│ ├── conversations.ts # Conversation queries
│ ├── messages.ts # Message CRUD
│ ├── mcp.ts # MCP queries + API key management
│ ├── mcpNode.ts # Crypto operations (Node.js runtime)
│ ├── forwarding.ts # Durable webhook forwarding + retries
│ ├── webhook.ts # Webhook ingestion + media download
│ ├── webhookTargets.ts # Webhook target configuration
│ ├── whatsapp.ts # Meta Graph API actions
│ └── templates.ts # Template queries
├── src/
│ ├── app/
│ │ ├── api/mcp/ # MCP HTTP endpoint
│ │ ├── api/webhook/ # WhatsApp webhook handler
│ │ ├── docs/ # Documentation (fumadocs)
│ │ └── page.tsx # Landing + auth gate
│ ├── components/
│ │ ├── Dashboard.tsx # Main shell
│ │ ├── ConversationList.tsx
│ │ ├── MessageThread.tsx # Chat view + template picker
│ │ ├── AccountSettings.tsx
│ │ ├── ApiKeyManager.tsx
│ │ └── ui/ # shadcn/ui primitives
│ └── lib/
│ ├── mcp-server.ts # MCP tool definitions
│ ├── source.ts # Fumadocs source loader
│ └── og/ # OG image generation
├── content/docs/ # Documentation MDX content
├── middleware.ts # Auth middleware (webhook excluded)
└── vercel.json # Vercel config (FRA1 region)