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 toAPP_HTTP_PORTonAPP_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-Idmiddleware (generate if absent), pino-http for structured request logs.
Webhook handler
src/webhook/meta.ts:GET /webhook/meta— verify-token handshake. Compareshub.verify_tokenagainstWA_WEBHOOK_VERIFY_TOKEN; returnshub.challengeon match, 404 on mismatch.POST /webhook/meta— mounted withexpress.raw({ type: 'application/json', limit: '5mb' })only on this route, never globally. The rawBufferis preserved for signature verification.
src/webhook/verify-signature.ts— HMAC-SHA256 of raw body withWA_APP_SECRET, constant-time compare withX-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 ourmessages/contactsrow shape. Sanitises any field whose key matches/(token|secret|signature|password)/ito<redacted>before storingraw.
Idempotency
inngest_idempotencymigration (early, even before Phase 3 brings full Inngest — the table is used directly here).- Before persisting, derive
event_idfromentry[].id+ change index +messages[].id.INSERT INTO inngest_idempotency ... ON CONFLICT (event_id) DO NOTHING. IfrowCount === 0, logwebhook_duplicateand skip.
Meta API client
src/meta/client.ts—fetchwrapper with retries on 5xx and 429 (exponential backoff, max 3 retries), 30s timeout. AddsAuthorization: Bearer <token>. No axios.src/meta/send-text.ts—POST /{phone_number_id}/messageswith text body.src/meta/mark-read.ts—POST /{phone_number_id}/messageswithstatus=read,message_id=....src/meta/types.ts— request/response types + error envelope.
Utilities
src/utils/phone.ts—toMetaFormat(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.131047→OutOfSessionWindowError(“re-engagement required; use a template — note: templates land in Phase 8”). Other notable codes mapped (131026,132xxx).
Migrations
drizzle/0002_messages.sql—messages,contacts,inngest_idempotency. Schema per architecture.md §1.
Tools
src/tools/send-message.ts:- Input zod:
{ to: string, body: string, phoneNumberId?: string }(defaults toWA_DEFAULT_PHONE_NUMBER_ID). - Normalises
to, posts to Meta, persistsoutrow, returns{ wamid, status }. - Catches
OutOfSessionWindowErrorand returns a typed MCP error result so the LLM gets a useful explanation.
- Input zod:
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:
sinceis the server-sidemessages.created_at(monotonic per row), NOTmessages.ts(Meta event time, which can be backfilled) and NOTwa_message_id(wamid is not monotonic / not orderable).sinceIdis an optional tiebreaker for rows sharing the samecreated_atto the microsecond. - Reads
messagesforlocal-owner, paginated by(created_at ASC, id ASC). Returns each row with itscreated_atso the client can pass the last value back assincenext call. - Updates a
last_pulled_atper (client, phone_number_id) — used later by Phase 5 for resource subscription bookkeeping.
- Input zod:
src/tools/mark-read.ts:- Input zod:
{ messageId: string }. - Calls Meta and records the status change.
- Input zod:
Docs (extended)
docs/architecture/webhook.md— full design: signature verification rules, idempotency strategy, normalisation pipeline.docs/architecture/database.md— extended withmessages,contacts,inngest_idempotency.docs/api/mcp-tools.md— extended withsend_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
- src/transport/http.ts — Express bootstrap
- src/webhook/{meta,verify-signature,normalise}.ts — webhook chain
- src/meta/{client,send-text,mark-read,types}.ts — Meta API client
- src/tools/{send-message,get-messages,mark-read}.ts
- src/utils/{phone,meta-errors}.ts
- drizzle/0002_messages.sql
Tests
Unit
tests/unit/utils/phone.test.ts—+44 7700 900123→447700900123;0044...→447700...; rejects empty / non-digit-only.tests/unit/utils/meta-errors.test.ts—131047→OutOfSessionWindowError; unknown codes pass through with genericMetaError.tests/unit/webhook/verify-signature.test.ts— valid signaturetrue; tampered bodyfalse; missing headerfalse; malformedsha256=prefixfalse. 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_idempotencyrow inserted. - Replay (same payload twice) → second call no-op, only one
messagesrow,audit_logwould recordwebhook_duplicate(audit comes in Phase 4; for now just assert no duplicate row). - Tampered body → 404, no rows written.
- Missing signature header → 404.
- Valid signature + new payload → 200, row inserted in
tests/integration/webhook/get-meta.test.ts:- Correct verify token → 200 with
hub.challenge. - Wrong verify token → 404.
- Correct verify token → 200 with
tests/integration/tools/send-message.test.ts:mswmocks Meta. Happy path: row inserted withstatus='sent'andwamidfrom mock.131047from Meta → typed error in tool result;status='failed'anderror_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_atvalues;limit=20returns 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:sinceIdtiebreaker resolves the order deterministically. - Negative test: passing a
wa_message_idassinceis rejected by the zod schema (only ISO timestamps accepted). direction='inbound'filters correctly.contactfilters correctly.
- Seed 50 messages with controlled
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.tshelper used in later phases.
Coverage
src/webhook/verify-signature.tsat 100 %.- Phase total ≥ 80 %.
Code documentation
- TSDoc with
@remarkson:verify-signature.ts(security boundary, constant-time, 404 contract).meta.tsPOST 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.mdanddocs/architecture/database.md. docs/api/mcp-tools.md,docs/api/webhook-payloads.md,docs/api/errors.mdupdated.docs/reference/regenerated.
Acceptance
- From Claude Code stdio:
send_messageto the owner’s personal WhatsApp number → message arrives. - Reply on the phone → webhook fires →
get_messagesreturns it within seconds. - Database inspection: one outbound + one inbound row in
messages, both withclient_id = <local-owner uuid>and correctwamidvalues. mark_readon the inbound message → Meta returns success;messages.statusupdated.pnpm test:cigreen; coverage gates met.- Manually replaying the same webhook POST (
curlwith captured headers + body) → second call returns 200 quickly and no duplicate row. - 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.tsboots Express onAPP_HTTP_PORT/APP_BIND. -
trust proxy = 1. -
X-Request-Idmiddleware (generate if absent). -
pino-httpstructured request logger.
Webhook handler
-
GET /webhook/metahandshake works againstWA_WEBHOOK_VERIFY_TOKEN. -
POST /webhook/metamounted withexpress.raw(route-scoped only). - HMAC-SHA256 verification with constant-time compare; reject 404 on mismatch.
- Idempotency on
inngest_idempotencyviaINSERT … 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.tsfetchwrapper 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.tstoMetaFormatstrips non-digits + leading zeros; rejects empty/short. -
src/utils/meta-errors.tsmaps131047→OutOfSessionWindowErrorand other notable codes.
Migrations
-
drizzle/0002_messages.sqladdsmessages,contacts,inngest_idempotency.
Tools
-
send_message— validates, sends, persistsoutrow, returns{ wamid, status }. -
get_messages— cursor oncreated_at, optionalsinceIdtiebreaker, filter by direction/contact. -
mark_read— calls Meta, updates row.
Tests
-
tests/unit/utils/phone.test.tspasses. -
tests/unit/utils/meta-errors.test.tspasses. -
tests/unit/webhook/verify-signature.test.tsat 100% coverage for that file. -
tests/unit/webhook/normalise.test.tspasses. -
tests/integration/webhook/post-meta.test.ts(valid + tampered + missing + replay) passes. -
tests/integration/webhook/get-meta.test.tspasses. -
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.tspasses. - Phase coverage gate ≥ 80%;
verify-signature.ts= 100%.
Documentation
-
docs/architecture/webhook.mdwritten. -
docs/architecture/database.mdextended. -
docs/api/mcp-tools.mdlists send_message, get_messages, mark_read. -
docs/api/webhook-payloads.mdwritten. -
docs/api/errors.mdcovers mapped Meta codes. - TSDoc
@remarksonverify-signature.ts,meta.tsPOST,meta/client.ts,meta-errors.ts,phone.ts. -
docs/reference/regenerated cleanly.
Acceptance verified
- From Claude (stdio):
send_messageto owner’s WhatsApp → message arrives. - Reply on phone → webhook fires →
get_messagesreturns within seconds. - DB has 1 outbound + 1 inbound row, both with
client_id = local-ownerandwamidset. -
mark_readsucceeds + 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 ✅.