Unofficial WABA (WhatsApp Business API) replicator — run your own WhatsApp Business API-compatible surface so you can test and prototype integrations without a live Business account or Meta approval.
- WABA-compatible HTTP API for API keys, sessions, and webhooks, plus the event pipeline that turns real WhatsApp activity into WABA-shaped webhook payloads.
- Dashboard (Next.js) — management UI for API keys, WhatsApp sessions (QR login / pairing), and webhook configuration. Same process as the API.
- Worker (Go + WhatsMeow) — owns WhatsApp device connections, executes outbound sends from the queue, and forwards inbound events to your webhook endpoints with retry.
apps/
dashboard/ # Next.js app: /api/v1/* API + /api/internal/* + management UI
services/
worker/ # Go + WhatsMeow gateway (two binaries, see below)
shared/
db/migrations/ # SQL migrations (golang-migrate format)
┌────────────┐ HTTP (Bearer) ┌────────────────────────────┐
│ Client / │ ────────────────► │ apps/dashboard (Next.js) │
│ Dashboard │ │ /api/v1 public API │
└────────────┘ │ /api/internal worker API │
└──────────┬─────────┬────────┘
│ │ Postgres
▼ ▼
┌─────────────────────────┐
│ Postgres (jobs queue, │
│ sessions, webhooks, │
│ api_keys) │
└───────────┬─────────────┘
│ jobs
┌───────────▼─────────────┐
│ worker (Go/WhatsMeow) │
│ cmd/worker dispatcher │
│ cmd/whatsapp_worker │
└────────────┬────────────┘
│
WhatsApp (device)
Inbound events ─► worker ─► webhook payload ─► your webhook URL (retried w/ backoff)
The worker runs as two binaries that talk through the whatsmeow_jobs queue.
Both ship in the same Docker image (services/worker/Dockerfile); the
compose services only select a different entrypoint:
cmd/worker— stateless dispatcher: pollsjobs, forwards towhatsmeow_jobs. No WhatsApp state. Safe to scale horizontally.cmd/whatsapp_worker— stateful executor: owns WhatsMeow clients, executes sends, writes terminal status back tojobs. Strictly one instance.
Why only one
whatsapp_worker? Every instance opens its own live WhatsApp connections to the same accounts. Running more than one means the same phone number is connected from multiple processes at once, which risks the account being banned by WhatsApp. The split exists precisely so the dispatcher can scale while the WhatsApp-facing executor stays pinned to a single instance — see Scaling.
See docs/README.md for the full docs graph.
- Node.js 20+ (developed on v22)
- Docker (OrbStack or Docker Desktop) for Postgres + migrations
- Go 1.2x+ for the worker
- WhatsApp device (phone number) to pair — required for sessions/messaging
git clone <your-fork> && cd waba-api-unofficial
npm install # installs all workspace dependenciesThere is one .env, at the repo root — the dashboard, worker, and docker
compose all read it. Copy the example and set the required values:
cp .env.example .envThe dashboard loads it through dotenv -e ../../.env in its npm scripts
(apps/dashboard/package.json) because Next.js only auto-loads .env from
its own directory; the worker loads it via godotenv from the repo root.
Generate an encryption key for API-key secrets at rest (32 random bytes,
base64) and paste it into API_KEY_ENCRYPTION_KEY:
openssl rand -base64 32| Variable | Required | Purpose |
|---|---|---|
POSTGRES_PORT |
— | Host port for Postgres (default 5433) |
POSTGRES_PASSWORD |
— | Postgres password (dev default postgres) |
DATABASE_URL |
✅ | postgres://postgres:<pw>@localhost:5433/waba |
API_AUTH_TOKEN |
✅ | Bootstrap Bearer token for /api/v1/*. Management routes are bootstrap-only; persisted keys can never call them. Empty = all public routes closed |
INTERNAL_TOKEN |
✅ | Bearer token for /api/internal/* (worker ↔ API). Empty = internal routes disabled |
API_KEY_ENCRYPTION_KEY |
✅ | Base64 32-byte AES-256-GCM key. Without it, key create/reveal fail closed (500s) |
API_KEY_ENCRYPTION_KEY_PREVIOUS |
rotation only | Previous key during rotation so old ciphertexts still decrypt |
NEXT_PUBLIC_API_TOKEN |
— | When set, the dashboard always sends Authorization: Bearer <token>. When blank, it prompts for the token on first 401 |
API_KEY_ENV |
— | Environment label in key prefixes (waba_<env>_…); defaults to NODE_ENV/dev |
SEND_TIMEOUT_MS |
— | Max ms to wait for a send job (default 25000) |
RESULT_POLL_MS |
— | Job poll interval (default 250) |
HEARTBEAT_TTL_MS |
— | Worker heartbeat TTL (default 30000) |
CORS_ORIGINS |
— | Comma-separated allow-list for cross-origin API calls |
BUSINESS_ACCOUNT_ID |
root .env |
— |
WHATSMEOW_STORE_DSN |
root .env |
— |
API_URL |
root .env |
— |
WEBHOOK_CONFIG_TTL |
root .env |
— |
HEARTBEAT_INTERVAL |
root .env |
— |
POLL_INTERVAL, MAX_ATTEMPTS, MIGRATIONS_DIR |
root .env |
— |
WORKER_REPLICAS |
root .env |
— |
docker compose build migrate
docker compose up -d postgres migrate
# verify: the migrate service exits 0 and the version table shows the latest
docker compose exec postgres psql -U postgres -d waba -c "select version, dirty from schema_migrations order by version desc limit 1"The tracked SQL in shared/db/migrations/ is the single source of truth — do
not apply untracked or locally generated migration files.
cd apps/dashboard
npm run dev # http://localhost:3000 (or: -p 3100 if 3000 is taken)Production-style:
cd apps/dashboard
npm run build && npm run startThe Docker image (services/worker/Dockerfile) ships both binaries; the
compose services select the entrypoint for you. Running from source:
cd services/worker
go build ./cmd/worker ./cmd/whatsapp_worker
./worker # stateless dispatcher (scalable)
./whatsapp_worker # stateful WhatsMeow executor (single instance)Run exactly one whatsapp_worker per deployment — a second instance opens
concurrent WhatsApp connections to the same accounts and risks a ban.
The worker reads env from the root .env (godotenv). For a first run you only
need Postgres + the API up; WhatsApp features require pairing a device (below).
Only the stateless dispatcher scales. Scale worker freely; keep
whatsapp_worker at exactly one instance (it owns the live WhatsApp
connections — more than one risks an account ban).
# Docker Compose: scale the dispatcher, never the WhatsApp executor
docker compose up -d --scale worker=3 --scale whatsapp_worker=1docker-compose.yml makes this the default: worker reads WORKER_REPLICAS
(default 1), and whatsapp_worker is pinned to deploy.replicas: 1
(swarm). Do not raise the latter.
Everything is Bearer-authenticated. Two credential types:
- Bootstrap token (
API_AUTH_TOKEN) — full access, incl. the management and reveal routes. - Persisted API keys (
waba_<env>_…, created via the API or dashboard) — scope-based (read/write/full), lifecycle + expiry enforced. Cannot call management routes.
TOKEN="<API_AUTH_TOKEN or waba_...>"
# Create an API key (bootstrap only)
curl -X POST http://localhost:3000/api/v1/api-keys \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"CI","scope":"full","expires_at":null}'
# → { "key": { "serial": "...", "key_prefix": "waba_..." }, "secret": "waba_..._<64hex>" }
# secret is shown once at creation — store it securely.
# Reveal a key's secret on demand (bootstrap only, rate-limited, server-side decrypt)
curl http://localhost:3000/api/v1/api-keys/<serial>/secret -H "Authorization: Bearer $TOKEN"
# → { "secret": "waba_..._<64hex>" }
# Sessions: list / create / status / pairing / QR / logout
curl http://localhost:3000/api/v1/sessions -H "Authorization: Bearer $TOKEN"
# Webhooks: configure a delivery endpoint and subscribe to event types
curl -X POST http://localhost:3000/api/v1/webhooks -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"my-handler","url":"https://example.com/waba","phone_number_id":"+15550000000"}'POST /api/v1/sessions— create a session (optionally with abusiness_account_id).POST /api/v1/sessions/<serial>/pairing— start pairing; returns a job serial.GET /api/v1/sessions/<serial>/pairing/qr— get the QR to scan with the WhatsApp mobile app.GET /api/v1/sessions/<serial>/status— connection status.POST /api/v1/sessions/<serial>/logout— disconnect and remove the session.
Configure a webhook with a delivery URL, subscribe to event types
(messages, etc.), and the worker forwards inbound WhatsApp events to that
URL as WABA-shaped payloads with retry/backoff. POST /api/v1/webhooks/<serial>/test
fires a test payload. Payload mapping details: services/worker/docs/api-mapping-webhook.md.
npm test # all workspaces (dashboard unit tests via vitest)
npm run lint
npm run build
# Dashboard integration suite (real Postgres, gated on TEST_DATABASE_URL):
cd apps/dashboard
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5433/waba \
npx vitest run src/app/api/v1/api-keys/route.integration.test.ts
# Worker:
cd services/worker && go test ./...| Symptom | Cause / fix |
|---|---|
| API key create/reveal returns 500 | API_KEY_ENCRYPTION_KEY unset in apps/dashboard/.env — the feature fails closed by design |
/api/v1/* returns 401 |
Wrong or empty API_AUTH_TOKEN; persisted keys can't call management routes |
| Postgres connection refused on 5433 | docker compose up -d postgres not running, or another Postgres owns the port |
| Page shows "Internal Server Error" | Dashboard can't reach Postgres — check DATABASE_URL and the postgres container |
| Port 3000 taken | Run the dashboard on another port: next dev -p 3100 |
| Worker can't reach the API | API_URL in root .env must point at the dashboard port |
| Reveal returns 410 | Key predates ciphertext storage (key_ciphertext NULL) — not recoverable by design |
| Reveal returns 429 | Per-serial reveal rate limit (10 / 5 min) exceeded |
- API-key secrets are encrypted at rest with AES-256-GCM (
key_ciphertext); the SHA-256 hash remains the auth lookup. Plaintext is returned only at creation and on explicit reveal — never stored or logged. - Key rotation: set
API_KEY_ENCRYPTION_KEY_PREVIOUSto the old key, swapAPI_KEY_ENCRYPTION_KEY, verify reveals still work, then drop the previous key. - Token comparisons are constant-time.
/api/internal/*accepts onlyINTERNAL_TOKEN, strictly isolated from public credentials. - Empty
API_AUTH_TOKENcloses every public route.
See ROADMAP.md for the feature checklist across the five
target phases (control plane, reliable events, WhatsApp API, production
platform, developer experience) with done / partial / missing status.
docs/README.md— docs index.opencode/plans/split-architecture.md— architecture planHANDOFF.md— worker handoff notesservices/worker/docs/api-mapping-webhook.md— webhook payload mapping spec