Skip to Content
WhatsApp MCPPlan & Phases2 — Outbound And Webhook

Phase 2 — Outbound Text + Webhook Ingress (Single Tenant, Owner)

Effort: L

Goal

End-to-end: send a text message from Claude (stdio), receive a reply via the Meta webhook, surface it through a pull tool. Single-tenant: everything tagged client_id = local-owner. All side effects persisted in Postgres — no in-memory queues.

Deliverables

Express bootstrap

  • src/transport/http.ts — Express server bound to APP_HTTP_PORT on APP_BIND (127.0.0.1 in dev). This becomes the host for MCP HTTP (Phase 5), webhook, Inngest serve route (Phase 3), media route (Phase 6), and /health.
  • Trust proxy = 1, X-Request-Id middleware (generate if absent), pino-http for structured request logs.

Webhook handler

  • src/webhook/meta.ts:
    • GET /webhook/meta — verify-token handshake. Compares hub.verify_token against WA_WEBHOOK_VERIFY_TOKEN; returns hub.challenge on match, 404 on mismatch.
    • POST /webhook/meta — mounted with express.raw({ type: 'application/json', limit: '5mb' }) only on this route, never globally. The raw Buffer is preserved for signature verification.
  • src/webhook/verify-signature.ts — HMAC-SHA256 of raw body with WA_APP_SECRET, constant-time compare with X-Hub-Signature-256. Reject 404 on mismatch (don’t leak endpoint existence). Returns boolean; caller decides response.
  • src/webhook/normalise.ts — translates Meta payload into our messages / contacts row shape. Sanitises any field whose key matches /(token|secret|signature|password)/i to <redacted> before storing raw.

Idempotency

  • inngest_idempotency migration (early, even before Phase 3 brings full Inngest — the table is used directly here).
  • Before persisting, derive event_id from entry[].id + change index + messages[].id. INSERT INTO inngest_idempotency ... ON CONFLICT (event_id) DO NOTHING. If rowCount === 0, log webhook_duplicate and skip.

Meta API client

  • src/meta/client.tsfetch wrapper with retries on 5xx and 429 (exponential backoff, max 3 retries), 30s timeout. Adds Authorization: Bearer <token>. No axios.
  • src/meta/send-text.tsPOST /{phone_number_id}/messages with text body.
  • src/meta/mark-read.tsPOST /{phone_number_id}/messages with status=read,message_id=....
  • src/meta/types.ts — request/response types + error envelope.

Utilities

  • src/utils/phone.tstoMetaFormat(raw) → strips non-digits, drops leading zeros, returns E.164 with no +. Validation throws on empty / too-short input.
  • src/utils/meta-errors.ts — typed error mapper. 131047OutOfSessionWindowError (“re-engagement required; use a template — note: templates land in Phase 8”). Other notable codes mapped (131026, 132xxx).

Migrations

  • drizzle/0002_messages.sqlmessages, contacts, inngest_idempotency. Schema per architecture.md §1.

Tools

  • src/tools/send-message.ts:
    • Input zod: { to: string, body: string, phoneNumberId?: string } (defaults to WA_DEFAULT_PHONE_NUMBER_ID).
    • Normalises to, posts to Meta, persists out row, returns { wamid, status }.
    • Catches OutOfSessionWindowError and returns a typed MCP error result so the LLM gets a useful explanation.
  • src/tools/get-messages.ts:
    • Input zod: { since?: ISO timestamp, sinceId?: uuid, limit?: number (default 20, max 100), direction?: 'inbound'|'outbound'|'all', contact?: string }.
    • Cursor semantics: since is the server-side messages.created_at (monotonic per row), NOT messages.ts (Meta event time, which can be backfilled) and NOT wa_message_id (wamid is not monotonic / not orderable). sinceId is an optional tiebreaker for rows sharing the same created_at to the microsecond.
    • Reads messages for local-owner, paginated by (created_at ASC, id ASC). Returns each row with its created_at so the client can pass the last value back as since next call.
    • Updates a last_pulled_at per (client, phone_number_id) — used later by Phase 5 for resource subscription bookkeeping.
  • src/tools/mark-read.ts:
    • Input zod: { messageId: string }.
    • Calls Meta and records the status change.

