For a credit-origination fintech · 19,627 contacts across 150+ campaigns
WhatsApp Campaign & Lead-Origination Platform
A two-service platform running multi-step WhatsApp outbound campaigns over the Meta Cloud API — sequenced follow-ups that stop the instant a prospect replies and hand off to a human agent.
Problem
A credit-origination business needed to reach and qualify large lists of prospects over WhatsApp — not blast broadcasts, but sequenced follow-ups (up to 5 templated steps with configurable delays) that stop the moment a person replies and hand the conversation to a human agent. The raw contact data came from bureau-style exports (Excel, dirty, Argentine phone formats), and the same person could not be messaged by two campaigns at once. The requirements were regulatory-grade: never double-send, never message someone who opted out, never keep messaging someone who already answered — while staying reliable across process crashes and Meta API failures.
My Role
Sole full-stack engineer, end-to-end. I designed the PostgreSQL data model (~25 tables), the WhatsApp gateway, the Next.js backoffice, the domain/state-machine layer, the import pipeline, and the deployment.
Architecture & Key Decisions
- Two processes, one clean boundary. A thin, stateless gateway owns everything WhatsApp: it verifies the webhook HMAC signature, downloads inbound media to object storage, normalizes Meta's payload, and forwards it over a shared internal token. It never touches the database. All domain logic lives in the backoffice, layered: route handlers → services → pure state-machine modules → database. This kept the surface that talks to Meta small and replaceable.
- A claim-then-verify dispatcher instead of a message queue. Campaign progress is driven by a 60s ticker. For each due enrollment it does an atomic conditional update to claim it (only one worker wins), then re-reads and re-validates one more time immediately before the Meta call — a deliberate triple-check so an automated step never lands on someone who replied mid-flight.
- Crash recovery baked into the model. Enrollments stuck mid-send by a dead worker are reclaimed by a time threshold. Bulk import jobs killed mid-run are re-adopted on boot via a per-worker lease token + heartbeat, so two replicas can't process the same job.
- Two-axis retries. Time-based backoff (6h → 24h → give up) and phone fallback: when Meta reports a number has no WhatsApp, it retries on the contact's backup phone — but only on step 1, and every later step is pinned to whichever number actually worked.
- Idempotency and optimistic concurrency for the human side. Admin mutations use a Stripe-style idempotency layer (key + payload hash). Human takeover of a conversation uses a version column plus a TTL lock, so two agents can't grab the same lead.
- Argentine phone reality as a first-class concern. Inbound matching tries ~6 variants of each number, because Meta's wa_id and the source files never agree on format. The importer normalizes to E.164 and dedupes by document → phone.
Trade-Offs
- Polling ticker over a real broker. I chose cron-style polling over BullMQ/SQS/Redis on purpose: the volume didn't justify the extra infrastructure, and a claim lock in Postgres gives the same at-most-once guarantee with far less to operate. A broker is the upgrade path if volume demands it.
- Some domain logic in the route handlers rather than fully extracted into services — pragmatic for a solo build. The genuinely tricky rules (state transitions, concurrency, resume sequences) are isolated in pure, testable modules; the rest stayed close to the routes.
Stack
- TypeScript
- Next.js
- React
- Node
- Express
- PostgreSQL
- Prisma
- WhatsApp Cloud API
- Cloudflare R2
- Railway
Internal client system — not public