Documentation
Get started in two minutes.
wapi speaks the WasenderAPI interface. If you have written against that, everything here will look familiar — change the base URL and your existing client works.
Quick start
From zero to a sent message.
1. Create a session and link a number
Open the dashboard, create a session, press Connect, and scan the QR with WhatsApp → Settings → Linked devices. The code refreshes about every twenty seconds and updates live.
Once it shows connected, copy the session API key from that page.
2. Send something
curl -X POST https://api.wapi.crafter.run/api/send-message \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"to":"+51999888777","text":"hello from wapi"}'3. Read the response
Every send returns an integer msgId from our own sequence — not WhatsApp’s message id. You use it for replyTo and for GET /api/messages/{msgId}/info, which returns both identifiers side by side.
{ "success": true,
"data": { "msgId": 100024, "jid": "+51999888777", "status": "in_progress" } }Two field types on /info catch people out, and both follow WhatsApp’s own record rather than ours. messageTimestamp is a string — it is a protobuf 64-bit integer, which JSON cannot hold as a number — and status is WhatsApp’s numeric acknowledgement (0 error, 1 pending, 2 sent, 3 delivered, 4 read), not the word you get back from a send.
Authentication
Two keys, two jobs.
Both go in the same header — Authorization: Bearer <token> — but they are not interchangeable, and using the wrong one returns 403.
Session API key
Messaging, contacts, groups, media. The key is the session selector, which is why GET /api/status takes no session id. Found on the session page.
Personal Access Token
Account-level: creating, updating and deleting sessions, setting a proxy, regenerating keys. Mint one under Tokens.
Messaging
One endpoint, every message type.
POST /api/send-message handles everything. Which field you set decides what gets sent — there is no separate route for images or groups. Setting two content fields is an error rather than a silent preference.
{ "to": "+51999888777", "text": "hello" }Recipients can be a phone number in any readable form (+51999888777, 51999888777), a WhatsApp JID, a group JID ending @g.us, or a channel JID ending @newsletter. Sending to a group is the same call with a group JID.
Media
Upload, send, and decrypt.
Media is sent by URL: imageUrl and friends are fetched server-side. If you do not already host the file, upload it first and use the URL you get back — it is permanent, so it still resolves when the message is sent later.
curl -X POST https://api.wapi.crafter.run/api/upload \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: image/png' \
--data-binary @photo.png
# → { "success": true, "publicUrl": "https://api.wapi.crafter.run/media/<uuid>/photo.png" }Inbound media is encrypted. WhatsApp hands out a CDN link plus a mediaKey; the bytes are useless without decryption. Take the imageMessage (or video, audio, document, sticker) node from the webhook payload and post it to /api/decrypt-media. Uploads cap at 16 MB.
Groups & contacts
Reading the address book.
curl https://api.wapi.crafter.run/api/groups -H "Authorization: Bearer $KEY"
# → { "success": true,
# "data": [ { "jid": "120363...@g.us", "id": "120363...@g.us",
# "name": "Team", "subject": "Team", "imgUrl": null,
# "owner": "...", "creation": 1678886400,
# "desc": null, "participants": [ ... ] } ] }
#
# jid and name are the documented keys; id and subject carry the same
# values and are kept so existing callers keep working.On LIDs. Group participants and inbound senders often appear as …@lid rather than a phone number. That is WhatsApp’s newer identity format, not an error. pn-from-lid resolves it where a mapping has been observed; a miss there is legitimate, because resolution only works in one direction reliably.
Webhooks
Receiving as it happens.
Set a webhook URL on the session and we POST events to it, retrying up to five times with exponential backoff. Configure it under Settings on the session, or with the API call below.
Two signature schemes. By default X-Webhook-Signature carries the webhook secret itself, and you compare strings — that is WasenderAPI’s scheme, reproduced so their clients work unchanged. Turning on HMAC in Settings switches the header to HMAC-SHA256 over the raw request body, which is what you should prefer: it proves the payload was not altered, and it never puts the secret on the wire.
curl -X PUT https://api.wapi.crafter.run/api/whatsapp-sessions/1 \
-H "Authorization: Bearer $PAT" \
-H 'Content-Type: application/json' \
-d '{"webhook_url":"https://your.app/hook",
"webhook_enabled":true,
"webhook_events":["messages.received","session.status"]}'
# An empty webhook_events array means "send everything".
#
# HMAC signing is a wapi addition rather than part of the cloned
# interface, so it is not a field on this endpoint. Turn it on under
# Settings for the session in the dashboard.Useful events. messages.received for inbound only, messages.upsert for everything including your own sends, message.sent, messages.update for delivery and read receipts, session.status for connection changes, and qrcode.updated during pairing. There are twenty-two in total.
The three messages-personal, messages-group and messages-newsletter variants are filtered views of messages.received, so subscribe to those if you only care about one chat kind.
Sandbox
A fake number, a fake WhatsApp.
Linking a real number is the hardest step here and the one that carries the risk — you need a phone, a QR scan, and a number you are willing to have banned. A sandbox session removes all three. It pairs itself, comes with a small directory, accepts sends, and can be made to receive messages so you can watch your webhook handler run.
It is not a separate API. A sandbox session goes through the same routes and the same code as a real one, so what you build against it is what runs in production. Its number lives under country code +999, which is unassigned and cannot route anywhere.
# A wapi extension — WasenderAPI has nothing like this.
# Needs a PAT, like any session creation.
curl -X POST https://api.wapi.crafter.run/api/sandbox/sessions -H "Authorization: Bearer $PAT" -H 'Content-Type: application/json' -d '{"name":"my sandbox"}'
# → { "success": true, "data": { "id": 42,
# "phone_number": "+99900000042", "api_key": "..." } }
# Connect it. No QR to scan: it shows a fake one, then pairs itself
# after about four seconds — the same need_scan -> connected transition
# a real session makes, so your status webhook fires too.
curl -X POST https://api.wapi.crafter.run/api/whatsapp-sessions/42/connect -H "Authorization: Bearer $PAT"Groups are safe to change here, and only here. Creating a group and adding participants is the one part of the API worth never rehearsing on a real number, because it makes a real group and adds real people to it. On a sandbox the participants are invented. A created group is listed by GET /api/groupsafterwards, and per-participant status is reported the way WhatsApp reports it — so adding somebody already in the group comes back as 409 for that participant inside a 200 response.
Three things behave differently on purpose. account_protection pacing is ignored, so sends return immediately where production waits five seconds — it protects a phone number from being banned, and a fake number cannot be. decrypt-media returns a fixed PNG rather than real media. And everything a sandbox accumulates — its conversation, any groups you create — lives in memory: a restart returns it to its fixtures, and logout is how you reset one deliberately. The first two matter if you tune retry or timing logic against a sandbox: production is slower.
Sandbox sessions are capped at 25 per account and carry a sandbox badge everywhere they appear in the dashboard, so a fake number is never mistaken for a live one. Each also gets its own Sandbox tab — the invented contacts, the conversation as it happens, and a box to write a message as one of those contacts. It is the shortest path from “I have a webhook handler” to “I have watched it run”.
Errors
Two shapes, on purpose.
Failures come back in one of two forms, and which one tells you where the failure happened. This mirrors the interface being cloned rather than being tidied up.
{ "success": false,
"error": "Your Whatsapp Session is not connected please connect your session first." }{ "success": false,
"message": "Validation failed",
"errors": { "to": ["The to field is required."] } }Rate-limit headers — X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset — are on every response. A 429 carries retry_after in seconds.
Common statuses. 401 missing or invalid key, 403 wrong credential type, 409 session not connected, 422 validation, 503 the WhatsApp service is briefly unavailable — retry.
Audit log
Every call, on the record.
Each request to the API writes one row: which credential acted, the endpoint, the headers, the request and response, the status, how long it took and where it came from. Read it on the Audit page, filtered by session or to errors only, and open any entry for the full record.
{
"method": "POST",
"route": "/api/send-message",
"path": "/api/send-message",
"status": 200,
"durationMs": 1249,
"credentialKind": "session",
"sessionId": 3,
"ip": "38.187.27.123",
"userAgent": "curl/8.9.0",
"requestHeaders": { "content-type": "application/json" },
"requestBody": { "to": "+51999888777", "text": "hello" },
"responseBody": { "success": true, "data": { "msgId": 100722 } }
}Rejected requests are recorded too — the audit middleware runs before authentication, so a sweep of bad credentials shows up as a run of 401s rather than as nothing at all.
One honest limitation. The write is fire-and-forget so that no send can fail because logging did, which means rows are best-effort: if the database is unreachable the request still succeeds and nothing is written. Treat this as an operational record, not a compliance ledger.
TypeScript SDK
A typed client, batteries included.
@wapi/sdk wraps the whole surface with no runtime dependencies — it uses global fetch, so Node 18+, Bun and Deno all work. It exists so you do not have to remember which of the five success envelopes an endpoint uses.
Vendor it rather than installing it. npm cannot install a subdirectory of a git repository, and this client lives inside a monorepo — so npm install github:crafter-station/wapi would fetch the root package, not the SDK. Since the client is dependency-free source, copying it in is a real channel rather than a workaround:
npx giget@latest gh:crafter-station/wapi/sdk/typescript/src src/wapi
# Then import it as local code:
# import { WapiClient } from "./wapi/index.js";
#
# Copy src/ and nothing else — scripts/ beside it is a build tool for
# the wapi repository and imports packages you will not have.import { WapiClient } from "@wapi/sdk";
const wapi = new WapiClient({ apiKey: process.env.WAPI_KEY });
const { msgId } = await wapi.messages.send({
to: "+51999888777",
text: "hello",
});
// Which field you set decides what is sent, and the types make
// setting two of them a compile error rather than a 422.
await wapi.messages.send({
to: "+51999888777",
imageUrl: "https://example.com/photo.jpg",
text: "optional caption",
});The types are generated from the same OpenAPI document this site publishes, so they cannot drift from the server; the method names are written by hand, because generated ones would read postApiWhatsappSessionsWhatsappSessionRegenerateKey. Source is in sdk/typescript, and sdk/ records the shape ports to other languages should follow.
Python SDK
The same client, in Python.
Same surface, same decisions, snake_case. Zero runtime dependencies — it uses urllib from the standard library — and it is synchronous, because most Python callers here are scripts and workers.
# pip understands git subdirectories, so this is an ordinary install.
pip install "git+https://github.com/crafter-station/wapi.git#subdirectory=sdk/python"
# Pin a tag for anything you deploy — main moves.
pip install "git+https://github.com/crafter-station/wapi.git@v0.1.0#subdirectory=sdk/python"from wapi import WapiClient
client = WapiClient(api_key=os.environ["WAPI_KEY"])
result = client.messages.send(to="+51999888777", text="hello")
print(result["msgId"])
# Which field you set decides what is sent.
client.messages.send(
to="+51999888777",
imageUrl="https://example.com/photo.jpg",
text="optional caption",
)Go SDK
And in Go.
Same surface again, zero dependencies, net/http only. Go resolves subdirectory modules natively, so unlike the TypeScript client this is an ordinary install rather than a vendoring step.
go get github.com/crafter-station/wapi/sdk/go@main
# Pin a commit for anything you deploy — @main moves.Compatibility
Their SDK, unmodified.
wapi implements the WasenderAPI interface closely enough that their published npm client works against it with no changes — this is covered by an automated test suite, not just an aspiration.
import { createWasender } from "wasenderapi";
// Third argument is the base URL. That is the whole migration.
const wa = createWasender(
process.env.WAPI_KEY,
undefined,
"https://api.wapi.crafter.run/api",
);
await wa.sendText({ to: "+51999888777", text: "hello" });
const groups = await wa.getGroups();The per-endpoint reference, with every field and response shape, is generated from the same contract the server validates against: api.wapi.crafter.run/docs. The raw spec is at /openapi.json.
Agent skill
Let your agent wire it up.
If you build with Claude Code, Cursor, Copilot or another agent, install the wapi-nextjs skill. It carries a server-only client, a webhook route handler, and notes on the parts of this API that are not guessable from the endpoint names — so your agent writes the integration correctly the first time instead of inferring it.
npx skills@latest add crafter-station/wapi --skill=wapi-nextjs
# Installs to .agents/skills/ and symlinks .claude/skills/ for Claude Code.
# Works with Cursor, Codex, Gemini CLI, Copilot and others from the same copy.It is four files in this repository under .claude/skills/wapi-nextjs, so you can read the whole thing before installing it — worth doing with any skill, since they run with your agent’s permissions. Prefer to copy by hand? The client and the webhook handler are directly usable on their own.