Build on whasp
Real personal numbers for WhatsApp, driven by a JSON API or by your agent over MCP. Base URLhttps://api.whasp.ai/v1. From zero to your first reply in five steps, then the full reference.
Quickstart
Top up and add a number
Sign in with your email, top up the balance and press Add number. It is usually ready at once. Set its name, about and photo on its page.
Create an API key
In API keys, with a name and the scopes it needs (all of them unless you choose). It is shown once: store it on your server as
WHASP_API_KEYand send it asAuthorization: Bearer wsk_....Set your webhook and verify it
Add your HTTPS endpoint in Webhook and copy the signing secret (shown once). Deliveries follow Standard Webhooks: three headers, one line to verify. After you rotate the secret, the old one keeps signing for 24 hours next to the new one (
webhook-signature: v1,<new> v1,<old>), so you can switch without missing an event.POST /whasp/webhook HTTP/1.1 content-type: application/json webhook-id: evt_2bX9kq7TfLw3 webhook-timestamp: 1792350000 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET); // whsec_... export async function POST(request) { const body = await request.text(); // the raw body: verify before parsing let event; try { // Checks webhook-id, webhook-timestamp (5 min max) and webhook-signature. event = wh.verify(body, Object.fromEntries(request.headers)); } catch { return new Response("Invalid signature", { status: 401 }); } // Events can arrive twice or out of order: dedupe on event.id. if (event.type === "message.received") { const { chat, text } = event.data.message; // The only copy: our servers keep no messages. Store it, hand it to your // agent, then answer with POST /v1/messages. } return new Response(null, { status: 204 }); }Answer 2xx within 10 seconds, then do the slow work. Events can arrive twice or out of order: dedupe on
id.Show new users a number
End users always write first. Ask for the number with the most room and show it, or its link, which opens the chat with your text prefilled.
curl "https://api.whasp.ai/v1/numbers/pick?text=Hi%20Ava" \ -H "Authorization: Bearer $WHASP_API_KEY" { "number": "+15555550102", "wa_link": "https://wa.me/15555550102?text=Hi%20Ava" }Reply
When
message.receivedarrives, answer withPOST /v1/messages. Leave outfromand the reply goes out from the number the user wrote to. Reuse theIdempotency-Keyon retries and it never sends twice.curl https://api.whasp.ai/v1/messages \ -H "Authorization: Bearer $WHASP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 4f9d2c1e-7b3a-4e8f-9a61-0c5d8e2b7f13" \ -d '{ "to": "+15555550111", "text": "Hey! How did the interview go?" }'{ "id": "msg_num_3fK9xQ2LmP8rT1vZ0aBc_8fK2xQ", "number": "+15555550100", "chat": "+15555550111", "direction": "outbound", "status": "queued", "error": null, "created_at": "2026-10-18T19:01:00Z", "sent_at": null }our servers keep no messages:
message.receivedis the only copy you get of what the user wrote, and a message object carries its status, never its text. Store the conversation on your side. Files people send are links valid 24 hours.Plan for a ban
If WhatsApp bans a number for good,
number.replacedgives you a new one at no cost, same profile and paid period. It starts empty and cannot write first, so your logic moves your users: send them itswa_linkin your app or by email, and each chat opens when they write.
Connect your agent
whasp is also an MCP server: Claude Code, Cursor, Codex, Claude Desktop or any MCP client can list your numbers, see who wrote, pick a number for a new user and reply, with the same answers and errors as the REST API.
- URL:
https://api.whasp.ai/mcp - Transport: streamable HTTP, stateless
- Auth:
Authorization: Bearer wsk_..., the same API keys
Create a key in API keys and keep it in WHASP_API_KEY. The blocks below read it from there, so it never lands in a file you commit. The tools a key can call follow its scopes: a key withoutmessages:send cannot send.
claude mcp add --transport http --scope user \
whasp https://api.whasp.ai/mcp \
--header "Authorization: Bearer $WHASP_API_KEY"{
"mcpServers": {
"whasp": {
"url": "https://api.whasp.ai/mcp",
"headers": {
"Authorization": "Bearer ${env:WHASP_API_KEY}"
}
}
}
}[mcp_servers.whasp]
url = "https://api.whasp.ai/mcp"
bearer_token_env_var = "WHASP_API_KEY"{
"mcpServers": {
"whasp": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://api.whasp.ai/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer wsk_..."
}
}
}
}{
"mcpServers": {
"whasp": {
"type": "http",
"url": "https://api.whasp.ai/mcp",
"headers": {
"Authorization": "Bearer wsk_..."
}
}
}
}Where each client keeps it
Claude Code: check with claude mcp list, or /mcp inside a session. The shell expands the variable, so the key is saved in ~/.claude.json. To keep it out of every file, add a project file instead; Claude Code reads ${WHASP_API_KEY} from the environment when it starts:
{
"mcpServers": {
"whasp": {
"type": "http",
"url": "https://api.whasp.ai/mcp",
"headers": {
"Authorization": "Bearer ${WHASP_API_KEY}"
}
}
}
}Cursor: ~/.cursor/mcp.json for every project, or .cursor/mcp.json for one. Cursor fills ${env:WHASP_API_KEY} from the environment it was started in.
Codex: the config block above, or from the terminal. The variable must be set in the shell that runs codex.
codex mcp add whasp --url https://api.whasp.ai/mcp --bearer-token-env-var WHASP_API_KEYClaude Desktop does not see your shell's variables, so the key goes inclaude_desktop_config.json (Settings, Developer, Edit config) and mcp-remote connects for it. Restart Claude Desktop after saving.
Check the endpoint and your key without a client:
curl -s https://api.whasp.ai/mcp \
-H "Authorization: Bearer $WHASP_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Or paste this into an agent that can edit its own config:
Add an MCP server named "whasp": streamable HTTP at https://api.whasp.ai/mcp, header "Authorization: Bearer <the value of WHASP_API_KEY>". Keep the key in the environment, not in a file I commit. Then call list_numbers and show me the result.Messages people send reach you on your webhook only (message.received): our servers keep no messages, so no tool reads a conversation back. Your agent sends with send_message and follows the status withget_message. Every tool is in the MCP table below.
Base URL https://api.whasp.ai/v1 (also served as https://whasp.ai/v1, the
dashboard’s origin). JSON in, JSON out. Times are ISO 8601 (UTC), money is
integer cents (USD), phone numbers are E.164 (+15555550100). MCP at
https://api.whasp.ai/mcp (see MCP).
Auth
Authorization: Bearer wsk_.... Keys are created and revoked in the dashboard,
shown once, at most 10 active. A key carries scopes, all of them unless chosen:
numbers:read (numbers, chats, message status), numbers:manage (buy, change,
cancel), messages:send. The dashboard calls the same routes with its session
cookie, which may do everything.
Errors
{ "error": { "code": "chat_not_open", "message": "+15555550111 has not written to any of your numbers yet." } }
| Status | Code | When |
|---|---|---|
| 400 | invalid_request |
bad body or parameter, or a body field we do not know (never ignored); the message names the field |
| 401 | unauthorized |
missing or wrong key or session |
| 402 | insufficient_balance |
not enough balance for a number-month |
| 403 | insufficient_scope |
the key lacks the scope of the route |
| 403 | session_required, forbidden_origin |
dashboard-only action |
| 403 | chat_opted_out |
the end user sent STOP (see Opt-out) |
| 404 | not_found |
no such number or message on this account |
| 409 | chat_not_open |
sending to someone who never wrote to that number |
| 409 | number_unavailable |
the number is banned, offline, without its network, retired or not set up yet |
| 409 | no_capacity |
/numbers/pick: every number is full, offline or unhealthy |
| 409 | idempotency_conflict |
another request with the same Idempotency-Key is still running (retry), or the key was used for another kind of request |
| 422 | media_unreachable, media_too_large |
we could not fetch media.url, or it is over the limit (see Messages) |
| 429 | rate_limited |
over 100 requests in 10 s per account |
| 429 | number_daily_limit |
the number sent 1500 messages in the last 24 h |
| 500 | internal_error |
our fault |
| 502 | checkout_unavailable |
the payment provider did not answer, retry in a minute |
| 503 | sales_closed |
top-ups are not open |
| 503 | no_capacity |
no number ready and none can be set up right now; the charge is refunded at once, retry later |
Numbers
Each number is a real personal WhatsApp account on its own Android cloud phone, with its own US mobile number and its own IP. $49 per 30 days, taken from the prepaid balance; renews by itself while the balance covers it (3 days of grace when it does not).
Number object
{
"id": "num_3fK9xQ2LmP8rT1vZ0aBc",
"number": "+15555550100",
"wa_link": "https://wa.me/15555550100",
"label": "support-1",
"status": "active",
"health": "healthy",
"health_reason": null,
"online": true,
"profile": { "name": "Ava", "about": "Your AI friend", "photo_url": "https://...", "synced": true },
"stats": { "active_chats_30d": 64, "new_chats_24h": 3, "outbound_24h": 412 },
"limits": { "active_chats_30d": 100, "new_chats_24h": 40, "outbound_24h": 1500 },
"paid_until": "2026-11-17T19:00:00.000Z",
"auto_renew": true,
"replaced_by": null,
"created_at": "2026-10-18T19:00:00.000Z"
}
id: stable from the moment you buy.numberandwa_linkare null while the number is being set up and has no phone number yet.status:provisioning(being set up for you, about 30 min),active,retired.health:healthy,at_risk(WhatsApp warning, temporary ban, or a limit above 80%),critical(banned, logged out, phone offline over 10 min, the phone’s network down, sends failing).health_reasonsays why in plain words. While the phone’s network is down, or once the phone is flagged offline, new sends get 409number_unavailable; queued ones wait up to 10 minutes and go out once it is back, or fail withmessage.failed.replaced_by: after a ban, the number that took over.
{number} in a path is the E.164 or the id.
| Method | Path | |
|---|---|---|
| GET | /numbers |
{ "data": [Number] }, retired ones included. Query text prefills every wa_link. |
| POST | /numbers |
Add a number. Body { "label"?, "profile"?: { "name", "about"?, "photo_url"? } }; label up to 64 chars, photo_url a public HTTPS JPEG, PNG or WebP up to 5 MB. Charges $49 first, then hands over or sets up the number: 201 with the number active when one is ready (the usual case), 202 with provisioning otherwise, then number.ready; refunded once if the setup fails (number.status_changed, retired). 503 no_capacity when no phone can be had right now, the charge refunded at once. Optional header Idempotency-Key (max 255), per account and kept 7 days: a retry with the same key returns the same number as it is now (200 when it is neither active nor provisioning) and never charges again; after a 503 the key is free. A saved purchase is resumed before downloading its photo again; a key used for another operation answers 409 idempotency_conflict. Ready stock remains available to buy even when fleet has no free IP for another setup. |
| GET | /numbers/{number} |
Number. Query text as above. |
| PATCH | /numbers/{number} |
{ "label"?, "profile"?, "auto_renew"? }. A profile change is applied on the phone within minutes (profile.synced). Profile name (max 25) and about (max 139) are plain ASCII for now. |
| DELETE | /numbers/{number} |
Stop renewing: the number works until paid_until, then retires. On a banned number whose replacement is still on its way (see Bans) it stops the renewal of the subscription the replacement takes over. |
| GET | /numbers/pick |
{ "number", "wa_link" }: the healthy, online number with the most room for a new end user (a number whose phone we cannot reach right now is never picked). Show it (or the link, which opens the chat with ?text prefilled if you pass text) to new users. 409 no_capacity when all are full, offline or unhealthy. |
Messages
End users always write first. A number can answer anyone who wrote to it; it
cannot open a chat cold (409 chat_not_open), because that is what gets
personal numbers banned. This holds for every number, a replacement after a
ban included.
whasp’s servers keep no message content. We send and receive; your system
keeps the history. The text and file of a message you send stay on our servers
only while it waits in the queue or is being sent. An inbound message’s content
reaches you once, in message.received, and is not stored; its file is deleted
after 24 hours. A webhook event keeps its body only until it is delivered or
given up. Message objects carry ids, status and times, never content, and there
is no endpoint to read a conversation back. WhatsApp itself, like on any phone,
keeps the chats on the number’s phone while the number lives; the phone and
everything on it are wiped when the number ends.
Message object
{
"id": "msg_3fK9xQ2LmP8rT1vZ0aBc_8fK2...",
"number": "+15555550100",
"chat": "+15555550111",
"direction": "outbound",
"status": "sent",
"error": null,
"created_at": "2026-10-18T19:01:00.000Z",
"sent_at": "2026-10-18T19:01:02.000Z"
}
Outbound status: queued, sent, failed. Inbound: received. Ids are opaque.
Inbound content, only in message.received: the Message object plus text
and media, and when present location, contacts, reply_to, sender_name.
media is { "url", "mime", "filename", "size" }: a signed link valid 24
hours, the file deleted after 24 hours, so copy it if you need it (a file over
30 MB arrives without it, media null; pictures, audio and MP4 open in the
browser, other files download). location is
{ "latitude", "longitude", "name", "address", "live" }, contacts vCards as
strings, reply_to { "id", "text" } (the quoted message: id null when we
do not know it), and sender_name the name they gave themselves on WhatsApp.
| Method | Path | |
|---|---|---|
| POST | /messages |
Send. { "to": "+1...", "from"?: "+1..." or id, "text"?, "media"?: { "url", "filename"? }, "override_optout"?: false }, at least one of text and media; with both, the phone sends the file first and then the text as a second WhatsApp message (no caption for now), still one API message. Without from we use the number to last wrote to (the usual case: a chat stays on its number). text up to 4096 chars, media.url public HTTPS up to 64 MB (16 MB when the server sends no Content-Length): we fetch it once when you send, the phone gets it from us, and our copy goes when the send ends (422 media_unreachable or media_too_large otherwise). Optional header Idempotency-Key (max 255), per account and kept 7 days: the same key returns the first message, from the number it went from, and never sends twice; a refused send (4xx) does not use up the key. 202 with the message queued. Messages from one number go out one at a time, in order, about a second apart. |
| GET | /messages/{id} |
Message: its status, no content. |
| GET | /numbers/{number}/chats |
{ "data": [Chat], "next_before" }, newest first. Query limit (max 100), before. Who wrote, when, and who opted out. |
Chat object: { "phone", "name", "opted_out", "last_message_at", "last_inbound_at", "first_inbound_at" }; name is the end user’s WhatsApp name, null until we know it.
Opt-out. An inbound message that is exactly STOP, UNSUBSCRIBE,
OPTOUT, CANCEL, END or QUIT (case sensitive), or any spelling of
opt out, opts the chat out: sends to it get 403 chat_opted_out, and messages
to it still in the queue are not sent either: they fail with message.failed,
error chat_opted_out. One send with "override_optout": true goes through
anyway (for “You are unsubscribed, write any time”), also when it was already
queued. The block lifts as soon as the end user writes again. Polite
requests (“please stop”) are yours to catch.
Not supported (WhatsApp on a phone, driven like a person): typing indicators, read receipts, outbound reactions, replying to a specific message, groups, status, voice notes (an audio file arrives as a file).
MCP
POST https://api.whasp.ai/mcp, Streamable HTTP, Authorization: Bearer wsk_....
The same API as tools, with the same answers and errors as REST (an error is
the REST error body, as a tool error). A key sees only the tools its scopes allow.
| Tool | Scope | REST |
|---|---|---|
list_numbers { text? } |
numbers:read |
GET /numbers |
get_number { number, text? } |
numbers:read |
GET /numbers/{number} |
list_chats { number, limit?, before? } |
numbers:read |
GET /numbers/{number}/chats |
get_message { id } |
numbers:read |
GET /messages/{id} |
buy_number { label?, profile?, idempotency_key? } |
numbers:manage |
POST /numbers |
send_message { to, from?, text?, media?, override_optout?, idempotency_key? } |
messages:send |
POST /messages |
Webhook
One HTTPS endpoint per account, set in the dashboard (these routes take the session only, so a leaked key cannot redirect your events).
| Method | Path | |
|---|---|---|
| GET | /webhook |
{ "url", "secret_last4" }: the secret itself is shown only when it is made or rotated. |
| PUT | /webhook |
{ "url" }. Returns { "url", "secret_last4" }, plus "secret" (whsec_ and a base64 key) the first time, when it is created; it is kept when the URL changes. |
| DELETE | /webhook |
Stop sending. |
| POST | /webhook/rotate-secret |
{ "url", "secret", "secret_last4" }: a new secret, shown once. For 24 hours the old one signs too (webhook-signature: v1,<new> v1,<old>), so you can switch without a gap. |
| POST | /webhook/test |
Sends a webhook.test now and answers { "id", "delivered", "status_code", "error" }. |
| GET | /webhook/deliveries |
Last 7 days, newest first, with a key too: { "data": [{ "id", "type", "status", "attempts", "last_status_code", "last_error", "created_at", "delivered_at" }], "next_before" }. Only the delivery’s record: we drop an event’s body once it is delivered or given up, so nothing can be sent again. |
Delivery. POST with the event as JSON body, signed per Standard Webhooks
(the official standardwebhooks libraries verify it): headers webhook-id (the
event id), webhook-timestamp (unix seconds) and webhook-signature: v1,<base64 HMAC-SHA256 of "<id>.<timestamp>.<raw body>">, keyed with the
secret (whsec_ and a base64 key); for 24 hours after a rotation the header
carries a signature with each secret, space-separated. Any 2xx within 10 s is a
success. Otherwise we retry with backoff (30 s, 2 min, 10 min, 30 min, 1 h, 2 h,
4 h, 8 h, 8 h: about a day) up to 10 attempts, then mark it failed. Without a
webhook URL an event is only listed, as no_endpoint, and never sent. Order is
not guaranteed and an event can arrive twice: dedupe on id. Every event about
a message or a number is recorded with the change it tells, or retried until it
is, under the same id. Reject signatures older than 5 minutes.
Envelope
{ "id": "evt_...", "type": "message.received", "created_at": "2026-10-18T19:00:00.000Z", "data": { } }
| Type | data |
|---|---|
message.received |
{ "message": Message } (inbound) with its content: text, media and the rest (see Inbound content). The only copy of the content: keep what you need. |
message.sent |
{ "message": Message } |
message.failed |
{ "message": Message }, reason in message.error |
reaction.received |
{ "number", "chat", "emoji", "message": { "id", "text" } }: the message reacted to, id null when we do not know it; empty emoji = reaction removed |
number.ready |
{ "number": Number }, a number that was provisioning is active |
number.status_changed |
{ "number": Number }, when health or online changes, or it retires |
number.replaced |
{ "old_number", "new_number": Number } |
balance.low |
{ "balance_cents", "renewals_cents" }: the balance does not cover the renewals of the next 7 days |
webhook.test |
{ "message": "It works." } |
Bans and lost phones. When WhatsApp bans a number for good, or fleet reports
that its ready phone was lost, we retire it and move you to a
ready number at no cost, same label and profile, same paid_until and
auto_renew (it takes over the old number’s subscription). The new number
starts empty: no chats move, and like any number it cannot write first (409
chat_not_open). Your own logic moves your end users, for example by sending
them the new number’s wa_link in your app or by email; each chat opens when
they write to it. Once they do, sends without from go out from the new number.
When no number is ready we set one up for you (about 30 minutes, then
number.replaced); meanwhile the banned number shows retired with the
subscription’s real auto_renew, and DELETE on it cancels the renewal.
A lost phone is detected by the five-minute health check and retires with reason
its phone was lost. It uses the same replacement and subscription described
above. An expired number rental alone remains a warning: it does not replace a
working WhatsApp account.
Account and billing
Lists (/webhook/deliveries, /billing/orders, /billing/ledger, chats) are
newest first, take limit (1 to 100, default 50) and before, and return
next_before (null on the last page). For the next page pass the next_before
of the previous one as before, as it is: it is opaque.
| Method | Path | |
|---|---|---|
| GET | /account |
{ "id", "email", "balance_cents", "monthly_cents", "webhook_url", "created_at", "numbers_active" } |
| GET | /api-keys |
Dashboard only. { "data": [{ "id", "name", "scopes", "last4", "created_at", "last_used_at" }] } |
| POST | /api-keys |
Dashboard only. { "name"?, "scopes"? } -> the key with "key" shown once. At most 10 active keys. |
| DELETE | /api-keys/{id} |
Dashboard only. Revoke. |
| POST | /billing/topup |
{ "amount_usd" } (whole dollars, 49 to 10000). { "url" }: Norpo checkout (card, Apple Pay, Google Pay, crypto). The balance grows when Norpo confirms. |
| GET | /billing/orders |
Top-ups: { "data": [{ "id", "amount_cents", "currency", "status", "created_at", "paid_at" }], "next_before" }, status pending, paid or failed. |
| GET | /billing/subscriptions |
What renews: each number’s subscription, its price and paid_until. |
| GET | /billing/ledger |
Every balance change: { "data": [{ "id", "amount_cents", "kind", "ref", "created_at" }], "next_before" }, amount_cents negative when taken, kind topup, number, refund or adjustment; a number’s ref is <number id>@<period start ms>, a refund’s the number id. |
Sign in (dashboard)
On https://whasp.ai/auth, cookie-based, no API key.
| Method | Path | |
|---|---|---|
| POST | /auth/signin |
{ "email", "turnstile_token" }. Always { "ok": true }; mails a link. The first sign-in creates the account. |
| GET | /auth/signin/{token} |
From the mail: sets the session cookie, 302 to /dashboard (expired: /login?error=expired). |
| GET | /auth/session |
{ "account": Account } or 401. |
| POST | /auth/signout |
Ends the session. |
Limits
| Limit | Value | What happens |
|---|---|---|
| Active chats per number (30 days) | 100 | the number leaves /numbers/pick; inbound still arrives |
| New chats per number (24 h) | 40 | same |
| Outbound per number (24 h) | 1500 | 429 number_daily_limit |
| First message | end user writes first | 409 chat_not_open |
| Opt-out | STOP and the other keywords | 403 chat_opted_out until they write again |
| API requests | 100 per 10 s per account | 429 rate_limited |