Docs (extended)

  • docs/architecture/webhook.md — full design: signature verification rules, idempotency strategy, normalisation pipeline.
  • docs/architecture/database.md — extended with messages, contacts, inngest_idempotency.
  • docs/api/mcp-tools.md — extended with send_message, get_messages, mark_read.
  • docs/api/webhook-payloads.md — Meta inbound payload shapes we accept, with examples.
  • docs/api/errors.md — error code catalogue with mapped Meta codes and our typed errors.

Critical files

Tests

Unit

  • tests/unit/utils/phone.test.ts+44 7700 900123447700900123; 0044...447700...; rejects empty / non-digit-only.
  • tests/unit/utils/meta-errors.test.ts131047OutOfSessionWindowError; unknown codes pass through with generic MetaError.
  • tests/unit/webhook/verify-signature.test.ts — valid signature true; tampered body false; missing header false; malformed sha256= prefix false. Constant-time verified via timing-independent input lengths.
  • tests/unit/webhook/normalise.test.ts — text, image, document, audio, video, sticker, reaction, system payloads each produce the expected row shape; secrets in payloads are redacted.

Integration (testcontainers Postgres)

  • tests/integration/webhook/post-meta.test.ts:
    • Valid signature + new payload → 200, row inserted in messages, contact upserted, inngest_idempotency row inserted.
    • Replay (same payload twice) → second call no-op, only one messages row, audit_log would record webhook_duplicate (audit comes in Phase 4; for now just assert no duplicate row).
    • Tampered body → 404, no rows written.
    • Missing signature header → 404.
  • tests/integration/webhook/get-meta.test.ts:
    • Correct verify token → 200 with hub.challenge.
    • Wrong verify token → 404.
  • tests/integration/tools/send-message.test.ts:
    • msw mocks Meta. Happy path: row inserted with status='sent' and wamid from mock.
    • 131047 from Meta → typed error in tool result; status='failed' and error_code='131047' on the row.
    • 5xx with retry-then-success → one row, status sent, two outbound HTTP calls observed.
  • tests/integration/tools/get-messages.test.ts:
    • Seed 50 messages with controlled created_at values; limit=20 returns the first 20 ordered by (created_at ASC, id ASC).
    • Calling again with since = <last row.created_at> returns the next page with no overlap and no gap.
    • Two rows with identical created_at: sinceId tiebreaker resolves the order deterministically.
    • Negative test: passing a wa_message_id as since is rejected by the zod schema (only ISO timestamps accepted).
    • direction='inbound' filters correctly.
    • contact filters correctly.
  • tests/integration/tools/mark-read.test.ts — calls Meta, updates the inbound row’s status.

Cross-tenant prep

  • Not strictly required (single tenant in this phase) but write the test scaffold for Phase 4: a tests/integration/_helpers/createClient.ts helper used in later phases.

Coverage

  • src/webhook/verify-signature.ts at 100 %.
  • Phase total ≥ 80 %.

Code documentation

  • TSDoc with @remarks on:
    • verify-signature.ts (security boundary, constant-time, 404 contract).
    • meta.ts POST handler (raw body required; idempotency contract; rejection rules).
    • meta/client.ts (retry policy, 429 handling, token redaction).
    • meta-errors.ts (131047 = re-engagement → template required, deferred).
    • phone.ts (E.164 contract, no leading +).
  • File-level headers on every new file (per standards.md §2).
  • Extend docs/architecture/webhook.md and docs/architecture/database.md.
  • docs/api/mcp-tools.md, docs/api/webhook-payloads.md, docs/api/errors.md updated.
  • docs/reference/ regenerated.

