# WASync WhatsApp Platform API — full documentation > Send and receive WhatsApp from your app or AI agent — beyond Bitrix24. The same WhatsApp connections WASync runs inside Bitrix24 CRM are exposed as a REST v1 API and as a remote MCP server, so a coding agent can wire up an integration in minutes. **Start with an API key** (`Authorization: Bearer wsk_live_…`, created at developers.wasync.app/keys): that is the whole setup for automating your own account. OAuth2 is the other path, for apps that OTHER WASync customers install. Both credential types work on every endpoint, in the same header. This file is the complete documentation in one document, intended for LLM ingestion. Index version: https://developers.wasync.app/llms.txt Machine contract: https://developers.wasync.app/openapi.json (OpenAPI 3.0, CORS-open) Human quickstart: https://developers.wasync.app/docs Changelog: https://developers.wasync.app/changelog --- ## Recent changes A dated summary of changes that affect integrators. Read this before starting, then check the full changelog for details. - **Aug 18 2026** — **Pairing codes ("link with phone number"):** new `POST /connections/{id}/pairing-code` `{ phoneNumber }` → `{ code: "ABCD-ABCD" }` (scope `whatsapp.manage`). The customer types the code into WhatsApp → Settings → Linked devices → Link with phone number instead of scanning a QR — the path that works when your onboarding flow is open on the SAME phone that runs WhatsApp, where a QR on that screen cannot be photographed. **A code is not always available (502 `pairing_code_unavailable`), so keep the QR flow in your UI as a fallback.** Re-requestable (a new code, same session), capped at 5 per connection per 15 minutes (429 `rate_limited` + `Retry-After`). Consumes nothing extra: same slot, same 7-day trial, same status monitoring as QR pairing. QR/WAPP only — Meta/WABA answers 409 `not_supported_for_provider`. - **Aug 17 2026** — **Idempotent connection creates:** `POST /connections` now accepts an optional `Idempotency-Key` header (that endpoint only; omit it and nothing changes). A create consumes a paid slot, so a timed-out retry used to cost you a second connection — now the same key with the same body returns the same connection with **200** and `Idempotency-Replayed: true` (QR re-fetched fresh), a different body returns 400 `idempotency_key_reuse`, an in-flight duplicate returns 409 `idempotency_in_progress` with `Retry-After: 1`, and a key over 255 chars returns 400 `idempotency_key_invalid`. Keys are namespaced per workspace and live 24 h. **Use one fresh UUID per customer-onboarding attempt** — a key reused across customers hands the second one the first one's connection. - **Aug 2026** — **API keys + webhook self-service:** `Authorization: Bearer wsk_live_…` now works on every endpoint. Create keys at developers.wasync.app/keys (scopes per key, optional IP allowlist, revoke any time). A key can create a workspace's FIRST connection, so the old "create the first one in the portal UI" bootstrap step no longer applies to key-based integrations. New endpoints `GET /webhook`, `PUT /webhook`, `POST /webhook/rotate` let you set your endpoint and manage its signing secret without registering an app. New error codes: 401 `invalid_key`, 403 `ip_not_allowed`. **Nothing changed for existing OAuth clients**, and the webhook signature scheme is unchanged. - **Aug 2026** — **Connection lifecycle events + slots:** `connection.disconnected` / `connection.connected` webhooks (QR connections; one event per real outage) and `GET /account` (slot quota + connection health) — the two pieces a partner needs to run our connections inside their own product without polling everything forever. Subscriptions created before Aug 10 2026 must re-consent to receive the connection events. - **Jul 25 2026** — **Read receipts:** new `POST /messages/read` (scope `whatsapp.send`, no re-consent) sends the WhatsApp read receipt so your customers see blue ticks when your operator opens the chat in your own CRM. Body `{ connectionId, phone?, messageId? }`, response `{ ok: true, marked }`, idempotent and not rate-limited. - **Jul 25 2026** — Media send added to `POST /messages` (images, video, audio/voice notes, documents, base64 ≤ 16 MB); `message.status` webhook event added (delivery status for your outgoing messages). `message.id` / `messageId` namespace unified across text and media sends — same cuid on both paths. `Retry-After` header added to 409 `in_progress` and 429 `rate_limited` / `warmup_limited`. - **Jul 24 2026** — `message.status` webhook subscriptions enabled for new consents (re-consent existing grants to enable). - **Jul 2026** — Field-named 400 errors: `invalid_request` responses now carry `field` (the offending input key) and optional `hint` as additive fields. Existing clients that only read `error` are unaffected. - **Jul 2026** — Canonical camelCase on connection endpoints: `GET /connections` now returns `phoneNumber` and `label` alongside the old `phone` and `name` (deprecated aliases, same values, kept for compatibility). `GET /connections/{id}` now returns `phoneNumber`, `licenseStatus`, `licenseExpires` alongside deprecated snake_case aliases (`license_status`, `license_expires`). Webhooks intentionally remain snake_case. - **Jun 2026** — Platform API launched (cloudapi.wasync.app). OAuth2 + PKCE, REST v1, remote MCP server. ## What it is WASync exposes WhatsApp connections as a plain REST API and a remote MCP (Model Context Protocol) server. Authenticate with an **API key** (or, for third-party apps, OAuth2), then list connections, read messages, and send WhatsApp messages (text, images, video, audio/voice notes, documents) — from any app or AI agent. - REST base URL: `https://developers.wasync.app/api/v1` - **The older host `https://cloudapi.wasync.app/api/v1` remains valid indefinitely.** `developers.wasync.app` is the canonical name; `cloudapi.wasync.app` is a permanent alias serving exactly the same API (same keys, same paths, same responses), so an existing integration pointed at it needs no change — ever. All examples below use the canonical host. - Remote MCP server (Streamable HTTP): `https://cloudapi.wasync.app/api/mcp` - Developer portal (API keys, webhook, workspaces, apps): `https://developers.wasync.app` **Why the OAuth and MCP URLs still say `cloudapi`.** The OAuth `issuer`, the authorize/token endpoints, the `/.well-known/…` discovery documents and the MCP server URL are *protocol identifiers* that clients have already registered and that must match byte-for-byte — they stay on `https://cloudapi.wasync.app` and are unchanged. Use them exactly as written below. Only the REST base URL and the human-facing pages moved to the canonical `developers.wasync.app`, and even those keep working on `cloudapi`. **Provisioning from zero — with an API key there is no manual first step.** A key is issued against your workspace, so `POST /connections` can create that workspace's very first connection: create an account, create a key, call the endpoint, render the QR it returns. Nothing has to be clicked in a portal UI first. This is the one place where the two credential types genuinely differ, and it is worth understanding why. An OAuth *grant* is issued **over connections that already exist** (an authorize request with an empty connection list is rejected), and `POST /connections` needs an active grant — so an OAuth-only integration cannot bootstrap an empty workspace and must have one connection created another way first. An API key has no such dependency. If you are automating your own account, use a key and the problem disappears. ## Two WhatsApp connection types (WAPP and WABA) The API supports two different WhatsApp connection types. Both send and receive through the identical REST endpoints. **WAPP (QR connections)** — ordinary WhatsApp numbers paired by scanning a QR code with a phone. No Meta Business account or phone number verification is needed. You create and manage them entirely through the API via `POST /connections`, which returns a QR code to render and scan. After scanning, the number is immediately usable. These connections apply anti-ban humanization (typing simulation, rate caps) which adds 1–7 s to sends normally, up to ~2.5 min on session self-heal. New WAPP numbers have a 72-hour warm-up period with hourly volume caps. Provider field value: `"qr"`. **WABA (WhatsApp Business Cloud API)** — numbers registered through Meta's official WhatsApp Business platform. Requires a Meta Business account and phone number registration but allows higher throughput and does not require QR scanning. Sends complete in ~1 s with no humanization. Provider field value: `"meta_cloud"`. Check the `provider` field on any connection to determine which type it is. All endpoint paths, request shapes, and response shapes are identical for both types; the only behavioral differences are send latency and warm-up caps (WAPP only). ## Authentication Two credential types reach every endpoint in this document, in the same `Authorization: Bearer` header. Pick one: | | **API key** (default) | **OAuth 2.0** | |---|---|---| | Use it when | you are automating **your own** account | your app is installed by **other** WASync customers and they must consent to connections they own | | Setup | create a key, paste it into a header | redirect URI, PKCE, consent screen, token exchange, refresh rotation | | Credential | `wsk_live_…`, no expiry | 15-minute JWT + 30-day rotating refresh token | | First connection | can create it (see Provisioning from zero) | needs one to already exist | ### API keys (start here) Create and revoke keys at **https://developers.wasync.app/keys**. ``` curl https://developers.wasync.app/api/v1/connections \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` - The key is displayed **once**, at creation. WASync stores only a hash of it — there is no code path that can show it to you again. Lose it and you revoke it and create another. - Each key carries its own **scopes**: `whatsapp.read`, `whatsapp.send`, `whatsapp.events`, `whatsapp.manage`. Grant the minimum the integration needs. - Scopes and the reachable connection list are resolved **live on every request**. Revoking a key, narrowing it, or creating a new connection takes effect on the very next call — there is nothing cached and nothing to refresh. - Store it like any other secret (environment variable / secret manager). Never commit it, never put it in a query string, never ship it in client-side code — a key is a full credential for the workspace it belongs to. ### Optional hardening — IP allowlist A key can be pinned to a set of IPv4/IPv6 addresses or CIDR ranges, managed on the same page. - A call from an address that is **not** on the list is refused with **403 `ip_not_allowed`**. - That is deliberately a different code from **401 `invalid_key`** (unknown or revoked key), because the fix is different: add an address, rather than rotate a perfectly good key. Branch on the code, not on the status. - If WASync cannot determine the calling address at all, a key with an allowlist fails closed (403). A key with no allowlist is unaffected. - 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 — change networks and add the new one. ### OAuth 2.0 Full flow, TTLs and refresh discipline are in **Tokens — TTL and refresh** below, and the consent paths are in **Two client types**. Use it only when a third party has to approve access to connections they own; otherwise it is avoidable work. ## Two client types The Platform API supports two distinct integration patterns. Understand which one you need before starting. ### Type 1 — Bitrix24-connected apps For apps that want to use WhatsApp connections **already owned by a Bitrix24 portal admin**: - Connections belong to the Bitrix portal admin. - Consent is approved by the portal admin in their Bitrix app under **Settings → "API & Agents"** (user code entry flow). - Each connection used through the API needs an active **per-connection API add-on**, purchased by the portal admin inside WASync. Pricing and activation: https://developers.wasync.app/billing - A connection without an active add-on does not appear in `GET /connections`, and sends to it are rejected with `addon_required`. - Connections cannot be created through the API on a Bitrix24 portal — `POST /connections` returns 403 `forbidden_portal_kind`. Bitrix connections are created by the portal admin inside the WASync app. ### Type 2 — Standalone workspaces For apps and SaaS products that manage their own WhatsApp numbers, **without Bitrix24**: - Self-serve entirely on https://developers.wasync.app. With an **API key**: register → create a key → `POST /connections` → scan the QR. With **OAuth**: register → create an app → create a **workspace** (Billing page, "+ New workspace") → get one connection to exist (create it with a key, or in the portal UI) → OAuth → create any further connections through the API. - Platform API access is **bundled into the connection license** — no separate add-on is needed. - Every new connection starts a **7-day trial** with full API access. After the trial, pay per connection (WAPP/QR or WABA tier) by card at developers.wasync.app → Billing. - A 402 `addon_required` / `license_inactive` response means the connection's license has lapsed. - **Auto-approve on consent:** when the app's developer owns exactly ONE standalone workspace that has at least one connection, `/oauth/authorize` is approved instantly — the browser redirects immediately with `?code=…` and no user code screen appears. Otherwise, the developer approves manually at `https://developers.wasync.app/authorize` (choose workspace + connections + scopes). ## Pricing & licensing **Current prices and activation live in one place: https://developers.wasync.app/billing** — this document deliberately does not restate figures, so it can never quote a stale price. **Bitrix24-connected apps:** each WhatsApp connection used through the API needs an active **per-connection API add-on**, purchased by the Bitrix24 portal admin inside WASync. There is no free tier for Bitrix connections. WASync connections themselves follow WASync's standard licensing (7-day trial for a new connection, then a paid WABA or WAPP plan); the API add-on is on top of an active connection. **Standalone workspaces:** Platform API access is bundled into the connection license — no separate add-on. New connections start a 7-day trial with full API access. After the trial, the workspace owner pays per connection by card (developers.wasync.app → Billing). 402 errors (`addon_required` / `license_inactive`) mean the connection's license has lapsed. **Slots:** `GET /account` reports how many connection slots the workspace holds, how many are free, and when the next one expires — check it before provisioning so you never promise a customer a number you cannot license. ## Quickstart (7 steps) ### 1. Create an API key Go to https://developers.wasync.app, create an account, then open **API keys** and create one. Give it a name, tick the scopes you need — `whatsapp.read`, `whatsapp.send`, `whatsapp.events`, and `whatsapp.manage` if you will create, inspect, restart, log out or delete connections (`GET /account` needs only `whatsapp.read`) — and optionally restrict it to a set of IP addresses. The key is shown **once**. Copy it into your environment: ``` export WASYNC_API_KEY=wsk_live_… ``` That is the whole auth setup. Send it as `Authorization: Bearer $WASYNC_API_KEY` on every call below. ### 2. Connect a WhatsApp number ``` 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"}' ``` Returns 201 `{ connection: { id, status: "connecting", qr } }`. `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"`. Scope: `whatsapp.manage`. **The QR from the create response does NOT stay valid.** WhatsApp rotates the pairing code roughly every 20 seconds, so the code you got at create time is dead within seconds of being displayed. A stale QR fails silently: the phone simply never pairs and nothing on screen says why. Polling and re-rendering is not an optimisation — it is the only way pairing works. This works on a workspace with no connections at all — the key is what makes that possible (see Provisioning from zero). **Can't scan? Ask for a pairing code instead.** When the customer is completing your flow on the SAME phone that runs WhatsApp, there is no second screen to photograph and the QR path is impossible for them. Request an 8-character code they can type in: ``` 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"}' ``` Returns `{ "code": "7S59-1KZP" }`. The customer opens WhatsApp → Settings → Linked devices → **Link with phone number** and types it. `phoneNumber` is E.164 **without** the leading `+` (digits only); anything else is rejected with 400 `invalid_phone_number` rather than cleaned up, because silently rewriting the number is how someone ends up pairing an account nobody asked for. **A pairing code is not always available.** The engine can decline, and that is an ordinary outcome rather than an outage: you get **502 `pairing_code_unavailable`**. **Keep the QR flow on screen as the fallback** — an integration that offers only the pairing code will strand customers with no way to connect at all. Offer both, side by side. You can request another code on the same connection (it returns a NEW code and does not rebuild the session), up to **5 times per connection per 15 minutes**; past that it is **429 `rate_limited`** with `Retry-After`. Nothing extra is consumed either way: same paid slot, same 7-day trial as QR pairing, claimed when the number actually pairs. Watch for success exactly as with a QR — poll `GET /connections/{id}` until `status` is `"connected"`, and/or listen for the `connection.connected` webhook. The `Idempotency-Key` header is optional but strongly recommended: a create consumes a paid slot, so a client that times out and retries without it ends up with a second connection and a second slot bill. Within 24 h, the same key with the same body returns the SAME connection (**200** + `Idempotency-Replayed: true`, with a freshly fetched `qr`); the same key with a different body is **400** `idempotency_key_reuse`; a retry while the first call is still in flight is **409** `idempotency_in_progress` with `Retry-After: 1`. **Mint one fresh UUID per customer-onboarding attempt** and reuse it only for that call's retries — never one key per process, per API key, per day, or a constant. Full rules in the `POST /connections` reference below. ### 3. Set your webhook ``` 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"}' ``` Returns `{ url, events, secret }`. **Store the `secret`** — it keys the HMAC on every delivery, and `GET /webhook` will not return it again (it reports `secretSet: true` instead). Rotate with `POST /webhook/rotate`. Scope: `whatsapp.events`. Verification code is in the Webhooks section below; the signature scheme is unchanged. ### 4. OAuth + consent — only for third-party apps **Skip this section if you are automating your own account.** It applies when your app is installed by other WASync customers, who must consent to connections they own. You will need an app (developers.wasync.app → Apps) with a `client_id`, a `client_secret` (shown once), one or more `redirect_uri` (must match exactly at token exchange), an optional `webhook_url`, and the scopes you need. The platform uses **Authorization Code with PKCE (S256 is required)**. Token endpoint auth method is **`client_secret_post`**. 1. Send the user to `https://cloudapi.wasync.app/oauth/authorize` with `response_type=code`, your `client_id`, `redirect_uri`, `scope`, a `state`, and a `code_challenge` (`code_challenge_method=S256`). 2. **Consent path depends on client type:** - **Standalone (auto-approve):** if your developer account owns exactly one standalone workspace with ≥1 connection, the redirect with `?code=…&state=…` happens immediately — no user action needed. - **Standalone (manual):** otherwise, the developer approves the request at `https://developers.wasync.app/authorize` (choose workspace + connections + scopes), then the redirect fires. - **Bitrix24:** a short user code is shown. The **Bitrix portal admin** opens their Bitrix app under **Settings → "API & Agents"**, enters that code, and picks the connections + scopes to approve. The browser is then redirected back to your `redirect_uri` with `?code=…&state=…`. 3. Exchange the code at `https://cloudapi.wasync.app/oauth/token` (`client_secret_post` + your `code_verifier`) for an `access_token` and `refresh_token`. ``` 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_VERIFIER ``` OAuth discovery (authorization-server metadata): `https://cloudapi.wasync.app/.well-known/oauth-authorization-server` Protected-resource metadata: `https://cloudapi.wasync.app/.well-known/oauth-protected-resource` JWKS: `https://cloudapi.wasync.app/.well-known/jwks.json` Call the API with header `Authorization: Bearer `. Scopes: - `whatsapp.read` — list connections + read messages - `whatsapp.send` — send messages (text and media) - `whatsapp.events` — receive webhooks for incoming messages - `whatsapp.manage` — create, inspect, restart, log out, and delete connections ### 5. REST API Base URL `https://developers.wasync.app/api/v1` (the `cloudapi.wasync.app` alias keeps working — see *What it is*). Full contract: https://developers.wasync.app/openapi.json List the connections you may use: ``` curl https://developers.wasync.app/api/v1/connections \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` Send a WhatsApp text message: ``` 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 👋"}' ``` Send a media message (e.g. an image): ``` 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","media":{"filename":"photo.jpg","mimetype":"image/jpeg","data":"","caption":"Look!"}}' ``` Mark a customer's messages as read (blue ticks) when your operator opens the chat: ``` 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"}' ``` ### 6. MCP (zero code) Add the remote MCP server to an MCP client (Claude Desktop, Cursor, …) and authenticate with the same OAuth flow. The agent gets three tools with no code on your side. ```json { "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`. ### 7. Webhooks Set your endpoint with `PUT /webhook` (step 3 above) — or, for a third-party OAuth app, set `webhook_url` on the app and request the `whatsapp.events` scope. Details below. --- ## Build it right the first time — implementation checklist Complete all ten items before going to production. Each one prevents a class of bug we have seen repeatedly. 1. **Set HTTP timeout ≥ 180 s on `POST /messages`.** QR/WAPP connections apply anti-ban humanization (typing simulation), so a send legitimately takes 1–7 s (up to ~20 s for long texts) and up to ~2.5 min when a session self-heals. A shorter client timeout aborts the request while the message IS being sent — you will see a client-side timeout or 500 for a message that was actually delivered. This is the single most common integration bug. 2. **Always send `idempotencyKey` (a UUID per logical send).** On ANY error or timeout, retry with the SAME key — never generate a new one. Semantics: if the send is still in flight you get 409 `in_progress` (honor `Retry-After` when present, otherwise back off ~5 s, and poll); if it already completed the original result is replayed with the same `messageId`. A new key = a second real WhatsApp message. 3. **Join on `messageId` / `message.id` — our stable id.** `waMessageId` / `wa_id` is WhatsApp's own id; use it for support tickets and debugging only, never for joins. Store both; index on ours (the cuid). One legitimate use for `waMessageId`: **correlation**. If you hold only a WhatsApp id (rows created before you stored ours, or a send whose response you lost), `GET /messages` returns BOTH ids — join once on `waMessageId`, persist our `messageId`, and join on ours from then on. That exact join replaces text+timestamp heuristics entirely. 4. **Handle all FOUR webhook events: `message.received`, `message.status`, `connection.disconnected`, `connection.connected`.** Verify the HMAC signature on the raw body before parsing (reject deliveries older than 5 min to block replays), respond 2xx fast (under 10 s), then process async. Dedupe on `message.id` — delivery is at-least-once. If you only ever see `message.received`, your subscription predates Jul 24 2026 — re-consent to enable the rest. Remember webhooks fire for QR/WAPP connections only: a `meta_cloud` (WABA) number needs polling instead. 5. **Apply status updates monotonically.** The ladder is `sent` → `delivered` → `read` (terminal: `failed`). Never regress: only apply an incoming status if its rank exceeds the one you already have. Late and out-of-order deliveries are normal. 6. **Build a reconciliation polling loop.** Webhook deliveries are dropped after 3 failed attempts — there is no long redelivery queue. Poll `GET /messages` on an interval (30–60 s is what a production client uses) and backfill anything your webhook missed. Treat webhooks as the fast path, polling as the guarantee. 7. **Store your credential properly — and, if you use OAuth, refresh it correctly.** An **API key** never expires and needs no refresh loop: keep it in an environment variable or secret manager, never in source control, never in a query string, never in client-side code. If you restricted it to IPs, remember that a 403 `ip_not_allowed` means the key is fine and the address is not — add the address rather than rotating the key. **OAuth only:** `access_token` TTL is 15 min, `refresh_token` TTL is 30 days, and refresh ROTATES — the old refresh token is invalidated immediately. Persist the new refresh token atomically (write it before returning) and serialize refreshes across workers (a concurrent refresh from a second worker uses an already-invalidated token and logs you out). 8. **Expect and tolerate latency differences by connection type.** WAPP/QR = humanized (1–7 s normal, ~20 s for long texts, up to ~2.5 min on self-heal); WABA/official API ≈ 1 s. A slow WAPP send is not a failure. Also: a new QR number has 72-hour warm-up hourly volume caps after first pairing — if you blast sends immediately you get 429 `warmup_limited` (the cap is hourly: back off and resume later, do not hot-loop). The separate abuse limiter returns 429 `rate_limited` with a `Retry-After` value. Always code a default back-off: `Retry-After` is not guaranteed to be present on every 409/429. 9. **Media constraints.** Base64 ≤ 16 MB; `media` and `text` are mutually exclusive in the request body. Voice notes must be `audio/ogg` (Opus codec). Inbound `media_url` in webhook payloads is a public, non-expiring HTTPS URL — copy it to your own storage if you need retention beyond what WASync keeps. 10. **Mark inbound read when your operator reads it, so the customer sees blue ticks.** If your agents read WhatsApp inside your own CRM, nobody ever opens the chat in WhatsApp — the customer's messages stay on double grey ticks forever and the conversation feels ignored. Call `POST /messages/read` (scope `whatsapp.send`) the moment an operator opens a conversation. It is idempotent, needs no idempotency key, and is not rate-limited. 11. **Offer BOTH pairing paths — QR and pairing code — on the same screen.** A QR needs a second device to photograph it, so a customer onboarding on the same phone that runs WhatsApp cannot use it at all; `POST /connections/{id}/pairing-code` gives them a code to type instead. The reverse is equally true: **a pairing code is not always available** (502 `pairing_code_unavailable` — an expected outcome, not an outage), so a UI that offers only the code strands those users. Render the QR, put "can't scan? get a code" next to it, and fall back to the QR whenever the code request fails. Both paths consume the same slot and start the same 7-day trial, and both finish the same way: `status` becomes `"connected"` and `connection.connected` fires. --- ## Before you report a problem — self-diagnosis Work through this before opening a support ticket: - **Log every webhook delivery** (event type, `message.id`, signature-verification result) before any business logic. Most "we're not receiving events" reports are events that were received and silently dropped by a failing lookup, not events that were never delivered. - **Confirm which id you're joining on.** If you're joining on `wa_id` / `waMessageId` instead of `message.id` / `messageId`, status updates will silently miss. - **Re-read the message from the API.** Call `GET /messages?connectionId=…` and check the `status` field on the row. If the status there is ahead of your UI, the gap is in your handler, not in our delivery. - **Check the connection state.** Call `GET /connections/{id}` (scope `whatsapp.manage`; with only `whatsapp.read`, use `GET /connections`) and look at `status` and `license_status` before concluding there is a platform problem. A 402 (`license_inactive` / `addon_required`) on sends means the license or API add-on has lapsed — the body carries a `payment_url`. - **When you do report, include:** the `messageId`, the UTC timestamp of the send or event, and the exact request body. With those three things we can trace the event end-to-end in seconds. --- ## Reference implementation (Node/TypeScript, no framework) Two minimal, production-grade snippets. Copy and adapt — they are intentionally free of framework dependencies. ### (a) sendWhatsApp — fetch with 180 s timeout, idempotency, and retry ```typescript import crypto from "crypto"; const STATUS_RANK: Record = { sent: 1, delivered: 2, read: 3, failed: 4, }; async function sendWhatsApp( accessToken: string, payload: { connectionId: string; to: string; text?: string; media?: { data: string; mimetype: string; filename?: string }; idempotencyKey: string; // UUID — same key on EVERY retry (sent in the BODY) }, maxAttempts = 5 // Text path resolves to { messageId, waMessageId, status }; // the media path returns { success, message: { id, … } } — same id, different key. ): Promise> { for (let attempt = 1; attempt <= maxAttempts; attempt++) { let res: Response; try { // 180 s — WAPP/QR humanization can take up to ~2.5 min res = await fetch("https://developers.wasync.app/api/v1/messages", { method: "POST", signal: AbortSignal.timeout(180_000), headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(payload), // idempotencyKey travels in the body }); } catch (err) { // AbortError = client-side timeout. // The message MAY have been delivered — retry the SAME key to find out. if (attempt < maxAttempts) continue; throw err; } if (res.ok) return res.json(); const data = await res.json().catch(() => ({})); if (res.status === 409 && data.error === "in_progress") { // A previous attempt is still in flight; honor Retry-After when present const wait = Number(res.headers.get("Retry-After") ?? 5) * 1000; await new Promise((r) => setTimeout(r, wait)); continue; } if (res.status === 429) { // rate_limited (abuse limiter) or warmup_limited (hourly cap on a fresh // QR number). Retry-After may be absent — always keep a default back-off. const fallback = data.error === "warmup_limited" ? 300 : 10; const wait = Number(res.headers.get("Retry-After") ?? fallback) * 1000; await new Promise((r) => setTimeout(r, wait)); continue; } if (res.status >= 500) { // server_error — safe to retry with the SAME idempotencyKey await new Promise((r) => setTimeout(r, 2000 * attempt)); continue; } // Other 4xx (invalid_request, insufficient_scope, license_inactive, replayed…) are not retryable throw Object.assign(new Error(data.error ?? "send failed"), { status: res.status, data }); } throw new Error("sendWhatsApp: max attempts reached"); } ``` ### (b) handleWebhook — raw-body HMAC verification, freshness check, monotonic status update ```typescript // (b) Webhook handler (Express-style — raw body required) type WASyncEvent = { event: "message.received" | "message.status" | string; connection_id: string; message: { id: string; // WASync cuid — the ONLY joinable id wa_id: string | null; // WhatsApp id — debugging only from?: string; text?: string | null; media_url?: string | null; media_type?: string | null; status?: "sent" | "delivered" | "read" | "failed"; timestamp: number; // epoch ms }; }; async function handleWebhook( rawBody: Buffer, sigHeader: string, // x-wasync-signature: sha256= tsHeader: string, // x-wasync-timestamp: evt: WASyncEvent // JSON.parse(rawBody) — only AFTER verification ): Promise { // 1. Verify HMAC on the RAW body (before any JSON.parse) const secret = process.env.WASYNC_WEBHOOK_SECRET!; const expected = "sha256=" + crypto.createHmac("sha256", secret) .update(`${tsHeader}.${rawBody}`) .digest("hex"); const sigBuf = Buffer.from(sigHeader.padEnd(expected.length)); const expBuf = Buffer.from(expected); if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) { throw Object.assign(new Error("bad signature"), { status: 401 }); } // 2. Reject stale deliveries (replay-attack guard) — timestamp is epoch ms const age = Date.now() - Number(tsHeader); if (age > 5 * 60_000) throw Object.assign(new Error("stale webhook"), { status: 400 }); // 3. Respond 200 immediately; process async (per-attempt timeout is 10 s) setImmediate(async () => { const m = evt.message; switch (evt.event) { case "message.received": { // Dedupe on message.id — delivery is at-least-once await db.messages.upsert({ where: { id: m.id }, update: {}, create: { id: m.id, waId: m.wa_id, connectionId: evt.connection_id, from: m.from, text: m.text, mediaUrl: m.media_url, timestamp: new Date(m.timestamp), }, }); break; } case "message.status": { // Join on m.id (WASync cuid) — NEVER on wa_id const current = await db.messages.findUnique({ where: { id: m.id } }); if (!current || !m.status) break; // Monotonic — never regress: only apply if new rank > stored rank if ((STATUS_RANK[m.status] ?? 0) > (STATUS_RANK[current.status] ?? 0)) { await db.messages.update({ where: { id: m.id }, data: { status: m.status } }); } break; } // Ignore unknown event types — new ones are announced on the changelog first } }); } ``` --- ## Tokens — TTL and refresh **OAuth only.** API keys do not expire and are not refreshed; skip this section if you authenticate with a key. - `access_token` — JWT, **TTL 15 minutes**. Pass as `Authorization: Bearer `. - `refresh_token` — **TTL 30 days**. **Refresh (important — read all of this):** ``` curl -X POST https://cloudapi.wasync.app/oauth/token \ -d grant_type=refresh_token \ -d refresh_token=$REFRESH_TOKEN \ -d client_id=$CLIENT_ID \ -d client_secret=$CLIENT_SECRET ``` - Refresh **rotates** — the old refresh token is invalidated immediately. The response carries a new `refresh_token`; persist it atomically (write it before returning). - **Never refresh concurrently from two workers** — only one refresh will succeed; the other will get a 401 with an already-invalidated token. Serialize all refresh calls for a given grant (use a mutex, advisory lock, or central token-refresh service). - On 401: attempt a single refresh, then retry the original request once. If the refresh itself returns 401, the grant has expired and the user must re-authorize. --- ## REST v1 reference ### GET /connections Returns the WhatsApp connections this access token may use. Only connections with an active license/add-on appear; an expired license or missing API add-on hides the connection. Call this first to discover which `id` to send from or read. - Scope: `whatsapp.read` - Response 200: `{ "connections": [ { "id", "phoneNumber", "label", "name", "phone", "status", "isDefault", "provider", "tier", "profilePicUrl", "licenseValid", "licenseStatus", "licenseExpires" } ] }` (possibly empty) - Canonical (preferred) keys: `phoneNumber`, `label` - Deprecated aliases still present: `phone` (same as `phoneNumber`), `name` (same as `label`) - Errors: 401 (see error catalog), 403 `insufficient_scope`, 500 `server_error` ### GET /messages Returns recent messages (newest first) scoped to the token's connections. Cursor-paginated. - Scope: `whatsapp.read` - Query params: - `connectionId` (optional) — limit to a single connection id (must be inside the grant; otherwise an empty page is returned) - `limit` (optional) — page size, 1–100, default 20 - `cursor` (optional) — opaque pagination cursor from a previous response's `nextCursor` - Response 200: `{ "messages": [ … ], "nextCursor": "…" | null, "hasMore": true | false }` - Errors: 401; 403 `insufficient_scope` or `addon_required` (requested `connectionId` has no effective license/add-on); 500 ```json { "messages": [ { "id": "cmqj3k2ab0001xyz", "waMessageId": "true_40700000000@c.us_3EB0A1B2C3", "connectionId": "conn_8f3a21", "direction": "outgoing", "text": "Hi 👋", "mediaUrl": null, "mediaType": null, "status": "delivered", "errorMessage": null, "createdAt": "2026-07-22T10:00:00.000Z" } ], "nextCursor": "cmqj3k2ab0001xyz", "hasMore": true } ``` - `id` — WASync's stable message id. This is the SAME value `POST /messages` returns as `messageId` and the webhooks carry as `message.id`. Join on it. - `waMessageId` — WhatsApp's own id for the same message (`null` when the provider never acknowledged it). Useful when raising a support ticket; do not use it as a join key. - `cursor` for the next page is the `id` (not the `waMessageId`) of the last row you received. ### Message ids (read this once) Every public surface uses **two different ids** for a message. Mixing them up is the #1 integration bug: | id | Where it appears | Use it for | |----|------------------|-----------| | WASync id (cuid, e.g. `cmqj3k2ab0001xyz`) | `messageId` on `POST /messages`, `message.id` on `GET /messages`, `message.id` in both message webhook payloads (`message.received`, `message.status`) | **Joining.** Store it when you send; match `message.status` events against it. | | WhatsApp id (e.g. `true_40700000000@c.us_3EB0A1B2C3`) | `waMessageId` (REST), `wa_id` (webhooks), `wa_message_id` (media send) | Support tickets, debugging, cross-checking with the WhatsApp app. **Never** a join key — it may be `null`, and it is not unique across our surfaces. | ### Connection management (scope whatsapp.manage) These endpoints create, inspect, and remove WhatsApp connections inside a grant. All six require the `whatsapp.manage` scope. **No bootstrap step with an API key:** a key is issued against your workspace, so `POST /connections` creates its FIRST connection as readily as its tenth. (With OAuth it cannot: `POST /connections` needs an active grant, and a grant is only ever issued over connections that already exist. That asymmetry is the main reason to start with a key.) `POST /connections` is standalone-only either way — on a Bitrix24 portal it returns 403 `forbidden_portal_kind`. Connections created here are QR/WAPP (`provider: "qr"`); WABA numbers are onboarded through Meta embedded signup, not through this API. Full lifecycle at a glance: | Step | Call | Result | |------|------|--------| | Create | `POST /connections` | 201 `{ connection: { id, status: "connecting", qr } }` | | Watch | `GET /connections/{id}` | current `status` + a refreshed `qr` while pairing | | Pair by code | `POST /connections/{id}/pairing-code` | `{ code: "ABCD-ABCD" }` — typed into WhatsApp instead of scanning; **not always available, keep QR as fallback** | | Re-pair | `POST /connections/{id}/restart` | `{ ok, recreated }` — new session, poll + re-render QR | | Sign out | `POST /connections/{id}/logout` | `{ ok: true }` — credentials cleared, needs restart + re-scan | | Remove | `DELETE /connections/{id}` | `{ ok: true }` — permanent, id retired | | Capacity | `GET /account` (scope `whatsapp.read`) | free slots + connection health before you create | #### QR pairing recipe 1. `POST /connections` (with an optional `label`) → the response body carries `connection.qr`, a data-URI PNG of the QR code. 2. Render `qr` in an `` tag and instruct the user to open WhatsApp → Linked Devices → Link a device → scan it. 3. Poll `GET /connections/{id}` every 3–5 seconds and **re-render the `qr` from every response**. Keep polling until `status` becomes `"connected"`. **WhatsApp rotates the pairing code roughly every 20 seconds**, so the `qr` returned by `POST /connections` is valid for seconds only. If you render it once and leave it on screen, scanning will simply never succeed and there is no error to show the user — the phone just sits there. Do not cache the QR, do not put it behind a "refresh" button the user has to press, and do not treat the create response's `qr` as the pairing code. 3b. **If the user cannot scan** — they are on the same phone that runs WhatsApp, or their camera is unusable — call `POST /connections/{id}/pairing-code` with their number and show the code returned; they type it into WhatsApp → Settings → Linked devices → Link with phone number. The poll loop in step 3 is unchanged, and so is what a success looks like. **Offer this ALONGSIDE the QR, never instead of it:** a code cannot always be issued (502 `pairing_code_unavailable`), and a screen with no QR on it leaves those users with nothing. 4. If the connection goes offline later, use `POST /connections/{id}/restart` to initiate a new session, then resume the same poll-and-re-render loop (step 3) — a restart produces a fresh, equally short-lived QR. 5. `POST /connections/{id}/logout` clears the session credentials. After a logout, restart + re-scan to reconnect. 6. `DELETE /connections/{id}` permanently removes the connection. This cannot be undone. #### POST /connections Creates a new WhatsApp connection scoped to this grant. The core provisions a QR pairing session and returns an initial QR code. - Scope: `whatsapp.manage` - Body (JSON, all fields optional): `{ "label": "Support line" }` - Header (optional): `Idempotency-Key: ` — makes a retried create safe. See **Idempotent creates** immediately below. This is the ONLY endpoint that supports the header. - Response 201: `{ "connection": { "id": "conn_8f3a21", "status": "connecting", "qr": "data:image/png;base64,…" } }` - Response 200: an idempotent replay — same body shape, plus the response header `Idempotency-Replayed: true` - Errors: 400 `idempotency_key_reuse` / `idempotency_key_invalid`; 401; 403 `insufficient_scope` / `forbidden_portal_kind` / `invalid_grant`; 409 `idempotency_in_progress` (with `Retry-After: 1`); 500 `server_error` ``` 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"}' ``` ##### Idempotent creates (optional `Idempotency-Key` header) Creating a connection provisions a WhatsApp session and consumes a paid **slot** at pairing time. An HTTP client that times out and retries would otherwise create a SECOND connection — and the partner silently pays for two slots for one customer. The `Idempotency-Key` header closes that hole. The header is **optional**, and supported on `POST /connections` **only** — no other endpoint takes it. If it is absent, behaviour is exactly as before: fully backwards compatible. **Four cases:** 1. **New key** → the connection is provisioned as normal → **201 Created**. 2. **Same key, same request body, within TTL** → the SAME connection is returned, nothing is provisioned again, **200 OK**, plus the response header `Idempotency-Replayed: true`. The `qr` in this 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. 3. **Same key, DIFFERENT request body, within TTL** → **400** `{"error":"idempotency_key_reuse"}`. 4. **Same key, the first request still in flight** → **409** `{"error":"idempotency_in_progress"}` with a `Retry-After: 1` response header. Honour it and retry. **Key scope:** keys are namespaced per **workspace**. Two different partners using the same literal key string never collide; a key from one workspace is invisible to another. **TTL:** 24 hours from the first request that used the key. After that the key is free to be reused for a different request. **Body comparison:** over a hash of the **canonical** request body — JSON key order does not matter, so `{"label":"A"}` and a differently-ordered but equivalent object are the same request. **Key format:** any opaque string up to 255 characters. Longer than 255 → **400** `{"error":"idempotency_key_invalid"}` — the key is rejected, never truncated. A blank or whitespace-only header is treated as absent. **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. The response body shape is unchanged in all cases: `{ "connection": { "id": …, "status": "connecting", "qr": … } }`. #### GET /connections/{id} Returns the current status and metadata of a single connection. Poll this every 3–5 s during QR pairing to detect when `status` reaches `"connected"`. The `qr` field carries a NEW pairing code on each poll — re-render it every time. WhatsApp rotates the code roughly every 20 s, so any QR you keep on screen for longer than that will not scan. - Scope: `whatsapp.manage` - Response 200: `{ "connection": { "id", "status", "phoneNumber", "label", "provider", "licenseStatus", "licenseExpires", "qr", …snake_case aliases } }` - Canonical (preferred) keys: `phoneNumber`, `label`, `licenseStatus`, `licenseExpires` - Deprecated aliases still present: `phone` (same as `phoneNumber`), `license_status` (same as `licenseStatus`), `license_expires` (same as `licenseExpires`) - Errors: 401; 403 `insufficient_scope`; 404 `connection_not_found`; 500 `server_error` ``` curl https://developers.wasync.app/api/v1/connections/conn_8f3a21 \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` Example 200 response (during pairing): ```json { "connection": { "id": "conn_8f3a21", "status": "connecting", "phoneNumber": null, "label": "Support line", "provider": "qr", "licenseStatus": "trial", "licenseExpires": "2026-08-01T00:00:00.000Z", "qr": "data:image/png;base64,…", "phone": null, "license_status": "trial", "license_expires": "2026-08-01T00:00:00.000Z" } } ``` #### POST /connections/{id}/pairing-code Issues a WhatsApp **pairing code** for a connection that is waiting to be paired — the alternative to scanning the QR. The customer opens WhatsApp → Settings → Linked devices → **Link with phone number** and types the 8-character code. Use it whenever the device running your onboarding flow is also the device running WhatsApp. That user cannot photograph their own screen, so for them the QR path is not merely awkward — it does not work at all. - Scope: `whatsapp.manage` - Body: `{ "phoneNumber": "40740267964" }` — E.164 **without** the leading `+`, digits only (8–15). `+40740267964`, `40 740 267 964` and `040740267964` are all rejected with 400 `invalid_phone_number` rather than cleaned up: silently rewriting the number you sent is how a customer ends up pairing an account nobody asked for. - Response 200: `{ "code": "7S59-1KZP" }` - Errors: 400 `invalid_phone_number`; 400 `not_a_qr_connection`; 401; 403 `insufficient_scope`; 404 `connection_not_found`; 409 `already_connected`; 409 `not_supported_for_provider`; 429 `rate_limited` (+ `Retry-After`); 502 `pairing_code_unavailable`; 500 `server_error` ``` 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"}' ``` **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 `pairing_code_unavailable`** rather than a 500 precisely so you can tell the two apart and route the user to the QR. An application that ships only the pairing-code path will, sooner or later, show a customer a screen with no way forward. Render both. **Re-requestable.** Calling it again on the same connection returns a NEW code and does not rebuild the session, so a "send me another code" button is safe to offer. It is capped at **5 requests per connection per 15 minutes**; past that you get **429 `rate_limited`** with `Retry-After`, and you should honour it. The cap exists because repeatedly requesting codes for one number is exactly the behaviour WhatsApp's anti-abuse systems act on — and the number at risk is your customer's. **Nothing extra is consumed.** This provisions nothing. The connection uses the SAME paid slot and starts the SAME 7-day trial as a QR pairing — the licence is claimed when the number actually pairs, whichever way it paired. There is no separate charge, quota or trial for pairing by code. **Status monitoring is unchanged.** There is no "code accepted" event. Poll `GET /connections/{id}` every 3–5 s until `status` is `"connected"`, and/or listen for the `connection.connected` webhook — exactly as with a QR. - **409 `already_connected`** — the session is live, so there is nothing to pair. Issuing a code anyway could displace the working session, which is why it is refused. - **409 `not_supported_for_provider`** — the connection is a Meta/WABA number (`provider: "meta_cloud"`). Pairing codes are a QR/WAPP concept and never apply there; this is permanent, so do not retry. #### POST /connections/{id}/restart Restarts the adapter session. Use this when a connection is disconnected (e.g. after a WhatsApp ban or network failure) to begin a fresh QR-pairing cycle without deleting the connection record. - Scope: `whatsapp.manage` - Body: none required - Response 200: `{ "ok": true, "recreated": false }` (`recreated: true` if the session had to be recreated from scratch) - Errors: 401; 403 `insufficient_scope`; 404 `connection_not_found`; 500 `server_error` ``` curl -X POST https://developers.wasync.app/api/v1/connections/conn_8f3a21/restart \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` #### POST /connections/{id}/logout Logs the WhatsApp session out and clears session credentials. The connection record is preserved. To reconnect, call `/restart` and re-scan the new QR code. - Scope: `whatsapp.manage` - Body: none required - Response 200: `{ "ok": true }` - Errors: 401; 403 `insufficient_scope`; 404 `connection_not_found`; 500 `server_error` ``` curl -X POST https://developers.wasync.app/api/v1/connections/conn_8f3a21/logout \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` #### DELETE /connections/{id} Permanently removes the connection and its adapter session. This cannot be undone — the user will need to create a new connection and go through QR pairing again. - Scope: `whatsapp.manage` - Response 200: `{ "ok": true }` - Errors: 401; 403 `insufficient_scope`; 404 `connection_not_found`; 500 `server_error` ``` curl -X DELETE https://developers.wasync.app/api/v1/connections/conn_8f3a21 \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` ### Webhook self-service (scope whatsapp.events) Point WASync at your endpoint and manage its signing secret, without registering an app or running a consent flow. All three are scoped to the credential's own workspace — there is no way to name another one. #### GET /webhook Returns the current configuration. **The secret is never returned here.** - Scope: `whatsapp.events` - Response 200: `{ "url": "https://example.com/wasync/webhook" | null, "events": ["message.received", …], "secretSet": true|false }` Why not the secret: a read is routinely logged, cached, and pasted into support tickets, so a secret that leaks through the read path is a secret you cannot reason about. If you lost yours, rotate it — which is what you would have to do after a leak anyway. ``` curl https://developers.wasync.app/api/v1/webhook \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` #### PUT /webhook Sets (or moves) the endpoint and returns the signing secret for it. Idempotent. - Scope: `whatsapp.events` - Body: `{ "url": "https://example.com/wasync/webhook" }` — HTTPS on a publicly reachable host. `http://`, `localhost` and private ranges are rejected. - Response 200: `{ "url", "events", "secret" }` — **store the secret**; this is the only response that carries it besides a rotation. - Errors: 400 `invalid_request` (with `field: "url"`), 400 `invalid_webhook` (not HTTPS / not public), 401, 403 `insufficient_scope`, 500 ``` 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"}' ``` #### POST /webhook/rotate Issues a new signing secret and invalidates the old one **immediately** — there is no overlap window. - Scope: `whatsapp.events` - Response 200: `{ "secret": "whsec_…" }` - Deploy the new secret to your receiver promptly. The retry policy (3 attempts, exponential backoff) covers a rollout measured in seconds, not hours. ``` curl -X POST https://developers.wasync.app/api/v1/webhook/rotate \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` The signature scheme these secrets key is unchanged — same headers, same HMAC. See Webhooks below. ### GET /account Slot quota and connection health for the token's workspace. It exists to answer one question before you take an order: *can I onboard another connection right now, or do I owe WASync money first?* Without it you would find out only when a freshly created connection came back with an expired licence — after you had already promised your customer it would work. - Scope: `whatsapp.read` - Everything is scoped to the token's workspace; there is no way to ask about another one. - Response 200: ```json { "slots": { "total": 10, "used": 7, "available": 3, "nextExpiry": "2026-09-01T00:00:00.000Z" }, "connections": { "total": 7, "connected": 6, "needsReconnect": 1 } } ``` - `slots.total` / `slots.used` / `slots.available` — how many connection slots the workspace holds, how many are consumed, and how many `POST /connections` can still fill with a licensed connection. - `slots.nextExpiry` — the date you must renew (or invoice your own client) BEFORE, so a slot never expires underneath a customer who already paid. `null` when nothing is due to expire. - `connections.total` / `connected` / `needsReconnect` — how many connections exist, how many sessions are live, and how many are waiting for their owner to scan a fresh QR (the same state that fires `connection.disconnected`). **What a slot actually is.** A slot is *capacity*, not a phone number and not something you bind to a customer. You never allocate or name one: it attaches when a number actually pairs, and when you `DELETE` that connection the slot returns to your pool **carrying whatever time is left on it**, so 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. **Mapping slots and connections to your customers is your job, and it is easy:** WASync mints the connection `id` (a stable cuid) and you store it against your own customer record. Every surface — `GET /connections`, `GET /connections/{id}`, `POST /messages`, and the `connection_id` on every webhook — carries that same id, so one column in your database is the whole mapping. Do not try to track slots per customer; track connections. - Errors: 401; 403 `insufficient_scope`; 500 `server_error` ``` curl https://developers.wasync.app/api/v1/account \ -H "Authorization: Bearer $WASYNC_API_KEY" ``` ## WABA health monitoring Connections on the official WhatsApp Business API (WABA) are continuously health-checked by WASync — you don't need to poll Meta separately. When a delivery-blocking condition is detected, WASync sends you a proactive alert with the exact issue and fix instructions, so you find out within minutes rather than discovering silently-failing messages days later. `GET /connections/{id}` always reflects the current health state of a connection. Error classes WASync monitors and surfaces: - **Billing / payment issues** — e.g. Meta error code 131042 ("Business account payment issue"); messages are blocked until billing is resolved. - **Quality rating limits** — messaging rate caps triggered when the phone number's quality rating drops; WASync alerts when limits change. - **Account bans or restrictions** — Meta-imposed bans (e.g. code 141014) or restricted account status that blocks all sends. - **Message template rejections** — approved templates can be paused or rejected by Meta post-approval; WASync surfaces the rejection reason. ### POST /messages Sends a WhatsApp message (text or media) from one of the authorized connections. Supply exactly one of `text` or `media` — not both. Pass an `idempotencyKey` on every send to make retries safe. - Scope: `whatsapp.send` **CRITICAL — HTTP timeout:** set your HTTP client timeout on `POST /messages` to **≥ 180 seconds**. QR/WAPP connections apply deliberate anti-ban humanization: 1.2–3.5 s base delay plus ~30 ms/character typing simulation (capped) → a short send takes 1–7 s and a long one up to ~20 s; session self-heal can take up to ~2.5 minutes. WABA (official API) connections have no humanization and complete in ~1 s. **A premature timeout followed by a retry without the same idempotencyKey causes duplicate sends.** **New QR numbers:** also have 72-hour warm-up hourly volume caps after first pairing. Do not blast large volumes immediately after connecting a fresh number. #### Text send ```json { "connectionId": "conn_8f3a21", "to": "40700000000", "text": "Hi 👋", "idempotencyKey": "order-1234-confirm" } ``` - `to` — international digits only, no `+` (e.g. `40700000000`) - `text` — max 4096 characters - `idempotencyKey` — strongly recommended; see Idempotency section - Response 200: `{ "messageId": "cmqj3k2ab0001xyz", "waMessageId": "true_40700000000@c.us_3EB0A1B2C3", "status": "sent" }` - `messageId` — **WASync's stable message id** (a cuid). Store it: it is the same id `GET /messages` returns as `id` and both message webhooks carry as `message.id`, so it is what you join `message.status` events on. - `waMessageId` — WhatsApp's own id for the message (`null` if the provider returned none). For support/debugging only — not a join key. - Rare degraded case: if the message reached WhatsApp but WASync could not persist it, the response is `{ "messageId": null, "waMessageId": "…", "status": "sent", "persisted": false }` (HTTP 200). The message WAS delivered, but it will not appear in `GET /messages` and will emit no `message.status` webhook. Do **not** retry it. #### Media send ```json { "connectionId": "conn_8f3a21", "to": "40700000000", "media": { "filename": "photo.jpg", "mimetype": "image/jpeg", "data": "/9j/4AAQSkZJRgABAQAA...", "caption": "Here is the photo!" } } ``` - `media.filename` — file name with extension (e.g. `photo.jpg`, `voice-note.ogg`, `report.pdf`) - `media.mimetype` — MIME type (see allowed types below) - `media.data` — Base64-encoded file contents. **Maximum decoded size: 16 MB** (base64 string length ≤ 22 400 000 chars) - `media.caption` — optional caption shown below the media in WhatsApp **Allowed MIME types:** - Images: `image/jpeg`, `image/png`, `image/gif`, `image/webp` - Video: `video/mp4`, `video/webm` - Audio: `audio/mpeg`, `audio/ogg`, `audio/wav`, `audio/webm`, `audio/aac` - Documents: `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `application/vnd.ms-excel`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `text/plain`, `text/csv`, `application/zip` Executables and `image/svg+xml` are blocked by the core. **Voice notes:** send `audio/ogg` (Opus codec) with a filename like `voice-note.ogg` — WhatsApp delivers it as a real playable voice note rather than a file attachment. Recipe: record with [opus-recorder](https://github.com/chris-rudmin/opus-recorder) in the browser, or encode with FFmpeg: `ffmpeg -i input.mp3 -c:a libopus output.ogg`. - Response 200: `{ "success": true, "message": { "id": "cmqj3k2ab0001xyz", "wa_message_id": "true_40700000000@c.us_3EB0A1B2C3", "media_url": "https://cdn.wasync.app/uploads/photo.jpg", "media_type": "image", "media_caption": "Here is the photo!", "message_type": "image", "status": "sent", "created_at": "2026-07-22T10:00:00.000Z" } }` - `message.id` — WASync's stable message id, the same id `GET /messages` and the webhooks use (the media path returns it as `message.id`, the text path as `messageId` — same id, different key) - `message.wa_message_id` — WhatsApp's own id (`null` when the send failed or the provider returned none); support/debugging only - `message.media_url` — public URL of the stored media file #### Errors (both paths) - 400 `invalid_request` / `invalid_to` / `text_too_long` (text path) - 400 `text_or_media` — both `text` and `media` supplied - 400 `invalid_media` — `media` object missing or has empty required fields - 400 `file_too_large` — base64 data exceeds the 16 MB limit - 401; 402 (license inactive — see below); 403 `insufficient_scope` / `forbidden_connection`; 409 `replayed` (same idempotencyKey, different payload); 409 `in_progress` (same idempotencyKey, send currently in flight — retry with the SAME key after `Retry-After`, or ~5 s if the header is absent); 429 `rate_limited` / `warmup_limited`; 500 `server_error`; 502 `send_failed` (provider failure — retry with the SAME key) ### POST /messages/read Sends a WhatsApp **read receipt** — the customer sees the blue ticks on the messages they sent you. Call it when your operator opens the conversation in your own UI. Without it, a customer whose agent reads WhatsApp inside another CRM never gets past double grey ticks and assumes they are being ignored. - Scope: **`whatsapp.send`** — marking read TRANSMITS a receipt from your number to the customer's device, so it is an outbound action (`whatsapp.read` only covers reading *our* stored history). No new scope, no re-consent: any client that can already send can mark read. Body — supply `connectionId` plus **at least one** of `phone` / `messageId`: ```json { "connectionId": "conn_8f3a21", "phone": "40700000000" } ``` ```json { "connectionId": "conn_8f3a21", "messageId": "cmqj3k2ab0001xyz" } ``` - `phone` — international digits, no `+`. Marks that peer's conversation read. - `messageId` — **WASync's stable message id** (the cuid from `GET /messages` / the webhook's `message.id`), *not* `waMessageId`. Must be an INCOMING message on that connection. Everything from that peer up to and including it is marked read. - Passing both is allowed; `messageId` wins (it is the more precise target). ``` 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"}' ``` - Response 200: `{ "ok": true, "marked": 3 }` - `marked` — how many of your stored inbound messages were newly stamped as read. `0` means the receipt was sent but everything was already marked (safe, expected on a repeat call). `null` means the receipt WAS sent but our bookkeeping write did not land — the customer still sees blue ticks. - Idempotent by nature: re-marking an already-read conversation is a no-op, so **no `idempotencyKey` is needed** and there is no rate-limit bucket on this endpoint. Retry freely. - Connection differences: WAPP/QR connections mark the whole chat; WABA (official API) connections can only mark a specific message, so a `phone`-only request resolves your most recent inbound message from that number automatically. Errors: - 400 `invalid_request` — missing `connectionId`, or neither `phone` nor `messageId` (the body then carries `"field": "phone|messageId"`) - 400 `invalid_phone` — `phone` could not be normalized - 400 `no_inbound_message` — WABA connection, `phone` only, and we hold no inbound message from that number to acknowledge - 400 `message_id_required` — WABA connection and the target message has no WhatsApp id we can acknowledge - 404 `message_not_found` — `messageId` is not an incoming message on this connection - 401; 402 (license inactive); 403 `insufficient_scope` / `forbidden_connection`; 409 `connection_unavailable` (session not live); 500 `server_error`; 502 `mark_read_failed` (the provider refused — nothing was faked, retry) ## Error catalog All errors are JSON: `{ "error": "" }`. Branch on the `error` code, never on any human-readable text. | HTTP | error code | Meaning | |------|-----------|---------| | 400 | `invalid_request` | Missing or invalid field in the request body. The response body also carries `field` (the name of the offending key, e.g. `"connectionId"`) and an optional `hint` string when the server knows exactly which input failed. These are additive — clients that only read `error` are unaffected. Example: `{ "error": "invalid_request", "field": "connectionId", "hint": "expected a non-empty string; did you send \`connection\` instead of \`connectionId\`?" }`. | | 400 | `invalid_to` | Recipient number could not be normalized. | | 401 | `missing_token` | No `Authorization: Bearer` header. | | 401 | `invalid_key` | The API key is unknown or has been revoked. Create a new one at https://developers.wasync.app/keys — refreshing or retrying will not help. | | 401 | (verification failures) | Expired/invalid OAuth token — refresh the access_token (see Tokens section) and retry once. | | 402 | `license_inactive` | The connection's license or Platform API add-on is not active. Body also carries the original core code in `code` (`addon_required` or `license_inactive`), a short `message`, and a `payment_url` where the connection owner can activate. Surface `payment_url` to the human. **License state can change under your feet at any time** (e.g. a QR re-scan may reassign the license, or it may simply expire) — a connection that worked yesterday can start returning 402 today. Always handle 402 gracefully even on connections that previously succeeded. | | 403 | `insufficient_scope` | The credential lacks the scope for this operation. | | 403 | `ip_not_allowed` | The API key has an IP allowlist and the calling address is not on it (or could not be determined). The key itself is valid — add the address at https://developers.wasync.app/keys, which is reachable from anywhere because it is authenticated by your account and not by the key. Do NOT rotate the key. | | 403 | `forbidden_connection` | `connectionId` is outside this grant. | | 409 | `replayed` | Same `idempotencyKey` reused with a different payload. | | 409 | `in_progress` | Same `idempotencyKey` is currently being processed (send in flight). Honor the `Retry-After` header when present (else back off ~5 s) and retry with the SAME key. | | 429 | `rate_limited` | Abuse rate limit. Back off (`Retry-After` when present) and retry with the SAME key. | | 429 | `warmup_limited` | Hourly warm-up cap on a QR number paired less than 72 h ago. Back off minutes and retry with the SAME key. | | 400 | `no_inbound_message` | `POST /messages/read` on a WABA connection with `phone` only, and no inbound message from that number exists to acknowledge. | | 400 | `message_id_required` | `POST /messages/read` on a WABA connection whose target message carries no WhatsApp id. | | 404 | `message_not_found` | `messageId` is not an incoming message on that connection. | | 409 | `connection_unavailable` | The WhatsApp session is not currently live. Check `GET /connections/{id}`. | | 502 | `mark_read_failed` | The provider refused the read receipt. Nothing was faked — retry. | | 502 | `send_failed` | The provider rejected or failed the send. Retry with the SAME key; if it persists, check `GET /connections/{id}`. | | 500 | `server_error` | Unexpected server error — safe to retry with the same `idempotencyKey`. | 402 body shape: ```json { "error": "license_inactive", "code": "addon_required", "message": "…", "payment_url": "https://developers.wasync.app/billing" } ``` **402 license churn — important for long-running integrations:** A connection's license status can change at any time without warning. For example, a QR re-scan (e.g. after a session ban) can trigger a license reassignment, turning a previously working connection into a 402. A trial that expires overnight will cause all sends from that connection to return 402 starting the next morning. Your integration MUST handle 402 as a recoverable per-connection error, not a fatal crash. Recommended pattern: on 402, log the `payment_url` from the body, surface it to the human connection owner, mark the connection as blocked in your system, and resume sending if the same connection later returns 200 (re-check via `GET /connections/{id}` or on the next successful send). ## Idempotency `POST /messages` accepts an optional `idempotencyKey` (any stable string, e.g. `order-1234-confirm`). Always supply one — it is your primary defense against duplicate sends. Rules: - Retrying with the **same key** replays the stored result (no duplicate send). - A 409 `in_progress` means the original send is still in flight (QR connections can take up to ~2.5 min). Retry with the **same key** after `Retry-After` (or ~5 s if the header is absent) — never rotate it. - Reusing a key with a **different** payload returns 409 `replayed`. - Generate one key per logical send — do not reuse a workflow-level ID across different messages. - Never rotate the key on retry. Rotating generates a new send. ## MCP server - URL (Streamable HTTP): `https://cloudapi.wasync.app/api/mcp` - Auth: same OAuth2 flow (the MCP client drives it; discovery documents are published under `/.well-known/`). - Tools: - `list_whatsapp_connections` — list the connections the grant may use - `send_whatsapp_message` — send a text message - `read_whatsapp_messages` — read recent messages - If a connection's license or API add-on is inactive, tool results return a short actionable text including the payment URL (the agent should relay it to the human). ## Webhooks Full reference page: https://developers.wasync.app/docs/webhooks **With an API key:** call `PUT /webhook` with your URL and you get the signing secret back — no app, no consent step. See *Webhook self-service* in the REST reference above. **With OAuth (third-party apps):** set a `webhook_url` on your app in the developer portal and request the `whatsapp.events` scope. Requirements for delivery (all four must hold): the URL is **HTTPS on a public host** (http/localhost/private IPs are rejected), the credential includes `whatsapp.events` and covers the connection, the connection's license/add-on is active, and the webhook subscription is active. **Webhook secret:** returned by `PUT /webhook`, re-issued by `POST /webhook/rotate`, and also visible in the developer portal (developers.wasync.app → API keys, and → Webhooks for app subscriptions). `GET /webhook` never returns it — it reports `secretSet` only. For OAuth app subscriptions the secret is minted at consent time and re-consent does NOT rotate it. **Webhooks fire for QR/WAPP connections only.** Deliveries come from the QR transport (`provider: "qr"`). Meta/WABA connections (`provider: "cloud_api"`, the official Cloud API) emit **no webhooks today** — for those, poll `GET /messages` for inbound and status, and `GET /connections/{id}` for connection health. Check `provider` before you design an event-driven flow around a number. Event catalog (four event types today; new types are announced on the changelog first — ignore unknown `event` values): - `message.received` — a WhatsApp message arrived on a granted connection. **Inbound only**; your own sends never fire this event. - `message.status` — the delivery state of one of **your outgoing** messages changed. Use it to render sent/delivered/read ticks (and surface send failures) in real time. - `connection.disconnected` — a connection stopped working and needs its owner to scan a fresh QR code; nothing sends or arrives on that number until they do. Fires **once per outage**, on the transition — not repeatedly while it stays broken. - `connection.connected` — a connection that had broken is working again. Fires once, and only for a connection you were already told had broken. > **Note:** subscriptions created **before Jul 24 2026** receive only `message.received`, and subscriptions created **before Aug 10 2026** do not receive the `connection.*` events. Re-consent (re-authorize the app) or ask support to add the missing events to your existing subscription. **Latency expectations (connection events).** A broken session is discovered by a status probe that runs every 5 minutes, so a disconnect that is immediately visible (auth lost / session gone) reaches you **within ~5 minutes**. When the session still looks recoverable, WASync first tries to heal it automatically and only gives up after repeated failed restarts — that path takes **~15–20 minutes** before `connection.disconnected` is delivered. Message events are dispatched as they happen, with no such delay. Do not build timeouts that assume a disconnect is reported instantly. `connection.disconnected` payload: ```json { "event": "connection.disconnected", "connection_id": "conn_8f2a…", "connection": { "id": "conn_8f2a…", "phone_number": "393331234567", "label": "Studio Rossi", "status": "disconnected", "needs_reconnect": true, "license_status": "active", "license_expires": "2027-08-08T09:00:00.000Z", "reason": "NEEDS_RECONNECT" }, "timestamp": 1786000000000 } ``` `connection.connected` carries the same envelope with `"event": "connection.connected"` and the recovered connection state. - **The QR code is deliberately not in the payload.** A WhatsApp QR is a scan-to-login credential — anyone who reads it can take over the session. The event tells you a re-scan is needed; fetch the QR over your authenticated `GET /connections/{id}` and show it to the number's owner. - `reason` is a short machine-readable hint for your logs (e.g. `NEEDS_RECONNECT` when auth was lost, `HEAL_GIVEUP` when automatic restarts failed). Treat it as advisory — new values can appear; the field that should drive your UI is `needs_reconnect`. - Both connection events are gated on a real state transition, so a flapping session does not produce a stream of alerts. When a message arrives, WASync POSTs: ```json { "event": "message.received", "connection_id": "conn_8f3a21", "message": { "id": "cmqj3k2ab0001xyz", "wa_id": "false_40700000000@c.us_3EB0A1B2C3", "from": "40700000000", "text": "Hello!", "media_url": null, "media_type": null, "timestamp": 1766138640000 } } ``` When one of your outgoing messages changes delivery state, WASync POSTs: ```json { "event": "message.status", "connection_id": "conn_8f3a21", "message": { "id": "cmqj3k2ab0001xyz", "wa_id": "true_40700000000@c.us_3EB0A1B2C3", "status": "read", "timestamp": 1766138641000 } } ``` `message.status` notes: `message.id` is WASync's stable id — the exact value `POST /messages` returned as `messageId` (and `GET /messages` returns as `id`). **Join on `message.id`, never on `wa_id`.** `wa_id` is WhatsApp's own id for the same message (`null` when unknown), carried for support/debugging only. `status` is one of `sent`, `delivered`, `read`, or `failed`. Only the meaningful transitions are delivered (`delivered`, `read`, `failed`); intermediate acks are not sent, and a message may skip straight to a later state. Ordering is not guaranteed — treat the states as a monotonic ladder (`sent` < `delivered` < `read`) and ignore any status that would move a message backwards. Payload notes (`message.received`): `message.id` is WASync's stable id, the same one `GET /messages` returns as `id` — **dedupe and join on it** (retries can deliver duplicates). `wa_id` is WhatsApp's own id (`null` when unknown) — support/debugging only, never a dedupe or join key. `from` is international digits, no `+`. `text` is null for media-only messages; `media_url`/`media_type` are null for text messages or when the file isn't publicly fetchable. `timestamp` is epoch **milliseconds** (a number, not an ISO string). Delivery & retry policy: - Success = any 2xx response; anything else (including redirects) is a failure. - 3 attempts per event: immediate, then after 500 ms, then after 1 s (exponential backoff). - Per-attempt timeout: 10 seconds — ACK fast (2xx), process async. - After the last failure the event is **dropped** — there is no long redelivery queue. Missed messages remain readable via `GET /messages`; poll to backfill after downtime. - Duplicates are possible — dedupe on `message.id`. Ordering is not guaranteed — order by `timestamp` if it matters. - Retries resend the identical body and signature. Verify every delivery. The header `X-WASync-Signature: sha256=` is `HMAC-SHA256(".", subscription_secret)` — compute it over the **raw** body (before JSON parsing) and compare in constant time. `X-WASync-Timestamp` is epoch ms; reject deliveries older than ~5 minutes to block replays: ```js 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=" const expected = "sha256=" + crypto.createHmac("sha256", secret) .update(`${ts}.${rawBody}`) .digest("hex"); return Boolean(sig) && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); } ``` ## Integration recipes ### Multi-tenant SaaS (one app, many customers) If you are building a SaaS product where each of your customers gets their own WhatsApp number: 1. Create **one API key** with `whatsapp.read`, `whatsapp.send`, `whatsapp.events` and `whatsapp.manage`. That is the entire onboarding — one workspace, one credential. 2. For each new customer/tenant, call `POST /connections` (with a `label` identifying the tenant). Each call returns a QR code that **that tenant scans with their own phone**. This includes the very first tenant: with a key there is no manual portal step to get the workspace started. Call `GET /account` first when you are near your quota — `slots.available` tells you whether the new connection will come up licensed. **Send an `Idempotency-Key` on every one of those creates.** A create consumes a paid slot, so a timeout followed by a retry gives you two connections and two slot charges for one tenant. Mint **one fresh UUID per tenant-onboarding attempt** (`crypto.randomUUID()`, generated immediately before the call) and reuse it only for that call's retries. Never share one key across tenants, across a process lifetime, per API key, per day, or as a constant — a key that coarse hands the SECOND tenant you onboard the FIRST tenant's connection. Keys are namespaced per workspace (so your key strings can never collide with another partner's) and expire 24 h after first use. Within that window: same key + same body → **200** with `Idempotency-Replayed: true` and a freshly fetched QR; same key + different body → **400** `idempotency_key_reuse`; first call still in flight → **409** `idempotency_in_progress` with `Retry-After: 1`, which you should honour and retry. 3. Call `PUT /webhook` once with your endpoint and store the secret it returns. 4. Route inbound webhooks by `connection_id` → tenant. The `connection_id` is a globally unique cuid, stable across restart / logout / re-scan. Only `DELETE /connections/{id}` retires it. 5. Subscribe to `connection.disconnected` / `connection.connected` so you learn a tenant's number went down before the tenant tells you — then re-render the QR from `GET /connections/{id}` (the event never carries it). Use `POST /connections/{id}/restart` to start a fresh pairing cycle, `POST /connections/{id}/logout` to drop credentials, `DELETE /connections/{id}` when the tenant leaves. 6. A freshly created connection is usable immediately: the key's connection list is resolved live on every request, so there is nothing to refresh and no re-consent step. Do **not** mint a key per tenant — one key covers the workspace, and per-tenant keys just multiply the secrets you have to rotate. (Building the same product on OAuth instead? Then it is one app + one workspace + one grant, and you still need one connection to exist before the grant can be issued — which is what the key path removes.) ### Reference reconciliation loop (recommended production pattern) Webhook delivery is at-most-3-attempts with no long queue. Build this polling loop alongside your webhook handler so missed events are always backfilled. The pattern below is what a production client uses at 45 s intervals and adds zero load on quiet systems. **Two rules this pattern lives or dies by** (both learned the hard way in production): 1. **Persist our `messageId` (the cuid) on EVERY match path — not only the fallback.** The `message.status` webhook joins on that id, so a row you matched by `waMessageId` but never linked will silently receive zero status updates: the update runs, matches nothing, and reports success. Link first, then apply status. 2. **Correlate through `waMessageId` when you have no cuid yet.** `GET /messages` returns both ids; an exact `waMessageId` join replaces text+time heuristics entirely. Use text+time only for rows created before you stored either id. ```typescript // Run every 45 s via setInterval or a cron job. // Only active when there is work to do: it scans connections that have had // outbound messages in a non-terminal state (pending/sent) in the last 24h. // A system with no recent outbound activity makes zero API calls. const STATUS_RANK: Record = { sent: 1, delivered: 2, read: 3, failed: 4 }; async function reconcileMessages(accessToken: string): Promise { // 1. Find connections that have outbound messages in a non-terminal state // from the last 24h. Replace with your own DB query. const activeConnectionIds: string[] = await db.getConnectionsWithPendingOutbound(); if (activeConnectionIds.length === 0) return; // nothing to do — zero API calls for (const connectionId of activeConnectionIds) { let cursor: string | undefined; do { const params = new URLSearchParams({ connectionId, limit: "50" }); if (cursor) params.set("cursor", cursor); const res = await fetch(`https://developers.wasync.app/api/v1/messages?${params}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) break; // skip on transient errors; next interval will retry const { messages, nextCursor, hasMore } = await res.json(); for (const msg of messages as Array<{ id: string; waMessageId: string | null; status: string; createdAt: string; direction: string; text?: string | null; }>) { if (msg.direction !== "outgoing") continue; // Match priority order: stored cuid → waMessageId → id → legacy (same // text + createdAt within ±3 min). Persist our cuid on first match so // later message.status webhooks join instantly. const stored = await db.findMessage({ orCuid: msg.id, orWaMessageId: msg.waMessageId ?? undefined, orLegacy: msg.text ? { text: msg.text, around: new Date(msg.createdAt), windowMs: 3 * 60_000 } : undefined, }); if (!stored) continue; // Persist our canonical cuid if we matched by fallback (makes future // message.status webhooks join in O(1) without scanning). if (stored.cuid !== msg.id) { await db.messages.update({ where: { id: stored.id }, data: { cuid: msg.id } }); } // Apply status through a monotonic ladder — never regress. // failed is only applied from pending/sent (terminal from the provider's // perspective; already-delivered messages don't revert to failed). const newRank = STATUS_RANK[msg.status] ?? 0; const currentRank = STATUS_RANK[stored.status] ?? 0; const isFailedFromTerminal = msg.status === "failed" && ["delivered", "read"].includes(stored.status); if (newRank > currentRank && !isFailedFromTerminal) { await db.messages.update({ where: { id: stored.id }, data: { status: msg.status } }); } } cursor = hasMore && nextCursor ? nextCursor : undefined; } while (cursor); } } ``` Key properties of this loop: - **Zero calls on a quiet system** — the guard on `activeConnectionIds` means no idle overhead. - **Match priority:** stored cuid (instant) → `waMessageId` → `id` → legacy text+time (fuzzy, ±3 min). - **Persist cuid on first match** — so the next `message.status` webhook joins without scanning. - **Monotonic ladder:** `sent` < `delivered` < `read`; `failed` only from `pending`/`sent`. - **45 s interval** is the proven production cadence — short enough to catch missed webhooks before a user notices; long enough that it is never the dominant API load. ### Handling QR/WAPP send latency ``` POST /messages → set HTTP timeout ≥ 180s → always include idempotencyKey → on 409 in_progress: wait Retry-After (or ~5s), retry with SAME key → on 429 rate_limited/warmup_limited: back off, retry with SAME key → on 5xx or timeout: retry with SAME key (safe — idempotent) → NEVER rotate the key on retry ``` ### On-401 token refresh (pseudocode) ``` function callApi(req): resp = http(req, token=access_token) if resp.status == 401: # Serialize: only one worker refreshes at a time acquire_mutex("token_refresh") new_tokens = POST /oauth/token (grant_type=refresh_token, ...) persist(new_tokens) # atomic — write refresh_token first release_mutex("token_refresh") resp = http(req, token=new_tokens.access_token) return resp ``` --- ## Prompt to hand to an AI coding agent ``` 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 (this is 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), whatsapp.manage (create/inspect/restart/logout/delete connections). 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 OAuth 2.0 — ONLY if this app is installed by OTHER WASync customers who must consent to their own numbers - Authorize: https://cloudapi.wasync.app/oauth/authorize Token: https://cloudapi.wasync.app/oauth/token (client_secret_post; Authorization Code + PKCE with S256 REQUIRED) - Discovery: https://cloudapi.wasync.app/.well-known/oauth-authorization-server - access_token TTL: 15 min (JWT). refresh_token TTL: 30 days, ROTATES on use. On 401: refresh once, retry. Serialize refreshes — never concurrent. - Consent: Bitrix24 portal admin (Settings → API & Agents, user code), or standalone developer at developers.wasync.app/authorize (auto-approved if the dev owns exactly 1 workspace with ≥1 connection). PROVISIONING FROM ZERO - With an API KEY: POST /connections works on an empty workspace — it creates the FIRST connection too. There is NO manual portal step. Create account → create key → POST /connections → render QR. - With OAuth only: a grant is issued over connections that ALREADY EXIST and POST /connections needs a grant, so one connection must exist first. This is the main reason to use a key. - POST /connections is standalone-workspace only: a Bitrix24 portal gets 403 forbidden_portal_kind. 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 one code on screen. Creates QR/WAPP numbers only (provider "qr"). SEND AN Idempotency-Key HEADER (optional, this endpoint ONLY; absent = old behaviour). A create consumes a paid SLOT, so a timeout + retry without it = a SECOND connection and a second slot bill. 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. NEVER one key per process, per API key, per day, or a constant — too coarse and the SECOND customer you onboard is handed the FIRST customer's connection. Keys are namespaced per workspace; TTL 24h from first use. Within the TTL: same key + same body → 200 + response header Idempotency-Replayed: true, SAME connection, nothing provisioned again; the qr is re-fetched fresh (codes rotate ~every 20s, a stale replayed QR would not scan) same key + different body → 400 idempotency_key_reuse first call still in flight → 409 idempotency_in_progress + Retry-After: 1 — honour it and retry key longer than 255 chars → 400 idempotency_key_invalid (rejected, never truncated; a blank/whitespace-only header is treated as absent) Body comparison is over a hash of the CANONICAL body — JSON key order does not matter. Response body shape is unchanged in all cases. - GET /connections/{id} → { connection: { id, status, phoneNumber, label, provider, licenseStatus, licenseExpires, qr, + deprecated snake_case aliases } } scope whatsapp.manage - POST /connections/{id}/restart → { ok, recreated } — re-pair a broken session, then poll + re-render the QR - POST /connections/{id}/logout → { ok: true } — clears session credentials; restart + re-scan to come back - DELETE /connections/{id} → { ok: true } — permanent, cannot be undone - GET /account (scope whatsapp.read) → { slots: { total, used, available, nextExpiry }, connections: { total, connected, needsReconnect } } Call it BEFORE provisioning: slots.available = how many more connections can come up licensed; slots.nextExpiry = renew-before date; connections.needsReconnect = numbers awaiting a fresh QR scan. - 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. 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). A client-side timeout is NOT a failure: retry the same key to learn the outcome. - 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). "messageId" = WASync's cuid of an INCOMING message (not waMessageId). Response { ok: true, marked }. Call it whenever an operator opens a conversation in YOUR UI — otherwise the customer never sees blue ticks. Idempotent: no idempotencyKey, no rate limit, safe to retry. Message ids — TWO ids, do not mix them up - WASync id (cuid): "messageId" on POST /messages, "id" on GET /messages, "message.id" in BOTH message webhook payloads. This is the ONLY joinable id — store it on send and match message.status events against it. - WhatsApp id: "waMessageId" (REST), "wa_id" (webhooks). May be null. Support/debugging only — never join or dedupe on it. Multi-tenant: ONE api key + one workspace. POST /connections per tenant (including the first one). Route webhooks by connection_id (stable cuid). New connections usable immediately — the key's connection list is resolved live, nothing to refresh. Check GET /account for a free slot before each create. 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) - SELF-SERVE with an API key (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). - Third-party OAuth apps instead set webhook_url on the app and request the whatsapp.events scope. - FOUR event types: message.received an inbound WhatsApp message arrived on a granted connection (inbound only) message.status delivery state of one of YOUR outgoing messages changed (sent/delivered/read/failed) connection.disconnected a connection broke 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 - QR CONNECTIONS ONLY: webhooks fire for provider "qr" (QR/WAPP). Meta/WABA connections (provider "cloud_api", Cloud API) emit NO webhooks today — poll GET /messages and GET /connections/{id} for those. - Latency: a disconnect is detected by a 5-minute status probe, so expect it within ~5 min; when the session looks recoverable WASync retries an automatic heal first, so that path takes ~15-20 min before connection.disconnected arrives. Do not assume instant disconnect reporting. - 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 (it is a scan-to-login credential) — fetch it from GET /connections/{id}. "reason" is advisory (e.g. NEEDS_RECONNECT, HEAL_GIVEUP); drive UI off needs_reconnect. - Webhook secret: from PUT /webhook (or POST /webhook/rotate); also visible at developers.wasync.app → API keys. For OAuth app subscriptions: developers.wasync.app → Webhooks (does not rotate on re-consent). - Verify header X-WASync-Signature ("sha256=" + hex): HMAC-SHA256 over the string (X-WASync-Timestamp + "." + raw_request_body), keyed with the subscription 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, not a WASync key). - Status ladder: sent → delivered → read (terminal: failed). Apply monotonically — never regress a status (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 to backfill anything your webhook missed. Webhooks = fast path; polling = guarantee. Token refresh discipline - access_token TTL: 15 min. refresh_token TTL: 30 days, ROTATES on use — the old token dies immediately. - Persist the NEW refresh token atomically (write before returning). Serialize across workers — a concurrent refresh from a second worker will use an invalidated token and log the user out. 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. 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 the app is meant to be installed by other WASync customers. ``` ## Generate your own SDK The OpenAPI spec is served CORS-open at https://developers.wasync.app/openapi.json — point any generator at it: ``` npx @openapitools/openapi-generator-cli generate \ -i https://developers.wasync.app/openapi.json \ -g typescript-fetch -o ./wasync-client ``` (or `-g python`, `-g go`, `-g php`, …) ## Links - Quickstart: https://developers.wasync.app/docs - OpenAPI: https://developers.wasync.app/openapi.json - Changelog: https://developers.wasync.app/changelog - Developer portal: https://developers.wasync.app - Product site: https://wasync.app