# WASync WhatsApp Platform API > Send and receive WhatsApp from your app or AI agent — beyond Bitrix24. **Start with an API key:** create an account at developers.wasync.app, create a key, send it as `Authorization: Bearer wsk_live_…`. That is the entire setup for automating your own account — no redirect URI, no PKCE, no consent screen, no token refresh. OAuth2 is the other path, for apps that OTHER WASync customers install (third-party consent), plus MCP for agents. Both credential types work on every endpoint, in the same header. **The 4-step path:** create account → create API key → `POST /api/v1/connections` (returns a QR to scan) → `PUT /api/v1/webhook` (returns your signing secret). There is no step that requires clicking around a portal UI first: a key is issued against your workspace, so it can create that workspace's very first connection. ## Auth (API keys — the default) - Create/revoke keys: https://developers.wasync.app/keys. The key is shown **once** at creation and stored only as a hash; lose it and you revoke it and create another. - Send it as `Authorization: Bearer wsk_live_…` on every endpoint below. No expiry, no refresh loop, no rotation dance. - Each key carries its own **scopes** — `whatsapp.read` (list + read), `whatsapp.send` (send + read receipts), `whatsapp.events` (webhooks), `whatsapp.manage` (create/inspect/restart/logout/delete connections). Scopes and the connection list are resolved LIVE on every request: revoking or narrowing a key takes effect on the next call. - **Optional IP allowlist** (hardening): pin a key to IPv4/IPv6 addresses or CIDR ranges on the same page. A call from an address that is not on the list is refused with **403 `ip_not_allowed`** — deliberately distinct from **401 `invalid_key`** (unknown/revoked key), because the fix is different: add an address rather than rotate a good key. You cannot lock yourself out — that page is authenticated by your account, not by the key, so it is reachable from anywhere. - Errors carry `WWW-Authenticate: Bearer error="…"`. 401 `invalid_key` · 403 `ip_not_allowed` · 403 `insufficient_scope`. ## API - [OpenAPI 3 spec](https://developers.wasync.app/openapi.json): REST v1 contract for codegen — `GET /connections`, `POST /connections`, `GET /connections/{id}`, `POST /connections/{id}/pairing-code`, `POST /connections/{id}/restart`, `POST /connections/{id}/logout`, `DELETE /connections/{id}`, `GET /account`, `GET /messages`, `POST /messages` (text or media: images, video, audio/voice notes, documents — base64, ≤ 16 MB), `POST /messages/read`, `GET /webhook`, `PUT /webhook`, `POST /webhook/rotate`. Base URL `https://developers.wasync.app/api/v1` (canonical). **`https://cloudapi.wasync.app/api/v1` is a permanent alias and keeps working indefinitely** — same API, same keys, same responses; existing integrations need no change. Served CORS-open. - **Connection lifecycle (scope `whatsapp.manage`)**: `POST /connections` `{ label? }` → 201 `{ connection: { id, status: "connecting", qr } }` (`qr` = data-URI PNG that is stale within seconds — WhatsApp rotates the pairing code ~every 20 s, so render the `qr` from each `GET /connections/{id}` poll, never the create response alone; a stale QR fails to scan with no visible error; standalone workspaces only — a Bitrix portal gets 403 `forbidden_portal_kind`) · **optional `Idempotency-Key` header on `POST /connections` ONLY** (no other endpoint takes it; absent = behaviour exactly as before): a create provisions a session and consumes a paid SLOT, so a client that times out and retries without it creates a SECOND connection and you pay for two slots for one customer. Keys are namespaced per WORKSPACE (two partners can use the same literal string without colliding) and live **24 h** from first use. Four cases: new key → provision as normal, **201** · same key + same body within TTL → the SAME connection, nothing provisioned again, **200** + response header `Idempotency-Replayed: true` (the `qr` there is FRESHLY fetched, not the stored one — WhatsApp rotates pairing codes ~every 20 s, so a stale replayed QR would just fail to scan) · same key + DIFFERENT body → **400** `{"error":"idempotency_key_reuse"}` · same key while the first request is still in flight → **409** `{"error":"idempotency_in_progress"}` + `Retry-After: 1`, honour it and retry. Body comparison is a hash of the CANONICAL body, so JSON key order does not matter. Key = any opaque string ≤ 255 chars; longer → **400** `{"error":"idempotency_key_invalid"}` (rejected, never truncated); blank/whitespace-only = treated as absent. **Generate ONE key per customer-onboarding ATTEMPT** — a fresh UUID (`crypto.randomUUID()`) minted right before the call, reused only by that call's retries; NEVER one per process, per API key, per day, or a constant, because a too-coarse key hands the second customer you onboard the FIRST customer's connection instead of his own. Response body shape is unchanged in all cases · **`POST /connections/{id}/pairing-code`** `{ phoneNumber }` → `{ code: "ABCD-ABCD" }` — the alternative to scanning: the customer types the code into WhatsApp → Settings → Linked devices → Link with phone number. Use it when the phone running WhatsApp IS the device your flow is open on, where a QR on the same screen cannot be scanned at all. `phoneNumber` is E.164 WITHOUT the leading `+` (digits only) and is REJECTED, never cleaned up, if it is anything else → **400** `invalid_phone_number`. **A pairing code is NOT always available — the engine can decline with 502 `pairing_code_unavailable`, so your app MUST keep the QR flow on screen as a fallback.** Re-requestable on the same connection (returns a NEW code, does not rebuild the session), capped at **5 per connection per 15 min** → **429** `rate_limited` + `Retry-After`. Consumes NOTHING extra: same slot, same 7-day trial as QR pairing, claimed when the number actually pairs. Status monitoring unchanged — poll `GET /connections/{id}` and/or listen for `connection.connected`. 409 `already_connected` if the session is live; 409 `not_supported_for_provider` for Meta/WABA (`meta_cloud`) numbers, which is permanent · `GET /connections/{id}` → `{ connection: { id, status, phoneNumber, label, provider, licenseStatus, licenseExpires, qr, …snake_case aliases } }`, poll every 3–5 s and re-render `qr` until `status` is `"connected"` · `POST /connections/{id}/restart` → `{ ok, recreated }` (re-pair a broken session) · `POST /connections/{id}/logout` → `{ ok: true }` (clears credentials; restart + re-scan to come back) · `DELETE /connections/{id}` → `{ ok: true }` (permanent). - **Webhook self-service (scope `whatsapp.events`)**: `GET /webhook` → `{ url, events, secretSet }` (the secret is NEVER returned by the read path) · `PUT /webhook` `{ url }` → `{ url, events, secret }` (HTTPS on a public host; the secret is issued here, store it) · `POST /webhook/rotate` → `{ secret }` (the old secret stops working immediately). Signature scheme unchanged — see Webhooks below. - **Slots — `GET /account`** (scope `whatsapp.read`): `{ slots: { total, used, available, nextExpiry }, connections: { total, connected, needsReconnect } }`. Call it before provisioning: `slots.available` is what you can still create with an active licence, `slots.nextExpiry` is the renewal deadline, `connections.needsReconnect` counts numbers waiting for a fresh QR scan. A slot is CAPACITY, not a phone number and not something you bind to a customer: it attaches when a number actually pairs, and a `DELETE`d connection returns its slot to the pool with the time left on it, ready for the next number. You map OUR connection `id` (a stable cuid, on every REST response and every webhook's `connection_id`) to your own customer record — we mint the id, you map it. - **Read receipts — `POST /messages/read`**: body `{ connectionId, phone?, messageId? }` (at least one of `phone`/`messageId`; `messageId` is WASync's cuid of an INCOMING message). Sends the WhatsApp read receipt so the customer sees blue ticks. Scope is **`whatsapp.send`** — it transmits a receipt to the customer's device, so it is an outbound action and needs no new scope / no re-consent. Call it when an operator opens the conversation in your own CRM, otherwise the customer stays on double grey ticks and assumes they are being ignored. Idempotent: no `idempotencyKey`, no rate limit. Response `{ ok: true, marked }`. - WABA health monitoring: official-API connections are continuously health-checked; billing blocks (e.g. Meta code 131042), quality limits, bans, and template rejections trigger proactive WASync alerts with fix instructions; current status always reflected in `GET /connections/{id}`. - [Quickstart](https://developers.wasync.app/docs): API key, REST, webhooks, MCP, and the OAuth path in one page. - [Full documentation in one file](https://developers.wasync.app/llms-full.txt): the complete docs as a single markdown document — API keys, IP allowlist, quickstart, REST reference (full connection lifecycle + `GET /account` slots + webhook self-service), send timeout guidance, idempotency, multi-tenant recipe, MCP, OAuth for third-party apps, all four webhook events + signature verification. - Build it right the first time — implementation checklist (in llms-full.txt, after Quickstart): 11-item numbered checklist covering HTTP timeout, idempotency key discipline, id joining, webhook HMAC + deduping, monotonic status ladder, reconciliation polling, credential storage, WAPP warm-up caps, media constraints, marking inbound read when your operator reads it (blue ticks), and offering BOTH pairing paths (QR + pairing code) because neither one always works. - [Changelog](https://developers.wasync.app/changelog): dated entries — new endpoints, improvements, breaking changes. ## OAuth2 (third-party apps only) Use this when your app is installed by OTHER WASync customers and a third party must consent to connections they own. If you are automating your own account, use an API key — everything below is avoidable work. - [Authorization server metadata](https://cloudapi.wasync.app/.well-known/oauth-authorization-server): issuer, authorize/token endpoints, scopes, supported PKCE. **These OAuth URLs — and the MCP endpoint below — stay on `cloudapi.wasync.app` on purpose:** they are protocol identifiers already registered by clients and must match byte-for-byte. Only the REST base URL and the human-facing pages use the canonical `developers.wasync.app`. - Authorization Code + **PKCE (S256 required)**. Token endpoint auth is **`client_secret_post`**. Pass the access token as `Authorization: Bearer `. - **access_token TTL: 15 minutes** (JWT). **refresh_token TTL: 30 days**. Refresh rotates — the old token is invalidated; persist the new one atomically. Never refresh concurrently (serialize across workers). On 401: refresh once, retry. - Same scopes as keys: `whatsapp.read`, `whatsapp.send`, `whatsapp.events`, `whatsapp.manage`. - Consent: a Bitrix24 portal admin approves in their portal (Settings → API & Agents, user code); a standalone workspace owner approves at developers.wasync.app/authorize. A grant is only ever issued over connections that already exist — which is why an OAuth-only integration cannot create a workspace's first connection, and an API key can. ## Pricing & licensing - **Bitrix24-connected connections:** each connection used through the API needs an active per-connection API add-on (purchased by the Bitrix portal admin). No free tier. Pricing and activation: https://developers.wasync.app/billing - **Standalone workspaces:** API access is bundled into the connection license. New connections get a 7-day trial with full API access; after that, pay per connection. Pricing and activation: https://developers.wasync.app/billing. 402 `addon_required`/`license_inactive` = license lapsed. ## Sends — timeout guidance - **QR/WAPP connections:** set HTTP timeout ≥ **180 s**. Anti-ban humanization adds 1.2–3.5 s base + ~30 ms/char typing simulation; session self-heal can take up to ~2.5 min. New QR numbers also have 72 h warm-up volume caps. - **WABA (official API) connections:** no humanization; sends complete in ~1 s. - Always include `idempotencyKey`. On 409 `in_progress`: honor `Retry-After`, retry with the **same key**. Never rotate the key on retry. ## MCP (remote server) - Endpoint (Streamable HTTP): `https://cloudapi.wasync.app/api/mcp` — add it to an MCP client (Claude Desktop, Cursor) and authenticate with the OAuth2 flow. No code required. - Tools: `list_whatsapp_connections`, `send_whatsapp_message`, `read_whatsapp_messages`. ## Webhooks - [Webhooks reference](https://developers.wasync.app/docs/webhooks): full delivery contract — event catalog, payload, signature verification, retry policy (3 attempts, exponential backoff, 10s timeout), duplicates/ordering semantics, troubleshooting. - **Set it yourself:** `PUT /api/v1/webhook` `{ url }` returns `{ url, events, secret }`. Scope `whatsapp.events`. No app registration and no consent step is involved when you use an API key. - **Four event types.** `message.received` — a WhatsApp message arrived on a granted connection (inbound only). `message.status` — the delivery state of one of *your* outgoing messages changed (`sent`/`delivered`/`read`/`failed`, for real-time ticks). `connection.disconnected` — a connection stopped working and its owner must scan a fresh QR; fires once per outage. `connection.connected` — a connection you were told had broken is working again; fires once. Third-party OAuth apps instead set `webhook_url` on the app and request the `whatsapp.events` scope; subscriptions created before Jul 24 2026 receive only `message.received`, and those created before Aug 10 2026 do not get the `connection.*` events (re-consent, or ask support to add them). - **QR connections only.** Webhooks fire for QR/WAPP connections (`provider: "qr"`). Meta/WABA connections (`provider: "cloud_api"`, Cloud API) emit **no webhooks today** — poll `GET /messages` and `GET /connections/{id}` for those. - **Latency expectations.** A disconnect is detected by the status probe, typically **within 5 minutes**; when the connection first looks recoverable, WASync retries the automatic heal before giving up, so that path can take **~15–20 minutes** before `connection.disconnected` is delivered. Message events are delivered as they happen. - `connection.disconnected` payload: `{ event, connection_id, connection: { id, phone_number, label, status, needs_reconnect, license_status, license_expires, reason }, timestamp }`. The QR is deliberately NOT in the payload (it is a scan-to-login credential) — fetch it over your authenticated `GET /connections/{id}`. `reason` is advisory (e.g. `NEEDS_RECONNECT`, `HEAL_GIVEUP`); drive your UI off `needs_reconnect`. - **Webhook secret:** returned by `PUT /api/v1/webhook`, rotatable with `POST /api/v1/webhook/rotate`, and also visible in the developer portal at developers.wasync.app → API keys. `GET /api/v1/webhook` never returns it — it reports `secretSet: true/false` only. - `message.received` payload: `{ event: "message.received", connection_id, message: { id, wa_id, from, text, media_url, media_type, timestamp } }` — `timestamp` is epoch milliseconds; dedupe on `message.id`. - `message.status` payload: `{ event: "message.status", connection_id, message: { id, wa_id, status, timestamp } }` — `message.id` is WASync's stable id, identical to the `messageId` returned by `POST /messages`; join on it to update ticks. - **Two ids, one rule:** `message.id` (webhooks) = `messageId` (POST /messages) = `id` (GET /messages) — WASync's cuid, the only joinable id. `wa_id` (webhooks) / `waMessageId` (REST) is WhatsApp's own id (`true_…@c.us_3EB0…`, may be `null`) — support and debugging only, never a join or dedupe key. - Verify each delivery: `X-WASync-Signature: sha256=` = HMAC-SHA256(`${X-WASync-Timestamp}.${rawBody}`, subscription_secret). Reject if timestamp > 5 min old. ACK 2xx in < 10 s, process async. ## Multi-tenant recipe One API key + one workspace. `POST /connections` per tenant (each returns a QR for that tenant's phone) — including the very first one, which needs no portal visit when you authenticate with a key. Check `GET /account` for a free slot before each create. **Send an `Idempotency-Key` on every create**: mint a fresh UUID per tenant-onboarding ATTEMPT (right before the call, reused only by that call's retries) so a timeout cannot hand you — and bill you for — a second connection for the same tenant. Keys are namespaced per workspace and expire 24 h after first use. Never reuse one key across tenants, per process, per day, or as a constant: too coarse a key returns the FIRST tenant's connection to the second tenant you onboard. Same key + same body within TTL → 200 + `Idempotency-Replayed: true` (QR is re-fetched fresh) · different body → 400 `idempotency_key_reuse` · first call still in flight → 409 `idempotency_in_progress` + `Retry-After: 1`. Set one webhook with `PUT /webhook` and route inbound by `connection_id` (stable cuid; only `DELETE` retires it). New connections are usable immediately — the key's connection list is resolved live on every call, so there is nothing to refresh.