API reference¶
The service answers HTTP on three listeners. This page describes them: the client API that agents call with a client token, the MCP endpoint that serves the same reads and sends as tools, the admin routes behind the admin token, the health check, and the fixed responses they share. How an agent should treat what it reads is in agents.md. Terms such as chat reference, canonical identifier, LID and pacer are explained in the glossary.
Contents¶
Listeners¶
The service opens these listeners and no other socket. All three speak plain HTTP without TLS. WaWarden does not restrict who can connect: network access control and TLS are the deployer's responsibility, and none of the listeners may face the public internet.
| Listener | Address | Opened | Serves |
|---|---|---|---|
client |
WAWARDEN_LISTEN, default 127.0.0.1:8080 |
Always | The client API and the MCP endpoint POST /mcp, with a client token. |
admin |
WAWARDEN_ADMIN_LISTEN, default 127.0.0.1:8082 |
Only when an admin token hash is configured | GET /metrics and the admin routes: the status, pairing and reconnection, the clients (create, list, show, revoke), the chat lookup and backfill, all with the admin token. |
health |
WAWARDEN_HEALTH_LISTEN, default 127.0.0.1:8081, loopback only |
Always | GET /healthz, without authentication. |
Every start logs a listening event for each listener with its bound address,
and a listener_not_loopback warning for each client or admin listener bound to
an address that is not loopback. The warning is written whatever
WAWARDEN_LOG_LEVEL is set to; see Logging.
Each listener's HTTP server has these limits: 5 seconds to read the request headers, 15 seconds to read the whole request, 30 seconds to write the response, 60 seconds of idle time on a kept-alive connection, and 16 KiB of request headers.
Outbound connections¶
Outbound, the service connects to WhatsApp, and only while a device is
paired or pairing was requested: the websocket at web.whatsapp.com, the
version fetch from the same host, and history downloads from the media hosts
WhatsApp names. Its only other outbound connections go to the
webhook, when WAWARDEN_NOTIFY_URL is set. Each connection to
WhatsApp is made directly: the proxy variables
(HTTPS_PROXY, HTTP_PROXY) are ignored, every step has a timeout (10 seconds to
connect and for the TLS handshake, 20 seconds for response headers; the
websocket's opening 30 seconds, a version fetch 20 seconds and a download 2
minutes in all), TLS is 1.2 or later, and redirects are refused, except to the
same host over HTTPS for the version fetch.
Client and admin requests¶
Requests to the client and admin listeners pass these checks in order:
- A request carrying an
Originor aSec-Fetch-Siteheader is refused with403(forbidden), whatever else it carries. Browsers send these headers; no legitimate client of WaWarden is a web page. - An
OPTIONSrequest, includingOPTIONS *, is refused with405(method_not_allowed). - The request is authenticated. A failure is answered
401(unauthorized) withWWW-Authenticate: Bearer, or429(too_many_requests, without that header) when the failure budget is spent; see Failed authentication. On the client listener, a request that finds the copy of the clients being republished after a change for more than 2 seconds, as described under Client authentication, is answered503(busy) withRetry-After: 1instead, whatever its token. - On the client listener, the request then costs one from the client's
read budget, every
POST /mcpincluded; when the budget is spent, it is answered429(rate_limited) withRetry-After, before routing. - Only an authenticated request reaches routing: an unknown path is
404(not_found) and a known path with another method is405(method_not_allowed) with anAllowheader.
No request body is read before authentication succeeds. When a request
announces a body the service does not read, as with every refusal, every 404
and 405, and every read route, the response carries Connection: close and
the connection is closed instead of being read.
The credential is taken from exactly one Authorization header of the form
Bearer <token>: the scheme in any letter case, one space, and a token without
spaces or tabs. A request with two Authorization headers is not authenticated.
Client authentication¶
The client listener accepts the token of a client the
admin routes created: ww_, the client id (8 characters of a
to z and 2 to 7, from 5 random bytes), _, 43 characters of unpadded
base64url encoding 32 random bytes, _, and the CRC-32 of everything before it
in 8 lower-case hexadecimal digits: 64 characters in all, in exactly this form.
The archive keeps only the token's SHA-256. Every request takes the same path:
the token is parsed, its id looked up in a copy of the clients held in memory,
the presented token hashed once and compared once, in constant time, with the
stored digest, or with a random digest drawn at start when the token is
malformed or its id unknown; only then are revocation and expiry checked. A
malformed, unknown, wrong, revoked or expired token is one failure, answered as
below. The copy is loaded when the archive opens, and authentication never
reads the archive. Three changes rebuild the copy: creating a client,
revoking one, and a re-key that moves a client's chat. A re-key moves a direct
chat from a phone number to the LID WhatsApp uses for it (see
Ingest). Each change rebuilds the copy inside its own
write and publishes it once that write commits and before the change returns,
so a revocation takes effect on the next request; a request that was already authenticated finishes with the
grant it holds, within the 2-second read deadline. A request that arrives while
a change is being published, or after a change that did not commit and before
the copy has been reloaded in the background, waits for the new copy for up to
2 seconds and is then answered 503 (busy) with Retry-After: 1, whatever
token it presents; that answer is not a failure and spends no failure budget.
An authenticated request goes on to the client API; every other
request that passes checks 1 and 2 is answered 401 (or 429):
HTTP/1.1 401 Unauthorized
Cache-Control: no-store
Content-Length: 24
Content-Type: application/json; charset=utf-8
Www-Authenticate: Bearer
X-Content-Type-Options: nosniff
Date: <date>
{"error":"unauthorized"}
Admin authentication¶
The admin listener accepts a bearer token only when both hold:
- it has the exact form
wawarden admin initgenerates:wwadm_, 43 characters of unpadded base64url encoding 32 bytes,_, and the matching CRC-32 in lower-case hexadecimal; - its SHA-256 equals the configured hash, compared in constant time.
A string that is not in this form is refused even when its SHA-256 matches the configured hash. Configuring the hash of anything other than a generated token therefore leaves the admin listener unusable.
The admin listener serves GET /metrics (Go's router also answers HEAD on
it; see Metrics) and the admin routes.
Failed authentication¶
The client listener and the admin listener each count failed authentications, in
wawarden_auth_failures_total and wawarden_admin_auth_failures_total; the
admin listener's also report the notification event
admin_auth_failure, at most once a minute. Each
also has one failure budget shared by all callers: 30 failures, refilled at one
per second up to 30. While the budget is empty, failures are answered 429
instead of 401.
The budget changes only the answer to a failure:
- Authentication still runs for every request, and a valid credential is accepted whether or not the budget is empty.
- A client list that is being republished answers
503without spending the budget. - Anyone who can reach a listener can keep its budget empty, after which a
caller presenting a wrong token sees
429rather than401.
Design rationale explains why the budget does not slow guessing.
On the client listener, a revoked or expired client's token is a failure like
any other, and spends the budget. Failed authentications write no
audit row. They are counted, and those of the
admin listener are reported in admin_auth_failure events, which carry a
count and nothing about the requests.
Request bodies¶
The admin routes pair, reconnect and the client revoke route read their
body, which must be the empty object {}, the client create route reads the
client to create, the backfill route its request, and the client send route
POST /v1/chats/{ref}/messages the message, all with one decoder. No other
client REST route reads a body. Only a
route handler can call it, so it runs only after authentication and after the
route's grant was decided. It answers with a fixed error and reads no further when:
| Answer | Refused when |
|---|---|
415 {"error":"unsupported_media_type"} |
The request does not carry exactly one Content-Type header, or that header is not application/json, optionally with the single parameter charset=utf-8 (media type and charset in any letter case). A body the request announces is not read, and the connection is closed. |
413 {"error":"body_too_large"} |
The body is longer than 16384 bytes, or 65536 bytes for the client create route so that two sets of 256 chats fit and for the send route so that the longest text fits with every character written as a \u escape, as announced by Content-Length (the body is not read) or found while reading; the connection is closed. |
400 {"error":"invalid_body"} |
The body is not valid UTF-8, starts with a byte-order mark, or is not exactly one JSON object with nothing but white space after it; it nests objects and arrays more than 8 levels deep, counting the outer object; an object holds a key that is not lower-case snake_case (a letter a to z, then letters, digits and _), or holds the same key twice once escapes are decoded ("te\u0078t" is "text"); a key is not a field of the route's request; or a value does not fit its field. |
POST /mcp checks its bodies with the same media type rule and its own size,
depth and key rules; see MCP requests.
Client API¶
The client listener serves these routes with a client token. A client reads only the chats its read scope names (or every chat, for an all-chats client), and sends only to the chats its write scope names; an all-chats client never writes.
| Route | Query | Answer |
|---|---|---|
GET /v1/me |
none | {client:{id, name, expires_at, read:{all, chats:[{id, kind}]}, write:{chats:[{id, kind}]}}, session}: the client's own scope, chats by canonical identifier, sorted |
GET /v1/chats |
cursor, limit |
{chats:[Chat], next, truncated, session}, the chats in scope by their last message, newest first, then the chats without a message |
GET /v1/chats/{ref} |
none | one Chat |
GET /v1/chats/{ref}/messages |
cursor, limit |
{messages:[Message], next, truncated, session}, newest first by time, then by arrival |
GET /v1/search |
q, chat, cursor, limit |
{messages:[Message], next, more, truncated, session}, newest first by arrival, without rank or count |
GET /v1/changes |
since, chat, limit |
{messages:[Message], next, more, truncated, session}, every message in scope that was added, edited, revoked or expired, in the order of its latest change, oldest first |
GET /v1/messages/{mref} |
none | one Message |
POST /v1/chats/{ref}/messages |
none; a body and an Idempotency-Key header, see Sending |
201 {message, replayed, session}, the sent Message |
A Chat is {id, kind, name, name_source, last_message_at}:
idis the chat's reference, 32 hexadecimal digits. It never reveals a phone number, and it stays the same when WhatsApp re-keys the chat: when the engine learns the LID behind a phone number and moves the direct chat from the number to the LID. A LID is the identifier WhatsApp gives an account in place of its phone number (<digits>@lid); see the glossary.{ref}andchat=take the reference exactly as/v1/chatslists it.kindisphone,lidorgroup.nameis the chat's name in the archive, ornull.name_sourcesays wherenamecame from:push_namefor a direct chat named by the contact's push name,group_subjectfor a group named by its subject, ornullwhen the chat has no name. A push name is the name a WhatsApp user sets on their own profile, which WhatsApp sends with their messages.last_message_atis the time of the chat's newest message, ornull.
A Message is {mref, chat, sender:{id, name}, from_me, ts, kind, text,
text_display, text_truncated, media_type, reply_to, quote_verified, edited_at,
revoked, origin, untrusted}:
mrefis the message's message reference.chatis the chat's reference;sender.idis the sender's canonical user identifier (<digits>@s.whatsapp.netor<digits>@lid).sender.nameis the name the owner saved for that contact when the contact's direct chat is also in the client's scope, otherwise the sender's push name, ornull.from_meistruewhen the owner's account sent the message.tsandedited_atare RFC 3339 times in UTC with milliseconds.edited_atisnullfor a message never edited.kindistext,media,reaction,poll_updateorother.text_displayistextwithout control, bidirectional, zero-width and tag characters, for showing to a person; neither is safe to act on (see docs/agents.md).textandtext_displayarenullfor a revoked message, which is answered withrevoked: true, and when the message has no text. Each is cut on a character boundary so that it encodes to at most 32 KiB, andtext_truncatedsays whether either was cut.media_typeis the MIME type of a media message, such asimage/jpeg, or its kind (image,video,audio,documentorsticker) when the MIME type is missing or malformed;nullfor other messages.reply_tois the message reference of the quoted message when it is in the archive, ornull;quote_verifiedpasses on whether the quote matched it.originisgateway:<client id>for a message a client sent through this service, shown to every client whose scope holds the chat;ownerfor any other message the owner's account sent; andpeerfor the rest. A message that history also delivers keeps itsgateway:origin.untrustedis alwaystrue: every string in a message, and every chat and sender name, is third-party text, a gateway send's included.
No answer carries a database sequence number, an alternate sender identifier,
raw protocol data or other media metadata. session is {state} alone, one of
unpaired, connecting, connected and disconnected, as under
Engine states.
For example, a page of GET /v1/chats/{ref}/messages with one message (the
mref and next values are shortened; real ones are longer):
{"messages":[{"mref":"m1_q8ZbN3xVd0kT5hLw2pRj7sYc4uFe9aGo","chat":"5b0e8c1f2a3d4e6f7a8b9c0d1e2f3a4b","sender":{"id":"15550100002@s.whatsapp.net","name":"Synthetic Contact"},"from_me":false,"ts":"2026-10-05T08:00:00.000Z","kind":"text","text":"See you at noon","text_display":"See you at noon","text_truncated":false,"media_type":null,"reply_to":null,"quote_verified":false,"edited_at":null,"revoked":false,"origin":"peer","untrusted":true}],"next":"Xk3v9Qm2...","truncated":false,"session":{"state":"connected"}}
Chat references and identifiers¶
The key id names two different things, depending on where it appears:
| Where | id holds |
Example |
|---|---|---|
A Chat of the client API, {ref}, chat= and the MCP chat argument |
The chat reference | 5b0e8c1f2a3d4e6f7a8b9c0d1e2f3a4b |
GET /v1/me, under read.chats and write.chats |
The chat's canonical identifier | 120363000000000001@g.us |
| The admin routes, for chats | The chat's canonical identifier; the reference is ref |
15550100002@s.whatsapp.net |
GET /v1/me under client, and the admin routes for clients |
The client id, 8 characters of a to z and 2 to 7 |
aaaqeaye |
A canonical identifier is the form the archive stores a chat or user under:
<digits>@s.whatsapp.net for a phone number, <digits>@lid for a LID, or
<id>@g.us for a group, without a device part. A client sees its own scope by
canonical identifier in GET /v1/me, but reads and sends name chats only by
reference.
Sending¶
POST /v1/chats/{ref}/messages sends one text message to a chat in the
client's write scope. The body is {"text": ..., "reply_to": ...}, read by the
strict decoder; reply_to is optional and null is the
same as absent. The header Idempotency-Key is required, exactly once, and
holds 8 to 128 printable ASCII characters (! to ~), for example a UUID.
For example, with curl, keeping the key so that a retry can reuse it:
KEY=$(uuidgen)
curl -sS -X POST "https://<your-host>/v1/chats/5b0e8c1f2a3d4e6f7a8b9c0d1e2f3a4b/messages" \
-H "Authorization: Bearer $WAWARDEN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
--data '{"text":"On my way","reply_to":null}'
A sent message answers 201:
{"message":{"mref":"m1_Vt7cKp2LmQ9xWb4nRz8eHs3jYd6uFa0g","chat":"5b0e8c1f2a3d4e6f7a8b9c0d1e2f3a4b","sender":{"id":"15550100001@s.whatsapp.net","name":null},"from_me":true,"ts":"2026-10-05T08:01:12.345Z","kind":"text","text":"On my way","text_display":"On my way","text_truncated":false,"media_type":null,"reply_to":null,"quote_verified":false,"edited_at":null,"revoked":false,"origin":"gateway:aaaqeaye","untrusted":true},"replayed":false,"session":{"state":"connected"}}
After a 503 busy, a 409 or a lost connection, retry with the same
$KEY: the service never sends one key's message twice. After a 504, read
the chat before sending anything else (Idempotency).
The checks run in this order, and the first that fails answers:
- The client must hold a write grant at the time of the request: a client
without write chats, an all-chats client, and an expired or revoked one get
the same
404as an unknown route, before any body is read. - The body:
415,413or400invalid_bodyas for every body, under a 65536-byte cap, andinvalid_bodywhentextis missing,nullor not a string, orreply_tois not a string. The cap counts the bytes sent, so it is wide enough for the longest text the next rule allows written entirely in\uescapes, as Python'sjsonmodule and Go'sencoding/jsonwrite some characters by default; the text's own length is checked after decoding. - The
Idempotency-Key: missing, repeated or malformed answers400invalid_idempotency_key. - The text: 1 to 4096 characters, at most 8192 bytes of UTF-8, and none of
the control characters other than line feed and tab, the bidirectional
controls U+202A to U+202E and U+2066 to U+2069, the marks U+200E, U+200F
and U+061C, U+FEFF, the separators U+2028 and U+2029, or the tag characters
U+E0000 to U+E007F; otherwise
400invalid_text. Text is refused, never altered, so what is sent is what was asked for. Zero-width joiners, non-joiners and variation selectors stay, for emoji. Text of white space only passes. - The chat:
{ref}must be a chat the client may read and write to; otherwise the uniform404, whether the chat is missing, unreadable or readable but outside the write scope. - The reply:
reply_tomust be a message reference, sealed for this client, of a message in the same chat that is not revoked and has text; otherwise the uniform404. The reply quotes that message's text only, under its stored sender, and is stored as a verified reply. - The send itself, under the send limits.
| Status | error |
When |
|---|---|---|
201 |
Sent, or a replay of a sent message; replayed says which. |
|
404 |
not_found |
No write grant, a chat missing or outside the scope, a reply target that fails. |
400 |
invalid_body |
The body does not decode, or text or reply_to has the wrong type. |
400 |
invalid_idempotency_key |
The key is missing, given twice or malformed. |
400 |
invalid_text |
The text breaks the length or character rules. |
413, 415 |
body_too_large, unsupported_media_type |
As for every body. |
403 |
first_contact_refused |
The chat is a direct chat that has never written to the account (no stored message from the other person, of any kind, history included), and the client was created without allow_first_contact. Groups are exempt. |
409 |
request_in_progress |
Another request with the same key is still running. Retry-After gives 1 to 5 seconds. |
422 |
idempotency_key_reused |
The key was used for another request: another chat, text or reply target. Also while the first is still running. |
429 |
rate_limited |
A send limit or the pacer, the minimum gap between two sends of any client, or another send that is running; one code whatever the cause, which never names another client. Retry-After gives the whole seconds until the send would be admitted, 1 to 3600, so a wait set by the pacer or the global hourly limit follows from other clients' sends too (see the threat model's residual risks). |
503 |
busy |
The archive did not answer within its deadline, or the client list was being republished. Retry-After: 1. Usually nothing was sent, but see Idempotency. |
503 |
not_connected |
The engine is not connected or not paired; nothing was sent. Retry-After: 5. |
502 |
send_rejected |
WhatsApp refused the message, or the recipient cannot be reached (not a member of the group, a missing group, a phone number without a WhatsApp account). Stored, never retried. |
504 |
send_outcome_unknown |
The message may have reached WhatsApp, but no acknowledgement came: a timeout, a drop after the message was written, a restart or an expired lease. Stored, never retried. |
500 |
internal_error |
Anything else, such as an acknowledged send whose result could not be stored. |
Idempotency¶
The service keeps each key for 24 hours from its first use, per client. It fingerprints the request by the chat's reference, the text, and the reply target's message id and archive row, which a re-key of the target's sender leaves unchanged. The same key reaches the same send from REST and from the MCP tool.
A later request with the same key and the same chat, text and reply target sends nothing and answers what the first did:
201with the same message, read again from the archive, or the same502or504;- with the header
Idempotent-Replayed: trueand, on a201,replayed: truein the body.
A replay is answered whatever the limits. It reads the message as it is now,
so a message revoked since shows no text. The reply target is checked before
the key is looked up, so a retry whose target was revoked or lost its text at
expiry since answers 404 and sends nothing, as a new request with that
target would.
What a key holds after each answer:
| First answer | The key afterwards |
|---|---|
400, 403, 404, 429 or 503 not_connected |
Nothing was stored: the same request can be made again under the key once the cause is gone. |
409 |
The first request's answer follows. |
422 |
The key stays bound to its first request. |
503 busy |
Usually nothing was stored. It also answers a send that went out and was stored when the archive is too busy to read the message back, or to write the request's audit row, within 2 seconds. Retry under the same key: a send that never left is then made, and one that went out answers 201 with Idempotent-Replayed: true and writes its audit row. |
500 from an acknowledged send whose result could not be stored, or from a send that never left whose key could not be freed |
The key answers 409 until its lease ends and 504 afterwards, and the service reports send_unrecorded. |
A send is at most once: when the outcome is unknown, the service never sends
again by itself, and answers 504 to the request and to every replay. A
504 therefore means the contact may or may not have the message; read the
chat to find out before sending anything else. A send whose acknowledgement
came is stored as the owner's message with origin gateway:<client id>,
under the requested chat, in the same write that settles the key; in a chat
with a disappearing timer it carries the timer of the chat's newest message
that has one, and expires with it.
The handler gives a send 25 seconds: the engine waits at most 12 seconds for
WhatsApp's acknowledgement within a 20-second call, and the result is stored
within 5 more seconds, even when the client has gone. A key stays reserved
while its send runs, for at most 60 seconds; a key still reserved after that,
or by a send cut short by a restart, is settled as send_outcome_unknown at
the next request with it or at the next start, which reports
send_outcome_unknown with the reason lease_expired or
restart. The engine runs one send at a time and queues none: a send while
another runs, or while a backfill runs, is refused at once with 429
rate_limited and Retry-After: 1, and gives its budget back.
Any other method on /v1/chats/{ref}/messages is answered 405 with
Allow: GET, HEAD, POST, for every client.
Query parameters and pages¶
Query parsing is strict: a parameter the route does not take, a parameter given
twice, an empty value or a malformed query string is 400 (invalid_query).
limit is a whole number from 1 to 200 in decimal digits, without a sign or a
leading zero; it defaults to 50. A page holds at most limit items and is cut
earlier, with truncated: true and a next cursor that resumes at the first
item left out, when its body would pass 256 KiB; it always holds at least one
item.
q is 3 to 128 bytes of UTF-8 without control characters, normalised to NFC,
of 1 to 8 terms separated by white space, each of at least 3 characters (code
points); a term matches anywhere inside the text, case folded the way the
full-text index folds it, and every term must match. Anything else is 400
(invalid_query). Search walks the archive in windows of about 20,000 messages,
newest first, so a page can hold fewer than limit messages, or none, with
more: true: follow next until more is false. The query string,
including q, appears in the logs of any reverse proxy in front of the
service; keeping those logs is the deployer's concern.
/v1/changes takes since, either a next cursor of an earlier call or, on
the first call, an RFC 3339 time: the feed then starts at the first change of a
message dated at or after it. Without since, it starts at the beginning. Its
next is always set, so a client can poll with it; more: true means the
feed has not yet walked every change in the archive, so call again at once,
even when the page is empty: the walk covers every chat, and a client that
reads few chats can get several empty pages before it is up to date.
Cursors and message references¶
next is an opaque cursor, at most 256 characters, or null at the end. It
holds only a position (a time and an arrival number, and for the change feed a
change number), sealed with AES-256-GCM under the cursor-seal key derived
from the master key, and is bound to the client, the route, the
chat (or all chats) and, for search, the query: a cursor that was altered,
truncated, issued to another client or for another route, chat or query, or
sealed under a previous master key, is 400 (invalid_cursor). The scope is
taken from the client's grant at every request, never from the cursor, so a
cursor replayed after the client's scope narrowed returns nothing outside the
new scope.
A message reference (mref, reply_to) is m1_ followed by the sealed chat,
message id and sender under the mref key, bound to the client, at most 640
characters. Its nonce is derived from the key, the client and the sealed
values, so the same message always gets the same reference for the same
client: a client can use it to recognise a message it has seen before, while
two clients get different references for one message. The reference changes
only when WhatsApp re-keys the message's chat or sender, or the master key is
replaced, and every reference issued to the client opens for it while the
master key stays the same, or while it stays in keys/retired/ after
keys rotate replaced it; a reference issued after the
rotation reads differently for the same message. It is opened, its chat mapped to the chat's current
identifier, and the client's scope checked before the message is looked up.
Not found¶
An unknown, out-of-scope or malformed chat reference in the
path or in chat=, and an unknown, out-of-scope, foreign or malformed message
reference, are all answered with the same 404 (not_found) as an unknown
route, byte for byte. A request that names a chat checks the chat before its
cursor: an invalid cursor with a chat outside the scope is 404, not 400.
404 means "not found or not yours".
Read rate limits¶
Each client has a read budget and a search budget, kept in memory and never queued:
| Budget | Default | Refill | Burst |
|---|---|---|---|
| Reads | WAWARDEN_READ_PER_CLIENT_PER_MINUTE, 600 a minute |
evenly, one every 100 ms at the default | 60, or the rate when it is lower |
| Searches | WAWARDEN_SEARCH_PER_CLIENT_PER_MINUTE, 60 a minute |
evenly, one a second at the default | 6, or the rate when it is lower |
Every authenticated request on the client listener costs one read, whatever its
route or its answer, unknown routes and 404s included, so probing spends the
budget; a search costs one read and one search. The MCP endpoint draws
on the same two budgets: every POST /mcp costs one read, whatever it carries,
and a search_messages call also costs one search, charged before its
arguments are checked. A spent budget is answered 429
(rate_limited) with Retry-After, the whole seconds until the next request is
admitted. Failed authentications cost nothing here; they have their own
budget. Each value is capped at its default unless
WAWARDEN_UNSAFE_RATE_CAPS=1, which lifts the caps to 1,000,000 a minute and
logs unsafe_rate_caps at every start. The budgets start full at every start of
the service.
Send limits¶
Sends have their own limits, apart from the read budgets: they are counted over the rows of the archive's idempotency table, so a restart does not refill them, in sliding windows measured with the service's clock.
| Limit | Default | Cap | Lifted cap |
|---|---|---|---|
| Each client, per minute | WAWARDEN_SEND_PER_CLIENT_PER_MINUTE, 10 |
10 | 60 |
| All clients, per hour | WAWARDEN_SEND_GLOBAL_PER_HOUR, 30 |
60 | 600 |
| Each client, per hour | the hourly limit divided by the number of clients that may write (active, not all-chats, with a write chat), at least 1 | ||
| Pacer | one send at a time, and the next send of any client starts at least 3 seconds plus a random wait of up to 2 seconds after the last one was admitted |
A send is counted from the moment it is admitted: the time is read once the
admitting write holds the archive, so a request that waited for the archive
is counted from the end of its wait, and its next send waits the full gap. A send that wrote nothing
to WhatsApp (not_connected, or rate_limited behind a running send) gives
its place back; a rejected send and one with an unknown outcome stay counted,
because WhatsApp may have seen them. A refused send answers 429
rate_limited with Retry-After, the whole seconds until the earliest moment
it would be admitted, at least 1 and at most 3600; nothing queues. A request
also costs one read from the read budget, like every
request. WAWARDEN_UNSAFE_SEND_CAPS=1 lifts the caps as in the table and logs
unsafe_send_caps at every start; WAWARDEN_UNSAFE_RATE_CAPS never lifts
them. wawarden_sends_rejected_total counts the refusals by limit.
Busy reads¶
Reads share the archive's single database connection with ingest. At most 8
reads run at once: a read that finds them all taken, or that passes its
2-second read deadline, stops at once instead of waiting and is answered 503
(busy) with Retry-After: 1. Every answer, 503 included, first writes its
audit row on the same connection and waits at most 2 seconds for
it; a row not written in that time makes the answer 503 (busy) with
Retry-After: 1. Behind a long ingest write, a request therefore waits at most
its 2-second read deadline and then 2 seconds for its row, never until the write
ends.
Read audit¶
Every request the read budget admits writes one audit row and
one standard-output line before it is answered, allowed or not. The action is
rest.me, rest.chats, rest.chat, rest.messages, rest.search,
rest.changes, rest.message or rest.send for the routes above, in that order, and
rest.unrouted for an unknown path or method, a send of a client without a
write grant included; the reason is ok (for a 201 too) or the error
code of the answer (not_found, invalid_query, invalid_cursor,
rate_limited, busy, method_not_allowed, one of the sending
codes, or internal_error, which a handler
that panics also records). The row names
a chat only when the answer is about one chat (rest.chat, rest.messages and
rest.message that succeed, and rest.send once the chat is found in the
write scope, whatever the send's outcome); a 404 names none. No row holds the
text or the idempotency key. Every POST /mcp writes one
row too, with the actions and reasons listed under MCP audit. A request whose row cannot be
written within 2 seconds is answered 503 (busy) with Retry-After: 1, and
one whose row fails for another reason 500 (internal_error); either way
nothing of the read leaves the service, although a send may already have gone
out (see sending). The row is written also when the client has
closed the connection before its answer, with the outcome the request reached:
a send whose client left during the acknowledgement wait is recorded with the
send's own result. A request refused by the read budget, and a failed
authentication, write no row.
MCP¶
The client listener also serves the client API as Model Context
Protocol tools at POST /mcp, through the MCP
Go SDK v1.8.0, over its streamable HTTP transport: stateless, every answer one
application/json body, no session (Mcp-Session-Id is ignored and never
sent) and no server-sent event stream. The endpoint is registered on the client
router and passes the same checks as every client
request first: the client token, the browser refusal, the read budget and the
audit row. GET, PUT and DELETE /mcp are answered 405
(method_not_allowed) with Allow: POST, like any other known path with
another method. Both protocol paths work: the 2026-07-28 revision, where each
request carries Mcp-Protocol-Version, Mcp-Method (and Mcp-Name for a tool
call) headers and _meta, starting with server/discover, and the earlier
initialize handshake.
MCP requests¶
Before the MCP library reads anything, the endpoint checks the body and answers with the fixed JSON errors of the request bodies table:
| Answer | Refused when |
|---|---|
415 {"error":"unsupported_media_type"} |
The request does not carry exactly one Content-Type header of application/json, optionally with charset=utf-8; the body is not read and the connection is closed. |
413 {"error":"body_too_large"} |
The body is longer than 65536 bytes, as announced by Content-Length (the body is not read and the connection is closed) or found while reading. |
400 {"error":"invalid_body"} |
The body is not valid UTF-8, starts with a byte-order mark, or is not exactly one JSON object with nothing but white space after it, so a batch or a second message is refused; it nests objects and arrays more than 16 levels deep; or an object holds the same key twice once escapes are decoded, in the envelope or in tool arguments. Keys are not limited to snake_case: MCP uses camelCase and _meta. |
The MCP library then requires an Accept header listing both application/json
and text/event-stream, and a well-formed JSON-RPC 2.0 message. It answers the
requests it refuses itself, such as a malformed message, an unknown method name
or a protocol version it does not support, with a plain-text 400 or a JSON-RPC
error; those answers may repeat the caller's own method name, header values or
message text, never anything read from the archive.
The server advertises the tools capability only, without list changes; it has
no logging, resources, prompts or completions. It answers initialize,
notifications/initialized, ping, server/discover, tools/list and
tools/call. Any other method the MCP library knows, such as resources/list,
prompts/list, logging/setLevel or subscriptions/listen, is answered with
the JSON-RPC error -32601 (method not found), with HTTP 404 on the
2026-07-28 path and 200 before it; for subscriptions/listen before
2026-07-28, the MCP library frames that one error as a single server-sent event and
ends the response. A notification is answered 202 without a body. A
tools/call naming any other tool is answered with the JSON-RPC error -32602
and the fixed message unknown tool, with HTTP 400 on the 2026-07-28 path
and 200 before it, and never repeats the name.
The MCP library's check that a request arriving on a loopback address names a
loopback Host is turned off: the service sits behind a loopback proxy that
forwards the original host name, which that check would refuse. The browser
refusal and the client token protect the endpoint instead. The MCP library also
checks the token's expiry against the wall clock, after the service has checked
it, and refuses an expired one with a plain-text 401.
Every answer carries Cache-Control: no-store and X-Content-Type-Options:
nosniff. The MCP library's own log output is discarded.
MCP tools¶
The tool definitions are constants: their names, descriptions and schemas never
contain a chat name or anything else read from the archive. A client that holds
a write grant at the time of the request gets the five read tools and
send_message; every other client gets the five read tools, and its call of
send_message is answered exactly as a call of a tool that does not exist,
byte for byte, with the audit reason unknown_tool. The endpoint holds the two
lists as two servers built at start and picks one for each request. Each tool
calls the same code as its route and answers the same JSON, which the
client API describes:
| Tool | Arguments | Answer |
|---|---|---|
list_chats |
cursor, limit |
As GET /v1/chats: {chats, next, truncated, session} |
get_chat |
chat (required) |
{chat, session}, where chat is the Chat that GET /v1/chats/{ref} returns |
get_messages |
chat (required), cursor, limit |
As GET /v1/chats/{ref}/messages: {messages, next, truncated, session} |
search_messages |
query (required), chat, cursor, limit |
As GET /v1/search: {messages, next, more, truncated, session} |
get_changes |
since, chat, limit |
As GET /v1/changes: {messages, next, more, truncated, session} |
send_message |
chat, text, idempotency_key (all required), reply_to |
As POST /v1/chats/{ref}/messages: {message, replayed, session} |
There is no tool for GET /v1/me or GET /v1/messages/{mref}.
| Argument | Schema | Meaning |
|---|---|---|
chat |
string, 1 to 64 characters | A chat's id as list_chats returns it. |
cursor |
string, 1 to 256 characters | The next value of the previous page of the same tool, chat and query. |
since |
string, 1 to 256 characters | The next value of the previous get_changes call, or an RFC 3339 time for the first one, as for GET /v1/changes. |
query |
string, 3 to 128 characters | The search query, under the rules of GET /v1/search: 3 to 128 bytes after NFC normalisation, 1 to 8 terms of at least 3 characters each. |
limit |
integer, 1 to 200 | Items per page; 50 when omitted. |
text |
string, 1 to 4096 characters | The message to send, under the text rules. |
reply_to |
string, 1 to 640 characters | The mref of a message of the same chat to reply to. |
idempotency_key |
string, 8 to 128 characters matching ^[\x21-\x7e]{8,128}$ |
The key Idempotency-Key carries on the route: unique per intended message, repeated only to retry the same call. |
Each input schema is an object that allows no other property, and the arguments
are checked against it before anything is read. Omitted or null arguments are
an empty object. Cursors are the route's: a next value from a tool continues
on its route and the other way round, under the same cursor rules.
Every read tool carries the annotation readOnlyHint: true; send_message
carries readOnlyHint: false, destructiveHint: false, idempotentHint: true
and openWorldHint: true, and its description asks for at most one send per
trigger. Every tool has an outputSchema inferred from the answer's type.
A result carries the answer in structuredContent and the same JSON as text in
content[0]. A refused call is a result with isError: true, the fixed code
alone as the text of content[0], and in structuredContent the answer's shape
with empty data and the current session, for example
{"chats":null,"next":null,"truncated":false,"session":{"state":"connected"}}.
No error text of the service or of the MCP library, and no argument value, reaches a
result. A refused send_message call carries retry_after_seconds, the whole
seconds the route's Retry-After would give, in its structuredContent for
request_in_progress, rate_limited, busy and not_connected, and
replayed: true when it repeats the stored send_rejected or
send_outcome_unknown of an earlier call with the same key.
| Code | Answered when |
|---|---|
not_found |
As on the routes: the chat is outside the client's scope or does not exist, including with a cursor or since value that does not open; or the client gets no read grant at the time of the call. A denied chat and a missing one give byte-identical answers. |
invalid_arguments |
The arguments do not fit the input schema: a missing required argument, another property, a wrong type, an empty string, a value out of bounds or a non-integral limit. |
invalid_query |
The query passes the schema but not the search rules. |
invalid_cursor |
As on the routes. |
rate_limited |
The client's search budget is spent, or for send_message a send limit. A tool result carries no Retry-After; at the default rate a search is admitted again within a second. A spent read budget refuses the whole POST with 429 before the call. |
busy |
As on the routes, or the call passed its deadline: 10 seconds for a read tool, 25 seconds for send_message. |
invalid_text, invalid_idempotency_key, first_contact_refused, request_in_progress, idempotency_key_reused, not_connected, send_rejected, send_outcome_unknown |
send_message only, when the route would answer that code. A text or key that breaks the input schema, such as an empty text or a seven-character key, is invalid_arguments instead; control characters and the 8192-byte cap reach the text check. |
internal_error |
Anything else failed, a panic in the tool included; the panic is counted as api.mcp and the endpoint keeps serving. |
Pages and texts are cut as on the routes. Because a result carries the page
twice, as structured content and as text, a full page makes an answer of about
twice its size; Claude Code saves a tool result over 50,000 characters to a file
instead of passing it to the model, so a smaller limit keeps answers inline.
MCP audit¶
Every POST /mcp that the read budget admits writes one audit
row. The action is mcp.list_chats, mcp.get_chat, mcp.get_messages,
mcp.search_messages, mcp.get_changes or mcp.send_message for a call of that tool, and
mcp.protocol for everything else: the handshake, tools/list, ping,
notifications, refused methods, unknown tools and refused bodies. The reason is
ok, the tool's code, the body refusal (unsupported_media_type,
body_too_large or invalid_body), method_not_found, unknown_tool, or
bad_request for a message the MCP library refused before it reached the server. A
notification answered 202 is ok. A row names a chat only for a successful
get_chat or get_messages, and for a send_message call once the chat is
found in the write scope. When the client has left before the answer, the
MCP library writes none, and the row records the call's own outcome.
Registering with Claude Code¶
-
Find the chats the agent may read with
admin chats list, then create a read client for them.--readtakes a chat identifier, a+E.164number or a chat reference, and can be repeated:wawarden admin chats list --match Synthetic --token-file admin.token wawarden admin clients create --token-file admin.token \ --name claude-code --read 120363000000000001@g.us --read +15550100002 \ --expires-days 90The token is printed once, on the
token:line. Keep it in a secret manager or in the agent host's environment asWAWARDEN_TOKEN, never in a repository.WAWARDEN_TOKENis read by the agent's host only. Never put it in the service's environment:serverefuses everyWAWARDEN_name it does not implement, so it stops withunknown_variable(see Unknown variables stop the service). 2. Register the endpoint, here aswhatsapp, at the address of the proxy that serves the client listener. The name and the URL come before--header:claude mcp add --transport http whatsapp https://<your-host>/mcp --header "Authorization: Bearer $WAWARDEN_TOKEN"The shell expands
$WAWARDEN_TOKENwhen the command runs, so Claude Code stores the token itself in~/.claude.json: for the current project with the default scopelocal, for every project with--scope user. Do not combine a literal token with--scope project, which writes.mcp.json, a file meant to be committed. To keep the token out of every file, write the entry in.mcp.jsonwith a reference that Claude Code expands when it connects:{"mcpServers":{"whatsapp":{"type":"http","url":"https://<your-host>/mcp","headers":{"Authorization":"Bearer ${WAWARDEN_TOKEN}"}}}}or replace
headerswithheadersHelper, a command that prints the headers as a JSON object and that Claude Code runs at each connection and again after a401or403. Claude Code asks once, whenclaudestarts in that project, whether to use a server from.mcp.json; until it is approved,claude mcp listshows it as pending approval. 3.claude mcp get whatsapporclaude mcp listshows the server as connected (a server from.mcp.jsononce it is approved)./mcpin a session lists its five tools:get_changes,get_chat,get_messages,list_chatsandsearch_messages, andsend_messageas a sixth for a client with write chats. A wrong, expired or revoked token shows as a failed connection rather than as a server that needs authentication: the endpoint offers no OAuth flow. 4. Rotate the token before it expires, as Rotating a client token describes. For this registration, switching to the new token means runningclaude mcp remove whatsappand adding the endpoint again with the new token, then checking it as in step 3. 5. When the agent read WhatsApp through another device linked to the same account before, log that device out on the owner's phone once the agent works through WaWarden: a linked device has the account's full access, outside every scope, budget and audit row of WaWarden (see the threat model and Retiring other linked devices).
Every name and text a tool returns is third-party content: an agent must never act on it. docs/agents.md says how an agent should read, page and retry, and how to scope its token.
Admin routes¶
Each route answers JSON with Cache-Control: no-store. Every call of a POST
route that passes authentication, whatever its outcome, reports the
notification event admin_mutation with the route's action
and its outcome: ok or the error code it answered.
In the admin routes, a chat's id is its canonical identifier, such as
15550100002@s.whatsapp.net, and its chat reference is ref. This differs
from the client API, where a chat's id is its reference (see
Chat references and identifiers). A
client's id is its client id.
| Route | Request body | Answer |
|---|---|---|
GET /admin/v1/status |
none | 200 with the fixed object below. |
POST /admin/v1/pair |
exactly {}, see Request bodies |
200 {"code":"<pairing code>"}; see Pairing. |
POST /admin/v1/reconnect |
exactly {} |
200 {"status":"accepted"}: the engine connects again at once, after an explicit reconnect as described under Engine states. |
POST /admin/v1/clients |
the client to create, below | 200 {"client":<client>,"credential":"ww_..."}. The JSON field credential holds the client token; this answer is the only one that carries it. |
GET /admin/v1/clients |
none | 200 {"clients":[...]}, oldest first: each client's id, name, state, created_at, expires_at, revoked_at, all_chats, allow_first_contact, read_chat_count and write_chat_count. |
GET /admin/v1/clients/{id} |
none | 200 with the client. |
POST /admin/v1/clients/{id}/revoke |
exactly {} |
200 with the revoked client. Revoking a revoked client changes nothing and answers the same. |
POST /admin/v1/backfill |
{"chat":..., "count":...}, below |
202 {"request_id":"<id>"}: the backfill went to WhatsApp under that id; the phone answers when it processes it. See Backfill requests. |
GET /admin/v1/chats |
none; the query may hold one match |
200 {"chats":[{"id","kind","ref","name"}...],"truncated":false}: at most 100 chats, most recent message first, whose name contains match (ignoring the letter case of ASCII letters), whose identifier contains it, or whose reference is it; every chat without match. truncated is true when more match. |
Client objects¶
A client is:
{"id":"aaaqeaye","name":"agent","state":"active","created_at":"2026-10-06T12:00:00Z","expires_at":"2027-01-04T12:00:00Z","revoked_at":null,"all_chats":false,"allow_first_contact":false,"read_chats":[{"id":"120363000000000001@g.us","kind":"group","known":true,"name":"Synthetic Group"}],"write_chats":[]}
state is active, expired once expires_at has passed, or revoked.
Each chat carries its canonical identifier, its kind (phone, lid or
group), whether the archive has seen it (known), and its name in the
archive, or null. The administrator sees chat identifiers and names by
design; no client route ever answers them.
Creating a client¶
The body of POST /admin/v1/clients is an object with these keys, every one
optional:
| Key | Content |
|---|---|
name |
1 to 64 characters without control characters, unique among all clients, revoked ones included, ignoring the letter case of ASCII letters. |
read_chats |
The chats the client may read: at most 256 chat identifiers. |
write_chats |
The chats the client may send to: at most 256, each also in read_chats. |
all_chats |
true lets the client read every chat, including chats that appear later; then read_chats and write_chats must be empty. |
allow_first_contact |
true lets a write chat be a direct chat the archive has not seen yet. |
expires_in_days |
1 to 365; 0 or no key means 90. A client never lives longer than 365 days, so a token cannot be made that never expires. |
Backfill requests¶
A backfill asks the owner's phone for older messages of one chat. On the wire it is a history request, a message to the owner's primary device (see What a send does on the wire). The phone answers with an on-demand history-sync blob, which arrives like any other history blob.
The body of POST /admin/v1/backfill holds chat, a chat identifier as
below or a chat reference (the 32 hexadecimal digits that GET
/admin/v1/chats answers as ref), and optionally count, from 1 to 50, 50
when omitted. The request asks the owner's primary device for the count
messages that come before the chat's oldest message in the archive. At most
one request is made every 5 minutes and 20 each UTC day, all chats together;
a request that wrote nothing to WhatsApp (not_connected, or rate_limited
while a send runs) does not count, and one that may have reached it counts
whatever its answer. When a history notification answers it, the engine marks
the request complete. The route reports admin_mutation with the action
backfill.
Chat identifiers in admin requests¶
A chat identifier is <digits>@s.whatsapp.net, <digits>@lid or
<digits>[-<digits>]@g.us; @c.us is read as @s.whatsapp.net, and a device
or agent suffix (:<device>) is dropped. A phone number that the archive has
already mapped to a LID is stored as the LID. A read chat the archive has not
seen yet is allowed and answered with known false: the client reads it once
messages arrive. For a client with allow_first_contact, the create also stores
each unseen direct write chat as an empty chat, so it has a reference; the
create answers it with known false, and later answers say true. A write
chat must also be a read chat; Design rationale
explains why.
Status object¶
The status object carries no identifier, name or text:
{"state":"connected","reason":"","paired":true,"counts":{"chats":12,"messages":3456,"history_blobs_pending":0,"history_blobs_quarantined":0,"inbox_backlog":0,"inbox_quarantined":0,"backfills_today":1},"clients":{"active":2,"expired":0,"revoked":1,"all_chats_active":1},"warnings":["all_chats_client"],"last_ingest_at":"2026-10-05T08:00:00Z","version":"v1.0.0"}
| Key | Content |
|---|---|
state, reason |
The engine state; reason is empty unless the state is disconnected. |
paired |
Whether a device is stored. |
counts |
Chats and messages in the archive; history blobs waiting to be processed and quarantined; inbox rows waiting to be applied and quarantined; backfills requested since midnight UTC. |
clients |
Clients that are active, expired and not revoked, and revoked; and active clients that read every chat. |
warnings |
Fixed codes, empty when nothing needs attention: all_chats_client while a client that reads every chat is active. |
last_ingest_at |
When the engine last stored a message, in UTC to the second, or null before the first. |
version |
The build's version, as wawarden version prints it. |
Admin refusals¶
The routes refuse with fixed codes:
| Answer | Route | When |
|---|---|---|
409 {"error":"already_paired"} |
pair | A device is stored. |
409 {"error":"owner_phone_missing"} |
pair | WAWARDEN_OWNER_PHONE is unset. |
429 {"error":"rate_limited"} |
pair | Three pairing attempts were made in the last hour. |
502 {"error":"pair_failed"} |
pair | Connecting or requesting the code failed, or no code arrived within 25 seconds. |
409 {"error":"already_connected"} |
reconnect | The engine is connected. |
409 {"error":"not_paired"} |
reconnect | No device is stored, or the stored device was rejected and is being logged out. |
409 {"error":"owner_mismatch"} |
reconnect | The stored device's number is not WAWARDEN_OWNER_PHONE; see Pairing. |
503 {"error":"engine_unavailable"} |
pair, reconnect | The engine has stopped because the service is shutting down. |
422 {"error":"name_invalid"} |
create client | The name is empty, longer than 64 characters, not UTF-8 or holds a control character. |
422 {"error":"expiry_out_of_range"} |
create client | expires_in_days is negative or above 365. |
422 {"error":"too_many_chats"} |
create client | read_chats or write_chats holds more than 256 values. |
422 {"error":"chat_invalid"} |
create client | A value is not a chat identifier. |
422 {"error":"all_chats_with_write"} |
create client | all_chats comes with a write chat. |
422 {"error":"read_scope_conflict"} |
create client | all_chats comes with a read chat. |
422 {"error":"read_scope_missing"} |
create client | Neither all_chats nor a read chat is given. |
422 {"error":"write_not_readable"} |
create client | A write chat is not among the read chats. |
409 {"error":"name_taken"} |
create client | Another client has the name. |
422 {"error":"write_chat_unknown"} |
create client | The archive has not seen a write chat, and it is a group or allow_first_contact is not set. |
404 {"error":"not_found"} |
show and revoke client | No client has the id. |
400 {"error":"invalid_body"} |
backfill | chat is neither a chat identifier nor a chat reference, or count is not from 1 to 50. |
404 {"error":"not_found"} |
backfill | No chat has the reference. |
422 {"error":"backfill_no_anchor"} |
backfill | The archive holds no message of the chat, so there is nothing to ask for history before. |
429 {"error":"backfill_quota"} |
backfill | A request was made less than 5 minutes ago, or 20 were made this UTC day. Retry-After gives the whole seconds until the next is allowed: up to 300, or until midnight UTC for the daily quota. |
503 {"error":"not_connected"} |
backfill | The engine is not connected or not paired; nothing was sent and the quota is unchanged. Retry-After: 5. |
429 {"error":"rate_limited"} |
backfill | A send or another backfill is running; nothing was sent. Retry-After: 1. |
502 {"error":"send_rejected"} |
backfill | WhatsApp refused the request. It counts against the quota. |
504 {"error":"send_outcome_unknown"} |
backfill | The request was written and no acknowledgement came. It counts against the quota and is not repeated. |
400 {"error":"invalid_query"} |
list chats | The query holds another key, match twice, a malformed escape, or a match longer than 64 characters, not UTF-8 or with a control character. |
500 {"error":"internal_error"} |
all | Anything else, such as an archive read that failed; the cause is never sent. |
After the body is decoded, the create route checks these in the order of the table and answers the first that applies.
Health listener¶
| Request | Answer |
|---|---|
GET /healthz while the service is serving and holds the archive and the device store |
200 {"status":"ok"} |
GET /healthz once shutdown has begun, or after the connection of the archive or the device store was lost (db_lost) |
503 {"status":"unavailable"} |
Any other method on /healthz, including HEAD |
405 {"error":"method_not_allowed"} with Allow: GET |
| Any other path | 404 {"error":"not_found"} |
The health listener requires no credential, so its address must be loopback; the
service refuses any other (health_address_not_loopback). 200 means the
process is up, its listeners are serving, and the archive and the device store
are open and locked by the service; the state of the
WhatsApp engine does not change it. The health listener
opens only after both databases, so while serve waits for a lock, health
checks fail to connect.
Responses¶
Every response that the service's handlers write, on every listener, carries
Cache-Control: no-store and X-Content-Type-Options: nosniff, and none carries
an Access-Control-* header. Their error bodies are fixed JSON objects with
Content-Type: application/json; charset=utf-8:
| Body | Status |
|---|---|
{"error":"forbidden"} |
403 |
{"error":"unauthorized"} |
401 |
{"error":"too_many_requests"} |
429 |
{"error":"not_found"} |
404 |
{"error":"method_not_allowed"} |
405 |
{"error":"internal_error"} |
500, when a handler fails or panics, or a client request's audit row fails for a reason other than its 2-second wait |
{"error":"busy"} |
503 with Retry-After: 1, from the client listener: a busy read, a request whose audit row cannot be written within 2 seconds, or a client list being republished (see Client authentication) |
{"error":"unsupported_media_type"}, {"error":"body_too_large"}, {"error":"invalid_body"} |
415, 413, 400, from a route that reads a body (pair, reconnect, POST /admin/v1/clients, POST /admin/v1/clients/{id}/revoke, POST /admin/v1/backfill and POST /v1/chats/{ref}/messages) and from POST /mcp; see Request bodies and MCP requests |
The other codes belong to particular routes and are listed with them:
| Codes | Listed under |
|---|---|
rate_limited |
Read rate limits and Send limits on the client listener; pairing and backfill under Admin refusals |
invalid_query, invalid_cursor |
Query parameters and pages and Cursors and message references; invalid_query also from GET /admin/v1/chats |
invalid_idempotency_key, invalid_text, first_contact_refused, request_in_progress, idempotency_key_reused, not_connected, send_rejected, send_outcome_unknown |
Sending; the last three also from POST /admin/v1/backfill |
already_paired, already_connected, not_paired, owner_phone_missing, owner_mismatch, pair_failed, engine_unavailable, the client refusals, backfill_quota, backfill_no_anchor |
Admin refusals |
MCP tool codes such as invalid_arguments |
MCP tools |
Responses never echo request content, header values or tokens, with one
exception: the MCP library's own refusals at POST /mcp, which may repeat the
caller's method name, header values or message text; see MCP
requests. Results of MCP tool calls are JSON-RPC answers with
Content-Type: application/json.
Go's HTTP server answers some requests itself, before any handler runs: for
example a request without a Host header (400), with headers over the 16 KiB
limit (431), with an unsupported Expect header (417) or an unsupported
protocol version (505). Those answers have a plain-text or empty body and do
not carry the two headers above.