WhatsApp over HTTP, on your own box.
A self-hosted REST API for WhatsApp, wire-compatible with WasenderAPI.
Site · Guide · API reference · OpenAPI
Link a number, get an API key, send and receive messages over plain HTTP.
curl -X POST https://api.wapi.crafter.run/api/send-message \
-H "Authorization: Bearer $KEY" \
-d '{"to":"+51999888777","text":"hello"}'{ "success": true, "data": { "msgId": 100024, "jid": "+51999888777", "status": "in_progress" } }Meta's Cloud API covers business messaging, not the conversations most teams actually run on — group chats, personal threads, the number people already message. Reaching those means driving a real WhatsApp client. wapi does that and puts a stable REST surface in front of it.
flowchart TB
NET["Cloudflare · Traefik"]
NET --> WEB["web<br/>Next.js 16<br/>dashboard · guide · Clerk"]
NET --> API["api<br/>Hono on Bun<br/>29 REST routes · stateless"]
API -- "RPC + Redis pub/sub" --> GW["gateway<br/>Node 22 · Baileys sockets<br/>stateful — one owner per session"]
GW -- enqueue --> WW["webhook-worker<br/>BullMQ · retry · backoff · DLQ"]
WW -- "POST + signature" --> APP["your app"]
subgraph STATE["shared state"]
direction LR
PG[("Postgres")]
RD[("Redis")]
OBJ[("UploadX / MinIO")]
end
API --> STATE
GW --> STATE
WW --> STATE
The gateway is the only stateful piece: a WhatsApp session is a live socket that exactly one process may own. Everything else scales sideways. Credentials live in Postgres rather than on disk, so a redeploy reconnects instead of asking you to scan a QR again.
The dashboard is both halves of that picture: link a number and watch its QR, then browse its contacts, groups and message log, watch webhook deliveries land as they happen, and run a health check that sends one message to the number itself and tells you what actually works.
sequenceDiagram
autonumber
participant C as your app
participant A as api
participant G as gateway
participant W as WhatsApp
participant K as webhook-worker
C->>A: POST /api/send-message
A->>A: validate · allocate msgId
A->>G: internal RPC (deadlined)
G->>W: Baileys socket
A-->>C: 200 — msgId, status in_progress
W-->>G: receipt / inbound message
G->>K: enqueue event
K-->>C: POST your webhook_url
Linking a real number needs a phone, a QR scan, and a number you are willing to have banned. A sandbox session needs none of them — a fake number on a fake WhatsApp that pairs itself, and goes through the same routes and the same code as a real session.
curl -X POST https://api.wapi.crafter.run/api/sandbox/sessions -H "Authorization: Bearer $PAT" -d '{"name":"my sandbox"}'It has a small directory, accepts sends, and — the point — can be made to receive messages, so your webhook handler gets a genuine signed delivery to prove itself against. Group mutations work too, which is the one part of the surface nobody should rehearse on a real number.
The dashboard gives a sandbox its own Sandbox tab: the invented contacts, the conversation as it happens, and a box to write a message as one of those contacts — the shortest path from "I have a webhook handler" to "I have watched it run".
bun install
cp .env.example .env # Postgres, Redis, Clerk, UploadX
bun run typecheck && bun test
docker compose upbun test covers unit, contract and SDK-compat suites; the ones needing a running stack or a real
number skip themselves rather than fail. The dashboard has its own browser suite
(bun run --cwd apps/web e2e) — see apps/web/e2e/README.md for the
one-time Chromium and Clerk setup.
The full stack is four services plus Postgres, Redis and object storage —
see docker-compose.yaml. Deployment targets a
Dokploy VPS from a single root Dockerfile.
29 endpoints reproduced from the WasenderAPI interface, down to the parts nobody would design on purpose: five distinct success envelopes, three failure envelopes, and two unrelated pagination shapes. Their published npm client runs against wapi unmodified — only the base URL changes:
const wa = createWasender(process.env.WAPI_KEY, undefined, "https://api.wapi.crafter.run/api");That claim is a test suite, not an aspiration: response schemas are checked against the provider's own documented examples, and again against live responses.
Zero runtime dependencies either side. Types are generated from the OpenAPI document so they
cannot drift from the server; method names are hand-written, because generated ones read
postApiWhatsappSessionsWhatsappSessionRegenerateKey.
Neither is published to a registry — they live in sdk/, so installation comes from here.
Go — resolves subdirectory modules natively:
go get github.com/crafter-station/wapi/sdk/go@mainclient := wapi.New(os.Getenv("WAPI_KEY"))
res, err := client.Messages.Send(ctx, "+51999888777", wapi.Text("hello"))Python — pip understands git subdirectories:
pip install "git+https://github.com/crafter-station/wapi.git#subdirectory=sdk/python"client = WapiClient(api_key=os.environ["WAPI_KEY"])
client.messages.send(to="+51999888777", text="hello")TypeScript — vendored, because npm cannot install a subdirectory of a git repository and this one sits in a monorepo:
npx giget@latest gh:crafter-station/wapi/sdk/typescript/src src/wapiconst wapi = new WapiClient({ apiKey: process.env.WAPI_KEY! });
await wapi.messages.send({ to: "+51999888777", text: "hello" });npx skills@latest add crafter-station/wapi --skill=wapi-nextjsShips a server-only client, a webhook route handler, and the API's non-obvious behaviour — so your agent writes the integration correctly the first time. Read it first; skills run with your agent's permissions.
wapi is built on Baileys, which speaks WhatsApp's protocol directly. That is what makes group access possible, and it is against WhatsApp's terms — numbers driven this way can be restricted or banned. Each session can route through its own proxy (http, https or socks5, covering both the socket and media transfers) and an account-protection mode paces sends. Neither is a guarantee.
Use a number you can afford to lose.
| Path | |
|---|---|
apps/api |
Hono on Bun — the 29 routes, stateless |
apps/gateway |
Node 22 — Baileys sockets, internal RPC only |
apps/webhook-worker |
BullMQ — delivery with retry and backoff |
apps/web |
Next.js 16 — dashboard, guide, Clerk auth |
packages/contracts |
Zod contracts + the emitted OpenAPI document |
packages/core |
shared logic, WhatsAppEngine and Storage interfaces |
packages/db |
Drizzle schema and migrations |
packages/baileys-auth |
Postgres-backed AuthenticationState |
sdk/typescript |
TypeScript client — generated types, hand-written surface |
sdk/python |
Python client — stdlib only, same surface |
sdk/go |
Go client — net/http only, nested module |
compat/ |
SDK-compatibility, fidelity (sandbox) and live integration suites |
apps/web/e2e |
Playwright — the only thing that renders a page |
Design decisions and their reasoning live in PLAN.md; repo conventions and the
traps worth knowing before changing anything are in AGENTS.md.