Integration guide
Use the versioned JSON API with Authorization: Bearer TOKEN. Tokens identify one app, are shown once and are stored only as hashes. Tokens cannot access administration, pairing codes or other apps’ channels. Never put a token in a URL.
Access and account identity
New apps start denied. An administrator grants logical account access, then independently grants Send and Read for canonical international phones, group JIDs, all_phones or all_groups. Named channels are private to their app and grant no permissions. Discovery lists destinations permitted for either operation; incoming reads require Read; sends and receipt lookups require Send. PN/LID aliases resolve before authorization; unresolved LIDs are denied. Grants are checked on each request and immediately before delivery.
Read GET /v1/accounts, then GET /v1/accounts/1. The logical account ID remains stable; account is the current linked phone identity. Every send and channel creation includes that identity as expectedAccount. An old channel cannot silently deliver from a newly paired phone.
Operations
| Method and route | Purpose |
|---|---|
| GET /v1/accounts | List granted accounts and statuses |
| GET /v1/accounts/{accountId} | Read connection status without QR contents |
| GET /v1/accounts/{accountId}/conversations | Discover permitted people and groups |
| POST /v1/accounts/{accountId}/refresh | Refresh conversation discovery |
| GET, POST /v1/channels | List or create private named channels |
| GET, PATCH, DELETE /v1/channels/{channelId} | Retrieve, rename or delete one private channel |
| POST /v1/deliveries | Send to a channel, phone or group |
| GET /v1/deliveries/{key} | Read a recorded outcome by idempotency key |
| GET /v1/messages?cursor=…&limit=50 | Poll incoming text |
Example: create a channel and send
curl -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' "$BASE/v1/channels" \
-d '{"accountId":1,"expectedAccount":"40700000000@s.whatsapp.net","name":"Dispatch","jid":"123456@g.us"}'
curl -H "Authorization: Bearer $TOKEN" \
-H 'Idempotency-Key: invoice-batch-581' \
-H 'Content-Type: application/json' "$BASE/v1/deliveries" \
-d '{"accountId":1,"expectedAccount":"40700000000@s.whatsapp.net","channelId":7,"text":"Report ready"}'For direct delivery replace channelId with destination, for example +40712345678 or 123456@g.us. Exactly one destination form is required. Rename with PATCH and {"name":"Dispatch"}; DELETE has no body. Refresh has no body.
Prefixes and acknowledgments
For an app named Organizator Șantiere the recipient receives [Organizator Șantiere] Report ready. The server applies this prefix exactly once, including pings, confirmations and automation. App clients must send the original text. WhatsApp message IDs and acknowledgment timestamps are returned unchanged. An acknowledgment means WhatsApp accepted the message; it is not a recipient read receipt.
Idempotency and uncertainty
Persist a unique idempotency key per intended send before submission. Keys are scoped to the app across all accounts and contain 1–200 letters, digits, dots, underscores, colons or hyphens. Repeating the same key and identical JSON field values returns its durable result. Changing request values returns idempotency_conflict. Attempts are committed as uncertain before contacting WhatsApp. Crashes, lost responses and missing acknowledgments never cause automatic retransmission. A retry with the same key only reads the recorded outcome; it does not send again. Use GET /v1/deliveries/{key} after a lost response. An uncertain response includes its delivery record. Verify the recipient before deliberately starting a new send with a new key. Preserve this rule in your own notification queue.
Incoming messages and cursors
Only newly received text is stored. History sync discovers conversations without importing historical contents. Events are deduplicated by account identity, chat and WhatsApp message ID. Contents expire after 30 days by default; the administrator configures retention server-side. Receipt and deduplication metadata remain after contents expire.
Start GET /v1/messages without a cursor, with limit 1–100 (default 50). Persist the opaque returned cursor only after processing the page. Pass it in the next request. Cursors survive restarts, are app-bound and expire after at most the retention period (earlier if an unread retained message will expire). Every page applies current Read permissions. A page can be empty with hasMore:true when scanning filtered records; continue with its returned cursor. An empty page with hasMore false can be polled again using its returned cursor. Expired, foreign and invalid cursors return HTTP 410 cursor_expired. Explicitly restart without a cursor and deduplicate IDs in the consumer; never silently discard this condition.
Typed errors
Errors use {"error":{"code":"permission_denied","message":"permission denied"}}. HTTP 400 validation; 401 missing/revoked/suspended token; 403 permission_denied; 404 not_found; 409 account_mismatch, delivery_uncertain or idempotency_conflict; 410 cursor_expired; 503 account_unavailable; 500 internal_error. Network errors after submission are uncertain. App tokens never gain admin rights, including by supplying an admin cookie to the app API.
See the checked-in OpenAPI contract for every request schema, response schema and example.