{
  "openapi": "3.0.3",
  "info": {
    "title": "WASync WhatsApp Platform API",
    "version": "1.0.0",
    "description": "Send and receive WhatsApp from your app or AI agent — beyond Bitrix24.\n\nThe same WhatsApp connections WASync runs inside Bitrix24 CRM are exposed here as a REST API (and a remote MCP server at `https://cloudapi.wasync.app/api/mcp`).\n\n**Auth — start with an API key.** Create one at https://developers.wasync.app/keys and send it as `Authorization: Bearer wsk_live_…`. That is the whole setup for a partner automating their OWN account: no redirect URI, no PKCE, no consent screen, no token refresh. Keys carry scopes and an optional IP allowlist, and are revocable from the same page.\n\n**OAuth 2.0 is the other path**, for an app that OTHER WASync customers install: Authorization Code with **PKCE (S256 required)**, token endpoint `client_secret_post`, discovery at `https://cloudapi.wasync.app/.well-known/oauth-authorization-server`. Use it when a third party must consent to connections they own; use a key otherwise. Both credential types are accepted on every endpoint in this spec, in the same `Authorization: Bearer` header.\n\n**Access gate:** only connections with an active licence / per-connection API add-on are visible and sendable. Pricing and activation: https://developers.wasync.app/billing\n\n**Base URL.** The canonical host is `https://developers.wasync.app/api/v1`. The older `https://cloudapi.wasync.app/api/v1` is a permanent alias for the same API and remains valid indefinitely — existing integrations pointed at it need no change. (The OAuth endpoints, the `/.well-known/…` discovery documents and the MCP server URL are protocol identifiers already registered by clients: they stay on `cloudapi.wasync.app` and are unchanged.)\n\n**Provisioning:** `POST /connections` (create + initial QR) → `GET /connections/{id}` (poll status/QR) → `POST /connections/{id}/pairing-code` (an 8-character code the customer types into WhatsApp instead of scanning — for the very common case where the phone running WhatsApp is the same device your flow is open on; **a code is not always available, so keep the QR flow as a fallback**) → `POST /connections/{id}/restart` (re-pair a broken session) → `POST /connections/{id}/logout` (clear credentials) → `DELETE /connections/{id}` (permanent). With an API key you can create a workspace's very first connection over the API — nothing has to be clicked in a portal UI first. `GET /account` reports the slot quota before you provision.\n\n**Webhooks:** set your endpoint with `PUT /webhook` and you get a signing secret back; rotate it with `POST /webhook/rotate`. Four event types are delivered: `message.received`, `message.status`, `connection.disconnected`, `connection.connected`. They fire for **QR/WAPP connections (`provider: qr`) only** — Meta/WABA connections (`provider: cloud_api`) emit no webhooks today; poll `GET /messages` and `GET /connections/{id}` for those. Disconnect detection is typically **≤ 5 minutes** (status probe) and can take **~15–20 minutes** on the restart-failure path. Full contract: https://developers.wasync.app/docs/webhooks\n\nQuickstart: https://developers.wasync.app/docs",
    "contact": {
      "name": "WASync Developers",
      "url": "https://developers.wasync.app/docs"
    }
  },
  "servers": [
    {
      "url": "https://developers.wasync.app/api/v1",
      "description": "Production REST v1 (canonical host)"
    },
    {
      "url": "https://cloudapi.wasync.app/api/v1",
      "description": "Production REST v1 — legacy alias. Identical API, kept working indefinitely; existing integrations need no change."
    }
  ],
  "security": [
    {
      "apiKey": []
    },
    {
      "oauth2": [
        "whatsapp.read"
      ]
    }
  ],
  "tags": [
    {
      "name": "Connections",
      "description": "Discover and manage the WhatsApp connections an access token may use."
    },
    {
      "name": "Messages",
      "description": "Read recent messages and send WhatsApp messages (text, images, video, audio/voice notes, documents)."
    },
    {
      "name": "Account",
      "description": "Slot quota and connection health for the token's workspace — check before provisioning another connection."
    },
    {
      "name": "Webhook",
      "description": "Point WASync at your endpoint and manage the signing secret. Self-service — no app, no consent step."
    }
  ],
  "paths": {
    "/connections": {
      "get": {
        "tags": [
          "Connections"
        ],
        "summary": "List authorized connections",
        "description": "Returns the WhatsApp connections this access token may use. Only connections with an active API add-on appear; an expired add-on hides the connection. Call this first to discover which `id` to send from or read.",
        "operationId": "listConnections",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The grant's connections (possibly empty).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ConnectionsResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Create a new connection",
        "description": "Provisions a new WhatsApp connection inside this grant. The core starts a QR pairing session and returns an initial QR code. Render `qr` (a data-URI PNG) and poll `GET /connections/{id}` every 3–5 s until `status` is `\"connected\"`, re-rendering the `qr` from every response. **WhatsApp rotates the pairing code roughly every 20 seconds**, so the `qr` in this create response is stale within seconds: a QR left on screen simply never pairs, and nothing surfaces an error to explain why. Do not cache it, and do not treat the create response as the pairing code.\n\n**With an API key there is no bootstrap step:** a key is issued against your workspace directly, so this endpoint creates the FIRST connection as readily as the tenth — nothing has to be clicked in a portal UI first. (An OAuth grant, by contrast, can only be issued over connections that already exist, so an OAuth-only integration still needs one connection to exist before it can create more. That is the reason to start with a key.) Standalone workspaces only — a Bitrix24-owned portal is rejected with 403 `forbidden_portal_kind`. Check `GET /account` first: creating past your slot quota leaves the new connection without an active licence. Connections created here are QR/WAPP (`provider: qr`); WABA numbers are onboarded through Meta embedded signup, not this endpoint.\n\n**Idempotency (optional).** Creating a connection provisions a WhatsApp session and consumes a paid SLOT at pairing time, so an HTTP client that times out and retries would otherwise create a SECOND connection — and you would silently pay for two slots for one customer. Send an optional `Idempotency-Key` header to make the retry safe: within 24 h the same key + the same body returns the SAME connection with **200** and `Idempotency-Replayed: true` instead of provisioning again; the same key with a DIFFERENT body is rejected with 400 `idempotency_key_reuse`; and while the first request is still in flight the retry gets 409 `idempotency_in_progress` with `Retry-After: 1`. Generate ONE key per customer-onboarding ATTEMPT (a fresh UUID minted right before the call, reused only by that call's retries) — never one per process, per API key, per day, or a constant. Omit the header and behaviour is exactly as before. This is the only endpoint that supports it.",
        "operationId": "createConnection",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.manage"
            ]
          }
        ],
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Optional. Makes a retried create safe: a connection consumes a paid slot, so a timed-out retry without this header creates a second connection you pay for twice.\n\nFour cases, all scoped to 24 h from the first request that used the key:\n1. New key → the connection is provisioned as normal → 201 Created.\n2. Same key, same request body → the SAME connection is returned, nothing is provisioned again, 200 OK plus the response header `Idempotency-Replayed: true`. The `qr` in that response is FRESHLY fetched, not the code stored at first creation — WhatsApp rotates pairing codes roughly every 20 seconds, so a replayed stale QR would simply fail to scan.\n3. Same key, DIFFERENT request body → 400 `idempotency_key_reuse`.\n4. Same key while the first request is still in flight → 409 `idempotency_in_progress` with `Retry-After: 1`. Honour it and retry.\n\nThe body comparison is over a hash of the CANONICAL request body, so JSON key order does not matter. Keys are namespaced per WORKSPACE — two partners using the same literal key string never collide, and a key from one workspace is invisible to another. After 24 h the key is free to be reused for a different request.\n\nAny opaque string up to 255 characters; longer is rejected with 400 `idempotency_key_invalid` (never truncated), and a blank/whitespace-only header is treated as absent.\n\n**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.",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "example": "3f2a9c14-8d5e-4b7a-9f01-2c6d8e4a7b13"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateConnectionRequest"
              },
              "example": {
                "label": "Support line"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Idempotent replay — this `Idempotency-Key` was already used within the last 24 h with the same request body, so the connection created by the first request is returned and nothing was provisioned again. The response body shape is identical to the 201; the `qr` is freshly fetched (pairing codes rotate roughly every 20 s), not the code stored at first creation.",
            "headers": {
              "Idempotency-Replayed": {
                "description": "Present and set to `true` only on a replayed response, i.e. when an existing connection was returned instead of a new one being provisioned.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "true"
                  ]
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateConnectionResponse"
                },
                "example": {
                  "connection": {
                    "id": "conn_8f3a21",
                    "status": "connecting",
                    "qr": "data:image/png;base64,…"
                  }
                }
              }
            }
          },
          "201": {
            "description": "Connection created and QR pairing initiated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateConnectionResponse"
                },
                "example": {
                  "connection": {
                    "id": "conn_8f3a21",
                    "status": "connecting",
                    "qr": "data:image/png;base64,…"
                  }
                }
              }
            }
          },
          "400": {
            "description": "The `Idempotency-Key` header could not be honoured.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "idempotency_key_reuse": {
                    "summary": "Same key, different request body, within the 24 h TTL",
                    "value": {
                      "error": "idempotency_key_reuse"
                    }
                  },
                  "idempotency_key_invalid": {
                    "summary": "Key longer than 255 characters — rejected, never truncated",
                    "value": {
                      "error": "idempotency_key_invalid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Scope missing or portal not allowed to create connections via the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "value": {
                      "error": "insufficient_scope"
                    }
                  },
                  "forbidden_portal_kind": {
                    "value": {
                      "error": "forbidden_portal_kind"
                    }
                  },
                  "invalid_grant": {
                    "value": {
                      "error": "invalid_grant"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "The first request that used this `Idempotency-Key` is still in flight. Honour `Retry-After` and retry with the SAME key.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying with the same key. Always `1` on this response.",
                "schema": {
                  "type": "integer",
                  "example": 1
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "idempotency_in_progress"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/connections/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Connection id (from `POST /connections` or `GET /connections`).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Connections"
        ],
        "summary": "Get connection status",
        "description": "Returns the current status and metadata of a single connection. Poll every 3–5 s during QR pairing — `status` becomes `\"connected\"` once the user scans the code. 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 a QR displayed for longer than that will not scan.",
        "operationId": "getConnection",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.manage"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Connection detail.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ConnectionDetailResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "delete": {
        "tags": [
          "Connections"
        ],
        "summary": "Delete a connection",
        "description": "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.",
        "operationId": "deleteConnection",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.manage"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Connection deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OkResponse"
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/connections/{id}/restart": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Connection id.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Restart a connection session",
        "description": "Restarts the WhatsApp adapter session without deleting the connection record. After a restart, poll `GET /connections/{id}` and re-render the QR code if `status` returns to `\"connecting\"`.",
        "operationId": "restartConnection",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.manage"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Session restart initiated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RestartConnectionResponse"
                },
                "example": {
                  "ok": true,
                  "recreated": false
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/connections/{id}/logout": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Connection id.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Log out a connection",
        "description": "Logs the WhatsApp session out and clears session credentials. The connection record is preserved. To reconnect, call `POST /connections/{id}/restart` and re-scan the new QR code.",
        "operationId": "logoutConnection",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.manage"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Session logged out.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OkResponse"
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/connections/{id}/pairing-code": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Connection id.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Connections"
        ],
        "summary": "Request a pairing code (link with phone number)",
        "description": "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 you return.\n\n**Why you want it:** a QR needs two devices — a screen showing the code and a phone photographing it. A large share of real onboardings happen on ONE device: the customer completes your flow on the same phone that runs WhatsApp, and cannot photograph its own screen. For those customers the QR path is not inconvenient, it is impossible.\n\n**A pairing code is NOT always available.** The engine can decline to issue one, and that is an ordinary outcome rather than an outage — it answers **502 `pairing_code_unavailable`**. **Your application must keep the QR flow on screen as a fallback.** An integration that offers only the pairing code will strand customers with no way to connect at all.\n\n**Re-requestable.** Calling this again on the same connection returns a NEW code and does not rebuild the session, so a \"send me another code\" button is safe. It is capped: **5 requests per connection per 15 minutes**, then **429 `rate_limited`** with `Retry-After`. The cap is not bureaucracy — repeatedly requesting codes for one number is exactly the pattern WhatsApp's anti-abuse systems act on, and the number at risk is your customer's.\n\n**Nothing extra is consumed.** This does not provision anything: the connection, its paid slot and its 7-day trial behave exactly as with QR pairing. The licence is claimed when the number actually pairs, whichever way it paired.\n\n**Status monitoring is unchanged.** Poll `GET /connections/{id}` until `status` is `\"connected\"`, and/or listen for the `connection.connected` webhook. There is no separate \"code accepted\" event.\n\nQR/WAPP connections only (`provider: qr`). Meta/WABA numbers (`provider: cloud_api`) are onboarded through Meta embedded signup and answer 409 `not_supported_for_provider`.",
        "operationId": "requestPairingCode",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.manage"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PairingCodeRequest"
              },
              "example": {
                "phoneNumber": "40740267964"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A pairing code was issued. It is short-lived — present it immediately, and keep the QR visible as the fallback.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PairingCodeResponse"
                },
                "example": {
                  "code": "7S59-1KZP"
                }
              }
            }
          },
          "400": {
            "description": "The request could not be honoured.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid_phone_number": {
                    "summary": "Not E.164 digits without the leading `+`",
                    "value": {
                      "error": "invalid_phone_number"
                    }
                  },
                  "not_a_qr_connection": {
                    "summary": "The connection is not a QR/WAPP session",
                    "value": {
                      "error": "not_a_qr_connection"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The connection cannot take a pairing code.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "already_connected": {
                    "summary": "The session is live — there is nothing to pair. Issuing a code anyway could displace the working session.",
                    "value": {
                      "error": "already_connected"
                    }
                  },
                  "not_supported_for_provider": {
                    "summary": "Meta/WABA connection. Pairing codes are a QR-tier concept and never apply — permanent, do not retry.",
                    "value": {
                      "error": "not_supported_for_provider"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Too many pairing codes for this connection (5 per 15 minutes). Honour `Retry-After`.",
            "headers": {
              "Retry-After": {
                "description": "Seconds until the window rolls over.",
                "schema": {
                  "type": "integer",
                  "example": 742
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "rate_limited"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "502": {
            "description": "**No code could be issued right now** — an expected outcome, not a server fault, and the reason the QR flow must remain available in your UI. Fall back to the QR from `GET /connections/{id}`; do not retry in a loop.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "pairing_code_unavailable"
                }
              }
            }
          }
        }
      }
    },
    "/account": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Slot quota and connection health",
        "description": "Answers \"can I onboard another connection right now?\" before you take the order. Everything is scoped to the token's workspace — there is no way to ask about another one.\n\n`slots.available` is what `POST /connections` can still consume with an active licence; `slots.nextExpiry` is the date you must renew before, so a slot never expires underneath a customer who already paid. `connections.needsReconnect` counts numbers waiting for their owner to scan a fresh QR (the same state that fires the `connection.disconnected` webhook).\n\n**A slot is capacity, not a phone number** and not something you bind to a customer: it attaches when a number actually pairs, and deleting a connection returns its slot to the pool carrying whatever time is left on it, ready for the next number you connect. Map WASync's connection `id` (a stable cuid, echoed by every REST response and by `connection_id` on every webhook) to your own customer record — we mint the id, you map it. Do not try to track slots per customer; track connections.",
        "operationId": "getAccount",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Slot quota and connection health for the token's workspace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccountResponse"
                },
                "example": {
                  "slots": {
                    "total": 10,
                    "used": 7,
                    "available": 3,
                    "nextExpiry": "2026-09-01T00:00:00.000Z"
                  },
                  "connections": {
                    "total": 7,
                    "connected": 6,
                    "needsReconnect": 1
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/messages": {
      "get": {
        "tags": [
          "Messages"
        ],
        "summary": "List recent messages",
        "description": "Returns recent messages (newest first) scoped to the token's connections. Cursor-paginated.",
        "operationId": "listMessages",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "connectionId",
            "in": "query",
            "required": false,
            "description": "Limit to a single connection id (must be inside the grant; otherwise an empty page is returned).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque pagination cursor from a previous response's `nextCursor`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessagesResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Missing scope (`insufficient_scope`), or the requested `connectionId` has no effective API add-on (`addon_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "addon_required": {
                    "value": {
                      "error": "addon_required"
                    }
                  },
                  "insufficient_scope": {
                    "value": {
                      "error": "insufficient_scope"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "tags": [
          "Messages"
        ],
        "summary": "Send a WhatsApp message (text or media)",
        "description": "Sends a message from one of the authorized connections. Supply either `text` (text message) or `media` (image, video, audio, document, voice note) — not both. Pass an `idempotencyKey` on text sends to make retries safe.",
        "operationId": "sendMessage",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.send"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendMessageRequest"
              },
              "examples": {
                "text": {
                  "summary": "Text message",
                  "value": {
                    "connectionId": "conn_8f3a21",
                    "to": "40700000000",
                    "text": "Hi from WASync 👋",
                    "idempotencyKey": "order-1234-confirm"
                  }
                },
                "image": {
                  "summary": "Image message",
                  "value": {
                    "connectionId": "conn_8f3a21",
                    "to": "40700000000",
                    "media": {
                      "filename": "photo.jpg",
                      "mimetype": "image/jpeg",
                      "data": "<base64-encoded-bytes>",
                      "caption": "Here is the photo!"
                    }
                  }
                },
                "voice_note": {
                  "summary": "Voice note (ogg/opus)",
                  "value": {
                    "connectionId": "conn_8f3a21",
                    "to": "40700000000",
                    "media": {
                      "filename": "voice-note.ogg",
                      "mimetype": "audio/ogg",
                      "data": "<base64-encoded-ogg-opus-bytes>"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted by the provider.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SendMessageResponse"
                },
                "examples": {
                  "text": {
                    "summary": "Text send",
                    "value": {
                      "messageId": "cmqj3k2ab0001xyz",
                      "waMessageId": "true_40700000000@c.us_3EB0A1B2C3",
                      "status": "sent"
                    }
                  },
                  "media": {
                    "summary": "Media send",
                    "value": {
                      "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"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Bad request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid_request": {
                    "summary": "Missing/invalid field",
                    "value": {
                      "error": "invalid_request"
                    }
                  },
                  "invalid_to": {
                    "summary": "Recipient could not be normalized",
                    "value": {
                      "error": "invalid_to"
                    }
                  },
                  "text_too_long": {
                    "summary": "Text exceeds 4096 chars",
                    "value": {
                      "error": "text_too_long"
                    }
                  },
                  "text_or_media": {
                    "summary": "Both text and media supplied",
                    "value": {
                      "error": "text_or_media"
                    }
                  },
                  "invalid_media": {
                    "summary": "Media object missing required fields",
                    "value": {
                      "error": "invalid_media"
                    }
                  },
                  "file_too_large": {
                    "summary": "Base64 data exceeds the 16 MB limit",
                    "value": {
                      "error": "file_too_large"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The send is not permitted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "forbidden_connection": {
                    "summary": "connectionId not in the grant / not this portal's",
                    "value": {
                      "error": "forbidden_connection"
                    }
                  },
                  "license_inactive": {
                    "summary": "Connection license expired/inactive",
                    "value": {
                      "error": "license_inactive"
                    }
                  },
                  "addon_required": {
                    "summary": "No effective per-connection API add-on",
                    "value": {
                      "error": "addon_required"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Temporarily not sendable, or a duplicate request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "connection_unavailable": {
                    "summary": "Session not live (e.g. logged out / banned)",
                    "value": {
                      "error": "connection_unavailable"
                    }
                  },
                  "in_progress": {
                    "summary": "Same idempotencyKey is still being processed",
                    "value": {
                      "error": "in_progress"
                    }
                  },
                  "replayed": {
                    "summary": "Idempotency nonce already claimed",
                    "value": {
                      "error": "replayed"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Rate limited — back off and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "rate_limited"
                }
              }
            }
          },
          "502": {
            "description": "The upstream WhatsApp provider failed to accept the message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "send_failed"
                }
              }
            }
          }
        }
      }
    },
    "/messages/read": {
      "post": {
        "tags": [
          "Messages"
        ],
        "summary": "Mark inbound messages as read (blue ticks)",
        "description": "Sends a WhatsApp **read receipt** so the customer sees the blue ticks on the messages they sent you.\n\nCall this the moment one of your operators opens the conversation in your own UI. If your agents read WhatsApp inside your CRM, nobody ever opens the chat in WhatsApp — the customer's messages stay on double grey ticks and the conversation feels ignored.\n\n**Scope is `whatsapp.send`, not `whatsapp.read`:** marking read TRANSMITS a receipt from your number to the customer's device, so it is an outbound action. Any client that can already send can mark read — no new scope, no re-consent.\n\nSupply `connectionId` plus at least one of `phone` / `messageId`. `messageId` is WASync's stable id (a cuid) of an INCOMING message — not `waMessageId`. When both are given, `messageId` wins.\n\nIdempotent by nature: re-marking an already-read conversation is a no-op, so no `idempotencyKey` is accepted and no rate limit applies. Retry freely.\n\nConnection differences: WAPP/QR connections mark the whole chat; WABA (official API) connections can only mark one specific message, so a `phone`-only request resolves your most recent inbound message from that number automatically.",
        "operationId": "markMessagesRead",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.send"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MarkReadRequest"
              },
              "examples": {
                "by_phone": {
                  "summary": "Mark the whole conversation with a number read",
                  "value": {
                    "connectionId": "conn_8f3a21",
                    "phone": "40700000000"
                  }
                },
                "by_message": {
                  "summary": "Mark up to a specific inbound message",
                  "value": {
                    "connectionId": "conn_8f3a21",
                    "messageId": "cmqj3k2ab0001xyz"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The read receipt was accepted by the provider.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarkReadResponse"
                },
                "example": {
                  "ok": true,
                  "marked": 3
                }
              }
            }
          },
          "400": {
            "description": "Invalid request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid_request": {
                    "summary": "Neither phone nor messageId was supplied",
                    "value": {
                      "error": "invalid_request",
                      "field": "phone|messageId"
                    }
                  },
                  "invalid_phone": {
                    "summary": "phone could not be normalized",
                    "value": {
                      "error": "invalid_phone"
                    }
                  },
                  "no_inbound_message": {
                    "summary": "WABA connection, phone only, and no inbound message exists to acknowledge",
                    "value": {
                      "error": "no_inbound_message"
                    }
                  },
                  "message_id_required": {
                    "summary": "WABA connection and the target message carries no WhatsApp id",
                    "value": {
                      "error": "message_id_required"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not permitted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "summary": "Token lacks whatsapp.send",
                    "value": {
                      "error": "insufficient_scope"
                    }
                  },
                  "forbidden_connection": {
                    "summary": "connectionId not in the grant / not this portal's",
                    "value": {
                      "error": "forbidden_connection"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "`messageId` is not an incoming message on this connection.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "message_not_found"
                }
              }
            }
          },
          "409": {
            "description": "The WhatsApp session is not currently live.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "connection_unavailable"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "502": {
            "description": "The upstream WhatsApp provider refused the read receipt. Nothing was faked — retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "mark_read_failed"
                }
              }
            }
          }
        }
      }
    },
    "/webhook": {
      "get": {
        "tags": [
          "Webhook"
        ],
        "summary": "Read the webhook configuration",
        "description": "Returns the endpoint URL, the event types delivered to it, and whether a signing secret is set.\n\n**The secret is never returned here.** A read is routinely logged, cached and pasted into support tickets; a secret that leaks through it 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.",
        "operationId": "getWebhook",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.events"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The workspace's webhook configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookConfig"
                },
                "example": {
                  "url": "https://example.com/wasync/webhook",
                  "events": [
                    "message.received",
                    "message.status",
                    "connection.disconnected",
                    "connection.connected"
                  ],
                  "secretSet": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "put": {
        "tags": [
          "Webhook"
        ],
        "summary": "Set the webhook endpoint",
        "description": "Points WASync at your URL and returns the signing secret for it. Idempotent — calling it again with a different URL moves the endpoint.\n\nThe URL must be HTTPS on a publicly reachable host; http, localhost and private ranges are rejected with 400 `invalid_webhook`.\n\nSignature scheme (unchanged): every delivery carries `X-WASync-Signature: sha256=<hex>` = HMAC-SHA256 of `${X-WASync-Timestamp}.${rawBody}` keyed with this secret. `X-WASync-Timestamp` is epoch milliseconds; reject anything older than 5 minutes. Verify over the RAW body, before parsing. Full contract: https://developers.wasync.app/docs/webhooks",
        "operationId": "setWebhook",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.events"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetWebhookRequest"
              },
              "example": {
                "url": "https://example.com/wasync/webhook"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Endpoint set. The response carries the signing secret — store it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SetWebhookResponse"
                },
                "example": {
                  "url": "https://example.com/wasync/webhook",
                  "events": [
                    "message.received",
                    "message.status",
                    "connection.disconnected",
                    "connection.connected"
                  ],
                  "secret": "whsec_3f9a…"
                }
              }
            }
          },
          "400": {
            "description": "The URL is missing, or not HTTPS on a public host.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid_request": {
                    "value": {
                      "error": "invalid_request",
                      "field": "url"
                    }
                  },
                  "invalid_webhook": {
                    "value": {
                      "error": "invalid_webhook"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/webhook/rotate": {
      "post": {
        "tags": [
          "Webhook"
        ],
        "summary": "Rotate the webhook signing secret",
        "description": "Issues a new signing secret and invalidates the old one immediately — there is no overlap window. Deploy the new secret to your receiver promptly; the retry policy (3 attempts with exponential backoff) covers a rollout measured in seconds, not hours.\n\nSignature scheme (unchanged): every delivery carries `X-WASync-Signature: sha256=<hex>` = HMAC-SHA256 of `${X-WASync-Timestamp}.${rawBody}` keyed with this secret. `X-WASync-Timestamp` is epoch milliseconds; reject anything older than 5 minutes. Verify over the RAW body, before parsing. Full contract: https://developers.wasync.app/docs/webhooks",
        "operationId": "rotateWebhookSecret",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "whatsapp.events"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "A new signing secret.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RotateWebhookResponse"
                },
                "example": {
                  "secret": "whsec_7c21…"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "wsk_live_*",
        "description": "**The default way to authenticate.** Create a key at https://developers.wasync.app/keys and send it as `Authorization: Bearer wsk_live_…`.\n\nThe key is shown once at creation and stored only as a hash — lose it and you revoke it and create another. Each key carries its own scopes (`whatsapp.read`, `whatsapp.send`, `whatsapp.events`, `whatsapp.manage`) and an optional **IP allowlist**.\n\nErrors: an unknown or revoked key returns **401 `invalid_key`**; a key whose IP allowlist does not cover the calling address returns **403 `ip_not_allowed`** (a distinct code on purpose — the key is fine, the address is not). Both carry a `WWW-Authenticate: Bearer` challenge. Scopes and the connection list are resolved live on every request, so revoking a key or narrowing it takes effect on the next call."
      },
      "oauth2": {
        "type": "oauth2",
        "description": "OAuth 2.0 Authorization Code flow — **for apps that other WASync customers install**, where a third party must consent to connections they own. If you are automating your own account, use an API key instead (see `apiKey`).\n\n**PKCE with `S256` is required.** The token endpoint uses **`client_secret_post`** (send `client_id` + `client_secret` + `code_verifier` in the form body). Consent happens either in the connection owner's Bitrix portal (Settings → \"API & Agents\": the admin enters the short code shown during authorization and picks the connections + scopes) or, for standalone workspaces, at https://developers.wasync.app/authorize.\n\n`access_token` TTL is 15 minutes; `refresh_token` TTL is 30 days and ROTATES on use — persist the new one atomically and never refresh concurrently.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://cloudapi.wasync.app/oauth/authorize",
            "tokenUrl": "https://cloudapi.wasync.app/oauth/token",
            "refreshUrl": "https://cloudapi.wasync.app/oauth/token",
            "scopes": {
              "whatsapp.read": "Read connections and messages",
              "whatsapp.send": "Send WhatsApp messages (text and media)",
              "whatsapp.events": "Receive incoming messages via webhooks",
              "whatsapp.manage": "Create, inspect, restart, log out, and delete WhatsApp connections"
            }
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid credential. Carries a `WWW-Authenticate: Bearer` challenge.\n\n`invalid_key` — the API key is unknown or has been revoked (create a new one at https://developers.wasync.app/keys).\n`invalid_token` — the OAuth access token is expired or malformed (refresh once, then retry).\n`invalid_grant` — the OAuth grant was revoked; re-authorize from scratch.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missing_token": {
                "value": {
                  "error": "missing_token"
                }
              },
              "invalid_key": {
                "value": {
                  "error": "invalid_key"
                }
              },
              "invalid_token": {
                "value": {
                  "error": "invalid_token"
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "`insufficient_scope` — the credential lacks the scope this operation requires.\n`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 on https://developers.wasync.app/keys, which is reachable from anywhere because it is authenticated by your account and not by the key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "insufficient_scope": {
                "value": {
                  "error": "insufficient_scope"
                }
              },
              "ip_not_allowed": {
                "value": {
                  "error": "ip_not_allowed"
                }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "The resource does not exist or is outside this grant's scope.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "connection_not_found"
            }
          }
        }
      },
      "ServerError": {
        "description": "Unexpected server error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "server_error"
            }
          }
        }
      }
    },
    "schemas": {
      "ConnectionsResponse": {
        "type": "object",
        "required": [
          "connections"
        ],
        "properties": {
          "connections": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Connection"
            }
          }
        }
      },
      "CreateConnectionRequest": {
        "type": "object",
        "properties": {
          "label": {
            "type": "string",
            "description": "Optional human-readable label for the connection.",
            "example": "Support line"
          }
        }
      },
      "CreateConnectionResponse": {
        "type": "object",
        "required": [
          "connection"
        ],
        "properties": {
          "connection": {
            "type": "object",
            "required": [
              "id",
              "status"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "New connection id.",
                "example": "conn_8f3a21"
              },
              "status": {
                "type": "string",
                "description": "Always `\"connecting\"` immediately after creation.",
                "example": "connecting"
              },
              "qr": {
                "type": "string",
                "nullable": true,
                "description": "Data-URI PNG of the QR code to scan in WhatsApp → Linked Devices. Valid for seconds only — WhatsApp rotates the pairing code roughly every 20 s, so re-render it from each `GET /connections/{id}` poll instead of keeping this one on screen."
              }
            }
          }
        }
      },
      "ConnectionDetailResponse": {
        "type": "object",
        "required": [
          "connection"
        ],
        "description": "Canonical keys are camelCase. Deprecated snake_case aliases (`phone`, `license_status`, `license_expires`) remain present for backward compatibility.",
        "properties": {
          "connection": {
            "type": "object",
            "required": [
              "id",
              "status"
            ],
            "properties": {
              "id": {
                "type": "string",
                "example": "conn_8f3a21"
              },
              "status": {
                "type": "string",
                "description": "Session status, e.g. `connecting` / `connected` / `disconnected`.",
                "example": "connected"
              },
              "phoneNumber": {
                "type": "string",
                "nullable": true,
                "description": "The connection's own WhatsApp number (intl digits, no `+`). Null until paired. Canonical camelCase key.",
                "example": "40700000000"
              },
              "label": {
                "type": "string",
                "nullable": true,
                "description": "Human-readable label.",
                "example": "Support line"
              },
              "provider": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "qr",
                  "cloud_api"
                ],
                "description": "WhatsApp transport: `qr` (WAPP/QR, no Meta account needed) or `cloud_api` (WABA, official Cloud API)."
              },
              "licenseStatus": {
                "type": "string",
                "nullable": true,
                "description": "WASync license status, e.g. `trial` / `active` / `expired`. Canonical camelCase key.",
                "example": "active"
              },
              "licenseExpires": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "ISO 8601 expiry timestamp of the WASync license. Canonical camelCase key."
              },
              "qr": {
                "type": "string",
                "nullable": true,
                "description": "Data-URI PNG of the current QR code. Non-null (and refreshing) while `status` is `\"connecting\"`; null once connected."
              },
              "phone": {
                "type": "string",
                "nullable": true,
                "description": "Deprecated alias of `phoneNumber`. Same value; still returned for backward compatibility.",
                "example": "40700000000"
              },
              "license_status": {
                "type": "string",
                "nullable": true,
                "description": "Deprecated alias of `licenseStatus`. Same value; still returned for backward compatibility.",
                "example": "active"
              },
              "license_expires": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "Deprecated alias of `licenseExpires`. Same value; still returned for backward compatibility."
              }
            }
          }
        }
      },
      "RestartConnectionResponse": {
        "type": "object",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "recreated": {
            "type": "boolean",
            "description": "`true` if the adapter session had to be fully recreated rather than just restarted."
          }
        }
      },
      "PairingCodeRequest": {
        "type": "object",
        "required": [
          "phoneNumber"
        ],
        "properties": {
          "phoneNumber": {
            "type": "string",
            "pattern": "^[1-9][0-9]{7,14}$",
            "description": "The number being linked, in **E.164 WITHOUT the leading `+`** — digits only, 8–15 of them (the same shape `phoneNumber` has everywhere else in this API). `+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.",
            "example": "40740267964"
          }
        }
      },
      "PairingCodeResponse": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "description": "The pairing code to show the customer, e.g. `7S59-1KZP`. Typed into WhatsApp → Settings → Linked devices → Link with phone number. Short-lived; request a new one rather than re-showing an old one.",
            "example": "7S59-1KZP"
          }
        }
      },
      "OkResponse": {
        "type": "object",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "example": true
          }
        }
      },
      "AccountResponse": {
        "type": "object",
        "required": [
          "slots",
          "connections"
        ],
        "description": "Slot quota and connection health for the token's workspace. camelCase, like the rest of v1.",
        "properties": {
          "slots": {
            "type": "object",
            "required": [
              "total",
              "used",
              "available",
              "nextExpiry"
            ],
            "properties": {
              "total": {
                "type": "integer",
                "description": "Connection slots the workspace holds in total.",
                "example": 10
              },
              "used": {
                "type": "integer",
                "description": "Slots already consumed by existing connections.",
                "example": 7
              },
              "available": {
                "type": "integer",
                "description": "Slots you can still fill with `POST /connections` and have the connection come up licensed.",
                "example": 3
              },
              "nextExpiry": {
                "type": "string",
                "format": "date-time",
                "nullable": true,
                "description": "When the next slot expires — renew before this date so a slot never lapses underneath a paying customer. `null` when nothing is due to expire.",
                "example": "2026-09-01T00:00:00.000Z"
              }
            }
          },
          "connections": {
            "type": "object",
            "required": [
              "total",
              "connected",
              "needsReconnect"
            ],
            "properties": {
              "total": {
                "type": "integer",
                "description": "Connections in the workspace.",
                "example": 7
              },
              "connected": {
                "type": "integer",
                "description": "Currently live sessions.",
                "example": 6
              },
              "needsReconnect": {
                "type": "integer",
                "description": "Numbers waiting for their owner to scan a fresh QR — the same state that fires `connection.disconnected`.",
                "example": 1
              }
            }
          }
        }
      },
      "Connection": {
        "type": "object",
        "required": [
          "id",
          "status",
          "isDefault",
          "provider",
          "tier",
          "licenseValid",
          "licenseStatus"
        ],
        "description": "Canonical keys are camelCase: `phoneNumber`, `label`, `licenseStatus`, `licenseExpires`. The deprecated aliases `phone` and `name` remain present for backward compatibility and will be removed in a future major version.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Connection id — use as `connectionId` when sending/reading.",
            "example": "conn_8f3a21"
          },
          "phoneNumber": {
            "type": "string",
            "nullable": true,
            "description": "The connection's own WhatsApp number (intl digits). Canonical camelCase key.",
            "example": "40700000000"
          },
          "label": {
            "type": "string",
            "nullable": true,
            "description": "Human-readable label or WhatsApp display name. Canonical camelCase key.",
            "example": "Acme Support"
          },
          "status": {
            "type": "string",
            "description": "Session status, e.g. `open` / `connected` / `disconnected`.",
            "example": "open"
          },
          "isDefault": {
            "type": "boolean"
          },
          "provider": {
            "type": "string",
            "enum": [
              "qr",
              "cloud_api"
            ],
            "description": "WhatsApp transport: `qr` (WAPP/QR, paired by scanning; no Meta account needed) or `cloud_api` (WABA, official WhatsApp Business Cloud API)."
          },
          "tier": {
            "type": "string",
            "enum": [
              "basic",
              "meta"
            ]
          },
          "profilePicUrl": {
            "type": "string",
            "format": "uri",
            "nullable": true
          },
          "licenseValid": {
            "type": "boolean",
            "description": "Whether the connection's WASync license is currently active."
          },
          "licenseStatus": {
            "type": "string",
            "example": "active"
          },
          "licenseExpires": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "phone": {
            "type": "string",
            "nullable": true,
            "description": "Deprecated alias of `phoneNumber`. Still returned; prefer `phoneNumber`.",
            "example": "40700000000"
          },
          "name": {
            "type": "string",
            "nullable": true,
            "description": "Deprecated alias of `label`. Still returned; prefer `label`.",
            "example": "Acme Support"
          }
        }
      },
      "MessagesResponse": {
        "type": "object",
        "required": [
          "messages",
          "nextCursor",
          "hasMore"
        ],
        "properties": {
          "messages": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Message"
            }
          },
          "nextCursor": {
            "type": "string",
            "nullable": true,
            "description": "Pass as `cursor` to fetch the next page; `null` when there are no more."
          },
          "hasMore": {
            "type": "boolean"
          }
        }
      },
      "Message": {
        "type": "object",
        "required": [
          "id",
          "connectionId",
          "direction",
          "status",
          "createdAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "WASync's stable message id — the same value `POST /messages` returns as `messageId` and both message webhooks (`message.received`, `message.status`) carry as `message.id`. Join on this.",
            "example": "cmqj3k2ab0001xyz"
          },
          "waMessageId": {
            "type": "string",
            "nullable": true,
            "description": "WhatsApp's own id for this message (null when the provider never acknowledged it). For support/debugging — not a join key.",
            "example": "true_40700000000@c.us_3EB0A1B2C3"
          },
          "connectionId": {
            "type": "string"
          },
          "direction": {
            "type": "string",
            "enum": [
              "incoming",
              "outgoing"
            ]
          },
          "text": {
            "type": "string",
            "nullable": true,
            "description": "Text body (null for media-only messages)."
          },
          "mediaUrl": {
            "type": "string",
            "nullable": true
          },
          "mediaType": {
            "type": "string",
            "nullable": true,
            "description": "e.g. `image`, `audio`, `document`."
          },
          "status": {
            "type": "string",
            "description": "e.g. `sent` / `delivered` / `read` / `received` / `failed`.",
            "example": "delivered"
          },
          "errorMessage": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "MediaObject": {
        "type": "object",
        "required": [
          "filename",
          "mimetype",
          "data"
        ],
        "properties": {
          "filename": {
            "type": "string",
            "description": "File name including extension (e.g. `photo.jpg`, `voice-note.ogg`, `report.pdf`).",
            "example": "photo.jpg"
          },
          "mimetype": {
            "type": "string",
            "description": "MIME type of the file. Allowed: 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.",
            "example": "image/jpeg"
          },
          "data": {
            "type": "string",
            "description": "Base64-encoded file contents. Maximum decoded size: **16 MB** (base64 string length ≤ 22 400 000 chars).",
            "example": "/9j/4AAQ..."
          },
          "caption": {
            "type": "string",
            "nullable": true,
            "description": "Optional caption shown below the media in WhatsApp.",
            "example": "Here is the photo!"
          }
        }
      },
      "SendMessageRequest": {
        "type": "object",
        "required": [
          "connectionId",
          "to"
        ],
        "description": "Send either a `text` message or a `media` message — supply exactly one of the two. Supplying both returns 400 `text_or_media`.",
        "properties": {
          "connectionId": {
            "type": "string",
            "description": "A connection id from `GET /connections`."
          },
          "to": {
            "type": "string",
            "description": "Recipient in international format, digits only (no `+`), e.g. `40700000000`.",
            "example": "40700000000"
          },
          "text": {
            "type": "string",
            "maxLength": 4096,
            "description": "Text message body (max 4096 chars). Mutually exclusive with `media`."
          },
          "media": {
            "$ref": "#/components/schemas/MediaObject",
            "description": "Media to send. Mutually exclusive with `text`."
          },
          "idempotencyKey": {
            "type": "string",
            "description": "Optional. Makes retries safe on text sends — the same key returns the original result instead of sending twice."
          }
        }
      },
      "SendMessageResponse": {
        "description": "Response shape depends on whether a text or media message was sent. Text sends return `messageId` + `waMessageId` + `status`; media sends return `success` + `message` with media fields. In both shapes the WASync id (`messageId` / `message.id`) is the joinable one — it matches `GET /messages` `id` and the webhooks' `message.id`.",
        "oneOf": [
          {
            "title": "TextSendResponse",
            "type": "object",
            "required": [
              "messageId",
              "waMessageId",
              "status"
            ],
            "properties": {
              "messageId": {
                "type": "string",
                "nullable": true,
                "description": "WASync's stable message id — the same id `GET /messages` returns as `id` and the webhooks carry as `message.id`. Store it to match `message.status` events. Null only in the rare degraded case where the message was delivered but could not be persisted (then `persisted` is false — do not retry).",
                "example": "cmqj3k2ab0001xyz"
              },
              "waMessageId": {
                "type": "string",
                "nullable": true,
                "description": "WhatsApp's own message id (null when the provider returned none). For support/debugging — not a join key.",
                "example": "true_40700000000@c.us_3EB0A1B2C3"
              },
              "status": {
                "type": "string",
                "description": "Initial status (e.g. `sent`).",
                "example": "sent"
              },
              "persisted": {
                "type": "boolean",
                "description": "Present and false ONLY when the message reached WhatsApp but could not be stored: it will not appear in `GET /messages` and emits no status webhook. Do not retry it.",
                "example": false
              }
            }
          },
          {
            "title": "MediaSendResponse",
            "type": "object",
            "required": [
              "success",
              "message"
            ],
            "properties": {
              "success": {
                "type": "boolean",
                "example": true
              },
              "message": {
                "type": "object",
                "required": [
                  "id",
                  "status",
                  "created_at"
                ],
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "WASync's stable message id — the same id `GET /messages` returns and the webhooks carry as `message.id`.",
                    "example": "cmqj3k2ab0001xyz"
                  },
                  "wa_message_id": {
                    "type": "string",
                    "nullable": true,
                    "description": "WhatsApp's own message id (null on a failed send or when the provider returned none). For support/debugging — not a join key.",
                    "example": "true_40700000000@c.us_3EB0A1B2C3"
                  },
                  "media_url": {
                    "type": "string",
                    "nullable": true,
                    "description": "Public URL of the stored media file.",
                    "example": "https://cdn.wasync.app/uploads/photo.jpg"
                  },
                  "media_type": {
                    "type": "string",
                    "nullable": true,
                    "description": "Media category, e.g. `image`, `video`, `audio`, `document`.",
                    "example": "image"
                  },
                  "media_caption": {
                    "type": "string",
                    "nullable": true,
                    "description": "Caption as stored.",
                    "example": "Here is the photo!"
                  },
                  "message_type": {
                    "type": "string",
                    "description": "WhatsApp message type, e.g. `image`, `audio`, `document`.",
                    "example": "image"
                  },
                  "status": {
                    "type": "string",
                    "description": "Initial status, e.g. `sent`.",
                    "example": "sent"
                  },
                  "created_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "ISO 8601 creation timestamp.",
                    "example": "2026-07-22T10:00:00.000Z"
                  }
                }
              }
            }
          }
        ]
      },
      "MarkReadRequest": {
        "type": "object",
        "required": [
          "connectionId"
        ],
        "description": "Identifies the conversation to blue-tick. `connectionId` is always required; supply at least one of `phone` / `messageId` (if both are present, `messageId` wins).",
        "properties": {
          "connectionId": {
            "type": "string",
            "description": "Id of the connection the conversation belongs to. Must be inside the grant.",
            "example": "conn_8f3a21"
          },
          "phone": {
            "type": "string",
            "description": "The customer's number in international digits, no `+`. Marks that conversation read.",
            "example": "40700000000"
          },
          "messageId": {
            "type": "string",
            "description": "WASync's stable id (cuid) of an INCOMING message — the same id `GET /messages` returns as `id` and the webhooks carry as `message.id`. NOT `waMessageId`. Everything from that peer up to and including this message is marked read.",
            "example": "cmqj3k2ab0001xyz"
          }
        }
      },
      "MarkReadResponse": {
        "type": "object",
        "required": [
          "ok",
          "marked"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "Always `true` — the provider accepted the read receipt. A failure is reported as a non-2xx status, never as `ok: false`.",
            "example": true
          },
          "marked": {
            "type": "integer",
            "nullable": true,
            "description": "How many of your stored inbound messages were NEWLY stamped as read. `0` means the receipt was sent but everything was already marked (expected on a repeat call). `null` means the receipt WAS sent but our bookkeeping write did not land — the customer still sees blue ticks.",
            "example": 3
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "description": "All errors carry a stable `error` code. On 400 `invalid_request`, the optional `field` and `hint` properties identify the specific failing input. Branch on `error`; treat `field`/`hint` as informational.",
        "properties": {
          "error": {
            "type": "string",
            "description": "A stable, machine-readable error code. Never branch on human-readable text."
          },
          "field": {
            "type": "string",
            "description": "On 400 `invalid_request`: the name of the request body field that failed validation (e.g. `connectionId`, `to`, `text`). Additive — present only when the server knows the exact failing field.",
            "example": "connectionId"
          },
          "hint": {
            "type": "string",
            "description": "On 400 `invalid_request`: a short human-readable tip about what to fix. Additive — may be absent.",
            "example": "expected string; did you send `connection` instead of `connectionId`?"
          }
        }
      },
      "WebhookConfig": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "nullable": true,
            "format": "uri",
            "description": "The endpoint WASync POSTs to, or null when none is set.",
            "example": "https://example.com/wasync/webhook"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Event types delivered to this endpoint.",
            "example": [
              "message.received",
              "message.status",
              "connection.disconnected",
              "connection.connected"
            ]
          },
          "secretSet": {
            "type": "boolean",
            "description": "Whether a signing secret exists. The secret itself is NEVER returned by this endpoint — it is issued by `PUT /webhook` and re-issued by `POST /webhook/rotate`."
          }
        }
      },
      "SetWebhookRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS URL on a publicly reachable host. http://, localhost and private ranges are rejected.",
            "example": "https://example.com/wasync/webhook"
          }
        }
      },
      "SetWebhookResponse": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "nullable": true,
            "format": "uri"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "secret": {
            "type": "string",
            "nullable": true,
            "description": "The signing secret for this endpoint. Returned HERE because this is the moment it is issued and the moment you need it to write your verification code. Store it — `GET /webhook` will not return it again."
          }
        }
      },
      "RotateWebhookResponse": {
        "type": "object",
        "properties": {
          "secret": {
            "type": "string",
            "description": "The new signing secret. The previous one stops working immediately."
          }
        }
      }
    }
  }
}