Acceptance

  1. From Claude Code stdio: send_message to the owner’s personal WhatsApp number → message arrives.
  2. Reply on the phone → webhook fires → get_messages returns it within seconds.
  3. Database inspection: one outbound + one inbound row in messages, both with client_id = <local-owner uuid> and correct wamid values.
  4. mark_read on the inbound message → Meta returns success; messages.status updated.
  5. pnpm test:ci green; coverage gates met.
  6. Manually replaying the same webhook POST (curl with captured headers + body) → second call returns 200 quickly and no duplicate row.
  7. Manually flipping one byte in the body → 404, no DB write.

Notes

  • Webhook must NOT be reachable through Nginx yet — Phase 7. For Phase 2 testing use ngrok  or cloudflared  pointed at 127.0.0.1:3000. Configure the Meta dashboard webhook URL to the tunnel.
  • Templates are deferred to Phase 8. The 24h-window error path must be present and tested.
  • The webhook handler still does DB writes here. Phase 3 moves them into Inngest so the HTTP handler returns 200 in milliseconds.

Definition of Done

Express + transport

  • src/transport/http.ts boots Express on APP_HTTP_PORT/APP_BIND.
  • trust proxy = 1.
  • X-Request-Id middleware (generate if absent).
  • pino-http structured request logger.

Webhook handler

  • GET /webhook/meta handshake works against WA_WEBHOOK_VERIFY_TOKEN.
  • POST /webhook/meta mounted with express.raw (route-scoped only).
  • HMAC-SHA256 verification with constant-time compare; reject 404 on mismatch.
  • Idempotency on inngest_idempotency via INSERT … ON CONFLICT DO NOTHING.
  • Webhook payload normaliser handles text/image/document/audio/video/sticker/reaction/system.
  • Sanitisation of payload raw (token/secret/signature/password fields redacted).

Meta API client

  • src/meta/client.ts fetch wrapper with retries on 5xx/429, 30s timeout.
  • src/meta/send-text.ts, src/meta/mark-read.ts, src/meta/types.ts.
  • No axios anywhere.

Utilities

  • src/utils/phone.ts toMetaFormat strips non-digits + leading zeros; rejects empty/short.
  • src/utils/meta-errors.ts maps 131047OutOfSessionWindowError and other notable codes.

Migrations

  • drizzle/0002_messages.sql adds messages, contacts, inngest_idempotency.

Tools

  • send_message — validates, sends, persists out row, returns { wamid, status }.
  • get_messages — cursor on created_at, optional sinceId tiebreaker, filter by direction/contact.
  • mark_read — calls Meta, updates row.

Tests

  • tests/unit/utils/phone.test.ts passes.
  • tests/unit/utils/meta-errors.test.ts passes.
  • tests/unit/webhook/verify-signature.test.ts at 100% coverage for that file.
  • tests/unit/webhook/normalise.test.ts passes.
  • tests/integration/webhook/post-meta.test.ts (valid + tampered + missing + replay) passes.
  • tests/integration/webhook/get-meta.test.ts passes.
  • tests/integration/tools/send-message.test.ts (happy + 131047 + 5xx-retry) passes.
  • tests/integration/tools/get-messages.test.ts (cursor + sinceId + wamid-rejected) passes.
  • tests/integration/tools/mark-read.test.ts passes.
  • Phase coverage gate ≥ 80%; verify-signature.ts = 100%.

Documentation

  • docs/architecture/webhook.md written.
  • docs/architecture/database.md extended.
  • docs/api/mcp-tools.md lists send_message, get_messages, mark_read.
  • docs/api/webhook-payloads.md written.
  • docs/api/errors.md covers mapped Meta codes.
  • TSDoc @remarks on verify-signature.ts, meta.ts POST, meta/client.ts, meta-errors.ts, phone.ts.
  • docs/reference/ regenerated cleanly.

Acceptance verified

  • From Claude (stdio): send_message to owner’s WhatsApp → message arrives.
  • Reply on phone → webhook fires → get_messages returns within seconds.
  • DB has 1 outbound + 1 inbound row, both with client_id = local-owner and wamid set.
  • mark_read succeeds + row status updated.
  • Manual replay of captured webhook → no duplicate row.
  • Manual single-byte flip in body → 404 + no DB write.

Phase signoff

  • Phase 2 complete. README.md status table updated to ✅.