Skip to content
Apsan Works
Internal2026

WhatsApp Dashboard

A local-first WhatsApp campaign and CRM platform running two connector types behind one schema, so a number's connection method is a config value.

The problem

WhatsApp marketing has two real ways in: Meta's official Cloud API, which is compliant but requires business verification and pre-approved templates, or an unofficial QR-pairing connector that needs no approval but carries a ban risk the operator has to accept knowingly. Committing the whole system to one meant losing the other. The system needed both to look identical to everything above them.

What we built

  • 01

    A connector_type column on each WhatsApp number picks Cloud API or the unofficial connector. Campaigns, contacts, and message history are written against one connector-agnostic schema, and never know which is behind a given number.

  • 02

    A shared WhatsAppConnector interface with two implementations, so a number can move connector types later without a schema or application-code change.

  • 03

    A long-running worker service holding the unofficial connector's sessions alive and processing a BullMQ send queue. This is not a start-and-forget job. It has to keep running for numbers to stay connected.

  • 04

    Cloud API messages arrive through a webhook receiver directly in the Next.js app. Unofficial-connector messages arrive through the worker's own session. Both write into the same inbox.

  • 05

    The entire stack (Postgres, Redis, dashboard, worker) runs as local processes with one script, so using it day to day needs no cloud account at all.

Decisions worth noting

One schema, two connectors

The decision that mattered most was refusing to let either connector's quirks leak into the data model. Campaigns and contacts are connector-agnostic; only the connector implementation itself knows whether it is talking to Meta's API or a QR-paired session.

The worker is the part that cannot go down

Unlike most background jobs, this one has to stay alive continuously. An unofficial-connector session drops the moment its process stops, and that constraint shaped it as a standing service, not a cron job.

No cloud requirement, on purpose

Postgres and Redis run locally, started by one script. For an internal tool that a small team runs day to day, that removed an entire category of infrastructure cost and setup friction.

Tell us what is slowing you down

A short conversation is usually enough to tell whether this is a build, an automation, or something you should not do at all. We will tell you which.