Integrating wapi into a Next.js app
wapi exposes WhatsApp over a plain REST API and is wire-compatible with WasenderAPI.
- Base URL:
https://api.wapi.crafter.run - Per-endpoint reference (generated from the server's own contract):
https://api.wapi.crafter.run/docs - Raw OpenAPI spec:
https://api.wapi.crafter.run/openapi.json - Narrative guide, with worked examples:
https://wapi.crafter.run/docs
Fetch the OpenAPI spec if you need a field this skill does not cover — it is generated from the contract the server validates against, so it cannot drift from the implementation.
If you are an agent building this without a human at the keyboard, read step 0 first. You cannot scan a QR code, and every real session begins with one.
Work in this order. Steps 1–3 are always required; 4 and 5 are per-feature.
0. Use a sandbox session while you build
A sandbox session is a fake number on a fake WhatsApp. It pairs itself — no QR, no phone, nothing to ban — and goes through the same routes and the same code as a real one, so what you build against it is what runs in production.
# Create one. Needs a PAT.
curl -X POST "$WAPI_BASE_URL/api/sandbox/sessions" -H "Authorization: Bearer $WAPI_PAT" -H 'Content-Type: application/json' -d '{"name":"agent sandbox"}'
# → data.api_key is what your app should use as WAPI_API_KEY while developing.
# Connect it, then poll: it pairs itself after ~4s.
curl -X POST "$WAPI_BASE_URL/api/whatsapp-sessions/<id>/connect" -H "Authorization: Bearer $WAPI_PAT"
Then, once your webhook handler exists, make it receive a real delivery:
await wapi.sandbox.inbound("hello from a fake human");
That fabricates an inbound message and sends it down the ordinary pipeline, so your endpoint gets
a genuine, signed messages.received. It is the only way to verify a webhook handler without a
real conversation, and it is the single most useful thing here for an agent.
Group and contact writes belong here specifically. Creating a group, adding or promoting
participants, leaving, blocking somebody — these are the parts of the API you should not rehearse
on a real number, because they make a real group, add real people, and block a real contact. On a
sandbox all of it is invented, and the read-back works: a created group is listed by
GET /api/groups, an invite code from GET /invite-link is accepted by
POST /api/groups/invite/accept, and a name saved with PUT /api/contacts shows up in the
directory.
If you have a shell, the wapi CLI does all of this in one line each — wapi sandbox create --use, wapi sessions connect, wapi sandbox inbound "hello" — and wapi sandbox thread -f
tails the fake conversation while your handler runs. It is a single binary with the runtime
inside, so installing it adds nothing to the project:
curl -fsSL -o wapi https://github.com/crafter-station/wapi/releases/latest/download/wapi-linux-x64
chmod +x wapi && sudo mv wapi /usr/local/bin/
wapi login
On macOS use wapi-darwin-arm64 and clear the quarantine attribute afterwards
(xattr -d com.apple.quarantine wapi), or Gatekeeper refuses to run an unsigned download. On
Windows the asset is wapi-windows-x64.exe.
If you have a browser, 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. It is the fastest way to watch your webhook handler run.
Swap in a real session before shipping. Two behaviours differ deliberately:
account_protection pacing is ignored (production waits five seconds per send) and
decrypt-media returns a fixed PNG. Do not tune retry or timing logic against a sandbox.
1. Decide which credential each call needs
Two token types go in the same Authorization: Bearer header and are not interchangeable.
Using the wrong one returns 403, not 401, which is the usual source of a confusing first
failure.
| Token | Use for | Where it comes from |
|---|---|---|
| Session API key | messaging, contacts, groups, media, status | the session's page in the dashboard |
| Personal Access Token | creating/updating/deleting sessions, proxies, regenerating keys | the Tokens page |
The session key is the session selector — that is why GET /api/status takes no session id.
Most app code needs only the session key.
2. Put the key on the server, never in the browser
Both tokens grant full control of a WhatsApp account. They must never reach the client.
# .env.local — no NEXT_PUBLIC_ prefix, deliberately
WAPI_BASE_URL=https://api.wapi.crafter.run
WAPI_API_KEY=<session api key>
WAPI_WEBHOOK_SECRET=<session webhook secret>
If a component needs to trigger a send, use a Server Action or a Route Handler. Never a
fetch from a client component with the key in scope — a NEXT_PUBLIC_ key is world-readable
the moment it ships.
3. Add the client
Vendor the official SDK, then add the server-only wrapper:
npx giget@latest gh:crafter-station/wapi/sdk/typescript/src src/wapi
Copy references/wapi-server.ts to src/lib/wapi.ts. It is deliberately thin — the SDK does the
work, and the wrapper adds the one thing the SDK cannot express because it is a Next.js concern
rather than an API one: server-only, which turns importing it from a client component into a
build error rather than a leaked WhatsApp credential.
Vendored rather than installed because npm cannot install a subdirectory of a git repository
and the SDK lives inside a monorepo; npm install github:crafter-station/wapi fetches the root
package instead. Copy src and nothing else.
Earlier versions of this skill shipped their own client. That was a second implementation of the same API covering thirteen of its thirty operations, with nothing keeping it in step — the SDK is checked against the OpenAPI document in CI, so use that instead.
Read references/api-notes.md before hand-rolling anything: several behaviours are not guessable
from the endpoint names.
4. Send a message
POST /api/send-message is one endpoint for every message type. Which field you set decides
what is sent — there is no separate route for images or groups. Setting two content fields is
an error rather than a silent preference.
"use server";
import { wapi } from "@/lib/wapi";
export async function notify(phone: string, text: string) {
const { msgId } = await wapi.messages.send({ to: phone, text });
return msgId;
}
Sending to a group is the same call with a group JID (…@g.us) as to.
Reacting is a separate call and a wapi extension — WasenderAPI reports reactions over webhooks but has no endpoint to send one, so feature-detect if you target both:
await wapi.messages.react(data.key, "👍"); // data.key comes straight from the webhook
await wapi.messages.unreact(data.key); // empty emoji clears it
It takes the WhatsApp key, not a msgId, because you mostly react to messages someone else
sent and those have no msgId.
Media is sent by URL — imageUrl, documentUrl and friends are fetched server-side at send
time. If the file is not already hosted, POST /api/upload first and use the URL it returns:
that one is permanent, so it still resolves when the message is sent later.
5. Receive webhooks
Create a Route Handler and verify the signature before trusting anything. Copy
references/webhook-route.ts to src/app/api/wapi/webhook/route.ts.
Two things that will bite otherwise:
- The default signature is a plain string compare, not an HMAC —
X-Webhook-Signaturecarries the webhook secret itself. That is WasenderAPI's scheme, reproduced for compatibility. wapi additionally supports HMAC-SHA256 over the raw body as a per-session opt-in (webhook_hmac); prefer it, and the reference handler supports both. - Acknowledge fast. Delivery retries with backoff on any non-2xx, so slow handlers cause duplicate deliveries. Return 200 and do the work after.
Point the session at the handler:
curl -X PUT "$WAPI_BASE_URL/api/whatsapp-sessions/1" \
-H "Authorization: Bearer $WAPI_PAT" \
-H 'Content-Type: application/json' \
-d '{"webhook_url":"https://your.app/api/wapi/webhook",
"webhook_enabled":true,
"webhook_events":["messages.received"]}'
An empty webhook_events array means send everything.
Checks before you call it done
- No token reaches the client bundle. Grep the build output for the key's prefix.
- The webhook handler rejects a request with a wrong or missing signature.
- A
403is handled distinctly from401— they mean different things here. - Pagination uses
contacts.page()/groups.page(), notlist(). They are separate methods because?paginated=truereturns a different shape, and mixing them up yieldsundefined. - Sends are not retried blindly. A timeout does not mean the message was not sent; reconcile
with
wapi.messages.info(msgId)rather than sending again. src/wapi/is vendored, so it does not update itself. Re-run the giget command after the API gains endpoints you need.- If you built against a sandbox, the app is pointed at a fake number. Swapping to a real session is a credential change, but re-read the timing caveats in step 0 before assuming behaviour carries over.
Migrating from WasenderAPI
Their published npm client works unmodified. The base URL is the third argument:
import { createWasender } from "wasenderapi";
const wa = createWasender(process.env.WAPI_API_KEY!, undefined, `${base}/api`);
If a client validates media hostnames or pins the provider origin, point it at wapi's origin — media is served from the same host as the API.