WhatsApp Platform API
Send and receive WhatsApp from your app or AI agent — a plain REST API plus a remote MCP server, beyond Bitrix24. Create an API key, put it in an Authorization header, and you are integrated. Hand this page to your coding agent and wire it up in minutes.
Building with an AI coding agent?
Give it https://developers.wasync.app/llms-full.txt — it is the complete machine-readable contract in one file: endpoints, auth, error catalog, idempotency rules, webhook schema, and integration recipes. One URL is all the agent needs.
What it is
WASync exposes WhatsApp connections as a plain REST API and a remote MCP server. Authenticate with an API key, then list connections, read messages, and send WhatsApp messages — from any app or AI agent.
Two credential types, one header. An API key is for automating your own account — that is most integrations, and it is what this page documents. OAuth2 is for an app that other WASync customers install, where a third party must consent to connections they own; it is covered at the bottom. Both go in Authorization: Bearer … and both reach every endpoint.
Two WhatsApp connection types are supported through the same API: WAPP (QR) — ordinary WhatsApp numbers paired by scanning a QR code with your phone, created and managed entirely through the API without any Meta Business account; and WABA (official Cloud API) — WhatsApp Business numbers registered with Meta. Both types send and receive through the same endpoints. If you assumed QR-from-your-own-CRM was impossible: it is not — WAPP connections are fully self-serve.
Create an API key
Go to developers.wasync.app/keys, create an account, and create a key. Give it a name, tick the scopes you need, and optionally restrict it to a set of IP addresses.
The key is shown once — we store only a hash of it. Copy it into your environment and send it on every request:
export WASYNC_API_KEY=wsk_live_…Authorization: Bearer $WASYNC_API_KEYThat is the whole auth setup. No redirect URI, no PKCE, no consent screen, no token refresh.
Scopes
- •
whatsapp.read— list connections and read messages. - •
whatsapp.send— send messages and read receipts. - •
whatsapp.events— receive webhooks, and configure where they go. - •
whatsapp.manage— create, inspect, restart, log out and delete connections.
Scopes and the reachable connection list are resolved live on every request, so revoking a key or narrowing it takes effect on the very next call — there is nothing cached and nothing to refresh.
Optional hardening — IP allowlist
A key can be pinned to IPv4/IPv6 addresses or CIDR ranges. A call from an address that is not on the list is refused with 403 ip_not_allowed — deliberately a different code from 401 invalid_key (unknown or revoked key), because the fix is different: add an address rather than rotate a good key. Branch on the code, not the status.
You cannot lock yourself out: the page where you edit the allowlist is authenticated by your account, not by the key, so it is reachable from any address.
Connect a WhatsApp number
One call creates the connection and returns a QR code to scan. This works on a brand-new workspace with no connections at all — with a key there is nothing to click in a portal first.
curl -X POST https://developers.wasync.app/api/v1/connections \
-H "Authorization: Bearer $WASYNC_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"label":"Support line"}'{
"connection": {
"id": "conn_8f3a21",
"status": "connecting",
"qr": "data:image/png;base64,iVBORw0KGgo…"
}
}qr is a data-URI PNG. Render it, then poll GET /connections/{id} every 3–5 s and re-render the QR on each poll until status is "connected". Uses the whatsapp.manage scope. Connections created this way are QR/WAPP numbers (provider: "qr"); WABA numbers are onboarded through Meta embedded signup instead.
The QR you get here does not stay valid. WhatsApp rotates the pairing code roughly every 20 seconds, so the qr in the create response is stale within seconds of being displayed. This fails silently: the phone never pairs and nothing on screen explains why. Poll GET /connections/{id} every 3–5 s and re-render the qr from every response. Do not cache it, and do not hide the refresh behind a button the user has to press.
Can’t scan? Offer a pairing code
A QR needs two devices: a screen showing it and a phone photographing it. A large share of real onboardings happen on one device — the customer finishes your flow on the same phone that runs WhatsApp, and cannot photograph its own screen. For them the QR path is not awkward, it is impossible. Ask for an eight-character code they can type instead.
curl -X POST https://developers.wasync.app/api/v1/connections/conn_8f3a21/pairing-code \
-H "Authorization: Bearer $WASYNC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phoneNumber":"40740267964"}'{
"code": "7S59-1KZP"
}The customer opens WhatsApp → Settings → Linked devices → Link with phone number and types the code. phoneNumber is E.164 without the leading + — digits only. Anything else is rejected with 400 {"error":"invalid_phone_number"} rather than cleaned up for you: silently rewriting the number you sent is how a customer ends up pairing an account nobody asked for. Uses the whatsapp.manage scope, same as creating the connection.
A pairing code is not always available — keep the QR flow as a fallback. The engine can decline to issue one. That is an ordinary outcome, not an outage, and it answers 502 {"error":"pairing_code_unavailable"} instead of a generic 500 precisely so you can tell the two apart and send the user back to the QR. An integration that ships only the pairing-code path will, sooner or later, show a customer a screen with no way forward. Render both, side by side.
You can request another code on the same connection — it returns a new code and does not rebuild the session, so a “send me another code” button is safe. It is capped at 5 per connection per 15 minutes; past that you get 429 {"error":"rate_limited"} with a Retry-After header to honour. The cap is not bureaucracy: repeatedly asking for codes for one number is exactly what WhatsApp’s anti-abuse systems act on, and the number at risk is your customer’s.
Nothing extra is consumed. Pairing by code uses the same paid slot and starts the same 7-day trial as scanning a QR — the licence is claimed when the number actually pairs, whichever way it paired. Watching for success is unchanged too: there is no “code accepted” event, so poll GET /connections/{id} until status is "connected" and/or listen for the connection.connected webhook. Two refusals are worth coding for: 409 already_connected (the session is live — there is nothing to pair) and 409 not_supported_for_provider (a Meta/WABA number; pairing codes are a QR-tier concept and never apply, so do not retry).
Make the retry safe — Idempotency-Key
Creating a connection provisions a WhatsApp session and consumes a paid slot at pairing time. A client that times out and retries would otherwise create a second connection — and you would silently pay for two slots for one customer. Send an optional Idempotency-Key header to close that hole. It is supported on POST /connections only; leave it off and behaviour is exactly as before.
A key is any opaque string up to 255 characters, namespaced to your workspace (two partners using the same literal string never collide), and it lives 24 hours from the first request that used it. After that it is free to be reused for a different request. Four cases:
- New key — the connection is provisioned as normal →
201 Created. - Same key, same body, within the TTL — the same connection is returned, nothing is provisioned again:
200 OKplus the response headerIdempotency-Replayed: true. Theqrin that response is freshly fetched, not the code stored at first creation — WhatsApp rotates pairing codes roughly every 20 seconds, so a replayed stale QR would simply fail to scan. - Same key, different body, within the TTL —
400{"error":"idempotency_key_reuse"}. The comparison is over a hash of the canonical request body, so JSON key order does not matter. - Same key, first request still in flight —
409{"error":"idempotency_in_progress"}with aRetry-After: 1response header. Honour it and retry.
A key longer than 255 characters is rejected with 400 {"error":"idempotency_key_invalid"} — never truncated. A blank or whitespace-only header is treated as absent. The response body shape is unchanged in all cases: { "connection": { "id": …, "status": "connecting", "qr": … } }.
Generate one key per customer-onboarding attempt. A fresh UUID (e.g. crypto.randomUUID()) minted right before the call and reused only by that call’s retries. Do not use one key per process, per API key, per day, or a constant. A key that is too coarse means the second customer you onboard gets handed the first customer’s connection back instead of his own.
Each connection needs an active licence to send and receive through the API — a new one starts on a 7-day trial. On a Bitrix24 portal, connections are created by the portal admin inside WASync and each needs the per-connection API add-on; this endpoint returns 403 forbidden_portal_kind there. Current pricing and activation: developers.wasync.app/billing.
REST API
Base URL https://developers.wasync.app/api/v1. Pass your key as Authorization: Bearer wsk_live_… (an OAuth access token goes in the same header). Full contract: openapi.json.
Already calling https://cloudapi.wasync.app/api/v1? Keep doing it. developers.wasync.app is the canonical host, but cloudapi.wasync.app is a permanent alias for exactly the same API — same keys, same paths, same responses — and it remains valid indefinitely. No existing integration has to change anything. (The OAuth endpoints, the /.well-known/… documents and the MCP server URL are protocol identifiers registered by clients: they stay on cloudapi.wasync.app and are shown that way below on purpose.)
List the connections you may use
curl https://developers.wasync.app/api/v1/connections \
-H "Authorization: Bearer $WASYNC_API_KEY"Send a WhatsApp text
curl -X POST https://developers.wasync.app/api/v1/messages \
-H "Authorization: Bearer $WASYNC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"connectionId":"conn_8f3a21","to":"40700000000","text":"Hi from my app 👋"}'{
"messageId": "cmqj3k2ab0001xyz",
"waMessageId": "[email protected]_3EB0A1B2C3",
"status": "sent"
}to is international digits only (no +), e.g. 40700000000. text ≤ 4096 chars. Also available: GET /messages?connectionId=&limit=&cursor= (cursor-paginated).
Read recent messages
{
"messages": [
{
"id": "cmqj3k2ab0001xyz",
"waMessageId": "[email protected]_3EB0A1B2C3",
"connectionId": "conn_8f3a21",
"direction": "outgoing",
"text": "Hi from my app 👋",
"mediaUrl": null,
"mediaType": null,
"status": "delivered",
"errorMessage": null,
"createdAt": "2026-07-22T10:00:00.000Z"
}
],
"nextCursor": "cmqj3k2ab0001xyz",
"hasMore": true
}Give the customer blue ticks
curl -X POST https://developers.wasync.app/api/v1/messages/read \
-H "Authorization: Bearer $WASYNC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"connectionId":"conn_8f3a21","phone":"40700000000"}'Call this when an operator opens the conversation in your UI — otherwise the customer's messages stay on double grey ticks and the chat looks ignored. Body: { connectionId, phone?, messageId? } — at least one of phone / messageId (WASync's cuid of an incoming message). Uses the whatsapp.send scope, because it transmits a receipt to the customer — no re-consent needed. Idempotent: no idempotencyKey, no rate limit. Returns { "ok": true, "marked": 3 }.
Check your capacity before you promise it
curl https://developers.wasync.app/api/v1/account \
-H "Authorization: Bearer $WASYNC_API_KEY"{
"slots": {
"total": 30,
"used": 28,
"available": 2,
"nextExpiry": "2027-08-08T09:00:00.000Z"
},
"connections": {
"total": 28,
"connected": 27,
"needsReconnect": 1
}
}Use this when you resell connections: slots.available tells you whether you can onboard another number right now, and nextExpiry is the date you need to invoice your own customer before — so a slot never expires underneath someone who has already paid you. connections.needsReconnect counts the numbers currently waiting for their owner to scan a QR; subscribe to connection events to be told the moment that number changes instead of polling for it. Uses the whatsapp.read scope.
A slot is capacity, not a phone number. Delete a connection and its slot returns to your pool carrying whatever time is left on it — the next number you connect picks it up automatically. That is what lets you move a paid slot from a departing customer to a new one without paying twice or asking us to intervene.
Two ids — join on the right one
messageId (POST) = id (GET) = message.id (webhooks): WASync's stable id. It is the only id you should store, dedupe on, or match message.status events against.
waMessageId (REST) / wa_id (webhooks) / wa_message_id (media send) is WhatsApp's own id, e.g. [email protected]_3EB0…. It can be null — use it for support tickets and debugging, never as a join key.
The spec is served with open CORS — generate your own client from it: npx @openapitools/openapi-generator-cli generate -i https://developers.wasync.app/openapi.json -g typescript-fetch -o ./wasync-client
Webhooks
Point WASync at your endpoint and you get a signing secret back. Uses the whatsapp.events scope. No app registration and no consent step is involved.
curl -X PUT https://developers.wasync.app/api/v1/webhook \
-H "Authorization: Bearer $WASYNC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/wasync/webhook"}'{
"url": "https://example.com/wasync/webhook",
"events": [
"message.received",
"message.status",
"connection.disconnected",
"connection.connected"
],
"secret": "whsec_3f9a…"
}Store the secret. It is issued here because this is the moment you need it to write your verification code, and GET /webhook will not return it again — that endpoint reports { url, events, secretSet }, deliberately without the secret, because a read path gets logged and pasted into tickets. Lost it? Call POST /webhook/rotate for a new one (the old one stops working immediately). The URL must be HTTPS on a publicly reachable host.
When a message arrives on one of your connections, WASync POSTs:
{
"event": "message.received",
"connection_id": "conn_8f3a21",
"message": {
"id": "cmqj3k2ab0001xyz",
"wa_id": "[email protected]_3EB0A1B2C3",
"from": "40700000000",
"text": "Hello!",
"media_url": null,
"media_type": null,
"timestamp": 1766138640000
}
}Verify every delivery. The header X-WASync-Signature: sha256=<hex> is HMAC-SHA256(`${X-WASync-Timestamp}.${rawBody}`, subscription_secret) — compute it over the raw body and compare in constant time.
import crypto from "node:crypto";
// rawBody MUST be the exact bytes you received (verify BEFORE JSON.parse).
export function verifyWASync(headers, rawBody, secret) {
const ts = headers["x-wasync-timestamp"];
const sig = headers["x-wasync-signature"]; // "sha256=<hex>"
const expected =
"sha256=" +
crypto.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
return Boolean(sig) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Full delivery contract — event catalog, retry policy, duplicates, troubleshooting: Webhooks reference.
MCP (zero code)
Add the remote MCP server to an MCP client (Claude Desktop, Cursor, …) and authenticate with the OAuth flow. The agent then gets three tools — list_whatsapp_connections, send_whatsapp_message, and read_whatsapp_messages — with no code on your side.
https://cloudapi.wasync.app/api/mcpFor clients that read a config file:
{
"mcpServers": {
"wasync-whatsapp": {
"type": "http",
"url": "https://cloudapi.wasync.app/api/mcp"
}
}
}A client without native remote-MCP support can bridge with npx mcp-remote https://cloudapi.wasync.app/api/mcp.
WASync is listed in the official MCP Registry as app.wasync/whatsapp — clients and directories that read the registry can find this server by name instead of pasting the URL.
OAuth2 — for apps other people install
Skip this if you are automating your own account — an API key does everything above with none of the machinery. OAuth is the right tool for one job: your app is installed by other WASync customers, and those customers have to consent to connections they own.
Register an app at developers.wasync.app/apps. You get a client_id and a client_secret (shown once) and you set one or more redirect_uri (matched exactly at token exchange), an optional webhook_url, and the scopes you need.
The flow is Authorization Code with PKCE (S256 is required). Send the user to /oauth/authorize with response_type=code, your client_id, redirect_uri, scope, a state, and a code_challenge. Consent depends on who owns the connections: Bitrix24 — WASync shows a short user code and the portal admin enters it under Settings → API & Agents, picking connections and scopes; standalone — the developer approves at developers.wasync.app/authorize (auto-approved when they own exactly one workspace with at least one connection). Then exchange the code:
These OAuth URLs — and the MCP server URL above — read cloudapi.wasync.app on purpose. They are protocol identifiers your client library and our discovery documents have already registered, so they must match byte-for-byte and are unchanged. Only the REST base URL and the human-facing pages use the canonical developers.wasync.app.
curl -X POST https://cloudapi.wasync.app/oauth/token \
-d grant_type=authorization_code \
-d code=$AUTH_CODE \
-d redirect_uri=https://your.app/callback \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET \
-d code_verifier=$CODE_VERIFIERaccess_token lives 15 minutes; refresh_token lives 30 days and rotates on use — persist the new one atomically and never refresh concurrently from two workers. One structural limit worth knowing: a grant is only ever issued over connections that already exist, so an OAuth-only integration cannot create a workspace's first connection. An API key can — which is why step 1 is a key.
Give this to your AI agent
Paste this into your coding agent (Claude Code, Cursor, …). It has everything it needs — the OpenAPI URL, the API-key header, scopes and error codes, webhook self-service and the signature scheme, and the MCP option — to build a working integration.
Integrate WhatsApp into this project using the WASync WhatsApp Platform API.
Docs & contract
- OpenAPI 3 spec (generate the client from this): https://developers.wasync.app/openapi.json
- REST base URL: https://developers.wasync.app/api/v1
- The older host https://cloudapi.wasync.app/api/v1 is a PERMANENT ALIAS and still works — if this
project already calls it, leave it alone. Either host is correct.
- The OAuth endpoints, the /.well-known/ documents and the MCP server URL below deliberately use
cloudapi.wasync.app: they are registered protocol identifiers and must match byte-for-byte.
Use them EXACTLY as written — do not rewrite their host.
- Full docs (one file, read this): https://developers.wasync.app/llms-full.txt
AUTH — USE AN API KEY (the default; do not build an OAuth flow unless told to)
- Create one at https://developers.wasync.app/keys. Shown once, stored as a hash. Put it in an env var:
WASYNC_API_KEY=wsk_live_…
- Send it on EVERY request: Authorization: Bearer $WASYNC_API_KEY
- No expiry, no refresh, no redirect URI, no PKCE, no consent screen. Never put it in a query string,
in client-side code, or in source control.
- Scopes are per key: whatsapp.read (list + read), whatsapp.send (send + read receipts),
whatsapp.events (webhooks + webhook config), whatsapp.manage (create/inspect/restart/logout/delete).
Scopes and the reachable connection list are resolved LIVE per request — revocation is immediate and
a connection you just created is visible on the next call.
- Errors (branch on the CODE, not the status):
401 invalid_key unknown/revoked key — create a new one; retrying will never help
403 ip_not_allowed the key has an optional IP allowlist and this address is not on it.
The key is FINE — add the address at developers.wasync.app/keys. Do not rotate.
403 insufficient_scope the key lacks the scope this endpoint needs
PROVISIONING FROM ZERO
- POST /connections works on an EMPTY workspace when you authenticate with a key — it creates the first
connection too. There is NO manual portal step: create account → create key → POST /connections → QR.
- POST /connections is standalone-workspace only: a Bitrix24 portal gets 403 forbidden_portal_kind.
- MAKE THE CREATE IDEMPOTENT. A create provisions a WhatsApp session and consumes a PAID SLOT, so a
timed-out request that you retry without an idempotency key produces a SECOND connection and the
partner pays for two slots for one customer. Send the optional Idempotency-Key header on
POST /connections (that endpoint ONLY — no other endpoint supports it; omit it and behaviour is
exactly as before). Generate ONE key per customer-onboarding ATTEMPT: a fresh UUID
(crypto.randomUUID()) minted right before the call and reused ONLY by that call's retries.
Do NOT use one key per process, per API key, per day, or a constant — a key that coarse means the
SECOND customer you onboard is handed the FIRST customer's connection back instead of his own.
Endpoints
- GET /connections
- POST /connections body { label? } → 201 { connection: { id, status: "connecting", qr } }
"qr" is a data-URI PNG. Render it, then poll GET /connections/{id} every 3-5s and RE-RENDER the qr from
EVERY poll until status === "connected". WhatsApp rotates the pairing code ~every 20s, so the qr in the
create response is stale within seconds — a stale QR never scans and shows the user no error at all.
Never cache it or leave a single code on screen. Creates QR/WAPP numbers only (provider "qr").
Optional header: Idempotency-Key (opaque string ≤ 255 chars, namespaced per workspace, TTL 24h from
first use). Handle all four cases:
new key → provisioned as normal, 201
same key + same body → 200 + response header "Idempotency-Replayed: true", the SAME
connection, nothing provisioned again. The qr is re-fetched FRESH
(pairing codes rotate ~every 20s; a stale replayed QR will not scan).
Treat 200 as success, not as an error.
same key + different body → 400 { "error": "idempotency_key_reuse" }
first call still in flight → 409 { "error": "idempotency_in_progress" } + Retry-After: 1 —
honour it and retry with the SAME key.
Body comparison is a hash of the CANONICAL body, so JSON key order does not matter. A key over 255
chars is rejected with 400 { "error": "idempotency_key_invalid" } (never truncated); a blank or
whitespace-only header counts as absent. Response body shape is unchanged in all cases.
- GET /connections/{id} · POST /connections/{id}/restart · POST /connections/{id}/logout · DELETE /connections/{id}
- GET /account slots + connection health — check slots.available before provisioning
- GET /messages?connectionId=&limit=&cursor=
- POST /messages text send: body { connectionId, to, text, idempotencyKey }
"to" = international digits only, e.g. 40700000000; "text" <= 4096 chars.
media send: body { connectionId, to, media: { filename, mimetype, data (base64, ≤16 MB), caption? } }
Supply exactly one of "text" or "media". Voice notes: mimetype=audio/ogg, filename=voice-*.ogg.
CRITICAL: set HTTP timeout >= 180s. QR/WAPP sends take 1-7s normally (up to ~20s for long texts);
self-heal up to ~2.5 min. WABA ~1s. A slow send is NOT a failure — never treat it as one.
Always send idempotencyKey (UUID per logical send). On ANY error or timeout retry with the SAME key —
a new key = a second real WhatsApp message. On 409 in_progress: poll with SAME key
(honor Retry-After when present, else back off ~5s).
- POST /messages/read body { connectionId, phone?, messageId? } — at least one of phone/messageId.
Sends a WhatsApp READ RECEIPT (blue ticks). Scope whatsapp.send (it transmits to the customer).
Call it whenever an operator opens a conversation in YOUR UI. Idempotent, no key, no rate limit.
- Webhook self-service (scope whatsapp.events):
PUT /webhook body { url } → { url, events, secret } ← STORE the secret, issued here only
GET /webhook → { url, events, secretSet } (NEVER returns the secret)
POST /webhook/rotate → { secret } (old secret dies immediately, no overlap window)
URL must be HTTPS on a public host; http/localhost/private ranges are rejected (400 invalid_webhook).
Message ids — TWO ids, do not mix them up
- WASync id (cuid, e.g. cmqj3k2ab0001xyz): "messageId" on POST /messages, "id" on GET /messages,
"message.id" in BOTH webhook payloads. This is the ONLY joinable id — store it on send and
match message.status events against it.
- WhatsApp id (e.g. [email protected]_3EB0…): "waMessageId" (REST), "wa_id" (webhooks),
"wa_message_id" (media send). May be null. Support/debugging only — never join or dedupe on it.
- Degraded case on POST /messages: { messageId: null, waMessageId: "…", status, persisted: false }
(HTTP 200) means the message WAS delivered but was not persisted — do not retry it.
Multi-tenant: ONE api key + one workspace. POST /connections per tenant (including the first one),
each with its OWN fresh Idempotency-Key (one per tenant-onboarding attempt — never one shared key).
Route webhooks by connection_id (stable cuid). New connections usable immediately — the key's
connection list is resolved live, nothing to refresh. Do NOT mint a key per tenant.
Prefer MCP if this agent supports it
- Remote MCP server (Streamable HTTP): https://cloudapi.wasync.app/api/mcp
- Tools: list_whatsapp_connections, send_whatsapp_message, read_whatsapp_messages
Webhooks (optional — incoming, delivery status, connection health)
- Events: message.received (inbound only) · message.status (sent/delivered/read/failed for YOUR sends) ·
connection.disconnected (fires once per outage) · connection.connected (fires once on recovery).
- QR CONNECTIONS ONLY: webhooks fire for provider "qr". Meta/WABA (provider "cloud_api") emit NO
webhooks today — poll GET /messages and GET /connections/{id} for those.
- Verify header X-WASync-Signature ("sha256=" + hex): HMAC-SHA256 over the string
(X-WASync-Timestamp + "." + raw_request_body), keyed with the webhook secret.
X-WASync-Timestamp is epoch MILLISECONDS. Reject if |now - X-WASync-Timestamp| > 5 min.
ACK 2xx in <10s, process async.
- Dedupe on message.id — delivery is at-least-once. Never dedupe on wa_id (may be null).
- Status ladder: sent → delivered → read (terminal: failed). Apply monotonically — never regress
(late/out-of-order deliveries are normal). Only apply an incoming status if its rank > stored rank.
- Reconciliation: webhooks are dropped after 3 failed attempts with no long queue. Poll GET /messages every
30–60 s as a safety net. Webhooks = fast path; polling = guarantee.
- connection.disconnected body: { event, connection_id, connection: { id, phone_number, label, status,
needs_reconnect, license_status, license_expires, reason }, timestamp }. The QR is NOT in the payload —
fetch it from GET /connections/{id}. Drive UI off needs_reconnect.
- Latency: a disconnect is detected by a 5-minute status probe (~5 min); when the session looks
recoverable WASync retries an automatic heal first, so that path takes ~15-20 min.
WAPP warm-up
- New QR/WAPP numbers have 72-hour warm-up hourly volume caps after first pairing.
- Exceeding the cap returns 429 warmup_limited (hourly cap — back off minutes, do not hot-loop).
- The separate abuse limiter returns 429 rate_limited. Honor Retry-After when present, but always keep a
default back-off: the header is not guaranteed on every 409/429.
OAuth 2.0 — ONLY if this app is installed by OTHER WASync customers who consent to their own numbers
- Authorization Code + PKCE (S256 REQUIRED). Authorize: https://cloudapi.wasync.app/oauth/authorize
Token: https://cloudapi.wasync.app/oauth/token (client_secret_post).
Discovery: https://cloudapi.wasync.app/.well-known/oauth-authorization-server
- access_token TTL 15 min; refresh_token TTL 30 days and ROTATES on use — persist the new one atomically
and serialize refreshes across workers. A grant is only issued over connections that ALREADY EXIST,
so an OAuth-only integration cannot create a workspace's first connection.
Deliverable: a minimal working module that authenticates with the API key from the environment, lists
connections, and sends one text message; plus a verified webhook receiver if the key has whatsapp.events.
Do NOT implement OAuth unless this app is meant to be installed by other WASync customers.