Back to Selected Work

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