SaaS Starter

WhatsApp

Opt-in WhatsApp Cloud API integration via Kapso — channel adapter, webhook router, HMAC verification, and Kapso-curated agent skills.

The @app/messaging-whatsapp package wraps the official WhatsApp Cloud API behind a provider-agnostic ChannelAdapter port and ships a production-ready Kapso adapter so you can send and receive messages without dealing with Meta Business verification yourself.

It is opt-in. Don't install it if your app doesn't need WhatsApp.

Why Kapso (and not Meta direct)?

Kapso handles WABA provisioning, the phone number, and Meta Business Verification for you. You get an API key, a phone number id, and a webhook secret — and the same Cloud API request shapes you'd use against Meta directly. Migrating to Meta later is a swap of the ChannelAdapter implementation; the rest of the codebase only sees IncomingMessage / OutgoingMessage.

The package's port is provider-agnostic. The Kapso adapter is the only implementation today — implement another for direct Meta or any other Cloud-API-compatible vendor.

Operator setup (one time)

  1. Create a Kapso account at https://kapso.ai/ → Get started for free.
  2. Install the Kapso CLI (Node ≥ 20.19):
    npm install -g @kapso/cli
    kapso login
  3. Provision a phone number via the dashboard, then list it:
    kapso whatsapp phone-numbers list
  4. Register the inbound webhook (use payload version v2, v1 is legacy):
    kapso whatsapp webhooks new \
      --phone-number-id "<id>" \
      --url "https://<your-server>/api/v1/messaging/webhook" \
      --event whatsapp.message.received \
      --payload-version v2 \
      --active
    The CLI prints a secret_key — save it as KAPSO_WEBHOOK_SECRET.
  5. Generate a project API key (Dashboard → Settings → API keys); save it as KAPSO_API_KEY.

The repo ships three Kapso-curated agent skills under .claude/skills/ (integrate-whatsapp, automate-whatsapp, observe-whatsapp) with operational scripts and references for templates, flows, debugging, and webhook workflows. Future Claude Code sessions in this repo pick them up automatically.

Install

// apps/server/package.json
"dependencies": {
  "@app/messaging-whatsapp": "workspace:*"
}

Set in apps/server/.env:

KAPSO_API_KEY=k_live_…
KAPSO_PHONE_NUMBER_ID=…
KAPSO_WEBHOOK_SECRET=whsec_…

Wire the adapter

import {
  KapsoHttpClient,
  KapsoChannelAdapter,
  createWhatsappWebhookRouter,
} from '@app/messaging-whatsapp';

const http = new KapsoHttpClient({
  apiKey: env.KAPSO_API_KEY,
  phoneNumberId: env.KAPSO_PHONE_NUMBER_ID,
});
const adapter = new KapsoChannelAdapter(http);

app.use(
  '/api/v1/messaging/webhook',
  createWhatsappWebhookRouter({
    adapter,
    secret: env.KAPSO_WEBHOOK_SECRET,
    onMessage: async (msg) => {
      // hand off — bot, assistant turn loop, human, n8n…
      logger.info({ from: msg.externalUserId, text: msg.text }, 'inbound');
    },
  }),
);

// Send:
await adapter.sendMessage(
  { phoneE164: '+5491155551234' },
  { text: 'Hola desde el boilerplate.' },
);

The webhook router uses express.raw() so HMAC verification runs against the unparsed body before JSON parsing — never the other way around.

Pairing with the assistant

The package's IncomingMessage slots cleanly into the @app/assistant turn loop. Build a ConversationTurn[] from your conversation history, hand it to runAssistantTurn, send the reply back via adapter.sendMessage. The two packages don't depend on each other — the glue lives in your consumer code.

import { runAssistantTurn, composePersona } from '@app/assistant';

onMessage: async (msg) => {
  const reply = await runAssistantTurn(completer, registry, {
    systemPrompt: composePersona({ baseRules: '…', toolCatalog: registry.buildCatalogSummary() }),
    messages: [{ role: 'user', content: msg.text }],
    ctx: { /* … */ },
    model: 'gpt-5.4-mini',
  });
  await adapter.sendMessage({ phoneE164: msg.externalUserId }, { text: reply.text });
};

Field naming

The IncomingMessage value object uses provider-agnostic names:

  • externalUserId — E.164 phone (+5491155551234)
  • externalMessageId — opaque provider id (Cloud API's wamid…)
  • externalMediaUrl — opaque media reference (URL or media id)

Your domain stores these as opaque strings. If you ever swap providers, the column shape stays the same.

Webhook signature

Kapso signs every webhook with HMAC-SHA256 over the raw body, sent in the X-Webhook-Signature header. The router rejects malformed or mismatched signatures with 401 before parsing JSON. Bring the secret from kapso whatsapp webhooks new — there's no way to recover it after creation, so capture it on the spot.

Migrating away from Kapso

Implement a new ChannelAdapter (e.g. MetaChannelAdapter) for the provider you're moving to, swap the wiring, redeploy. The webhook payload shape and the Cloud API request shape are mostly the same; adjust parseIncoming and the URL/auth shape in your http client.

On this page