openapi: 3.0.3
info:
  title: Tarceel API
  version: "1.0"
  description: |
    WhatsApp API gateway (linked-device / QR model). Not affiliated with or endorsed
    by WhatsApp/Meta. WhatsApp can suspend numbers for policy violations; you are responsible
    for consent/opt-in on your own contact lists.

    **Authentication:** three credential types gate the routes below — an account token (JWT) and
    a personal access token (`pat_...`, no expiry) are fully interchangeable everywhere, both
    granting the exact same account-level access; an instance's own API key (`sk_...`) is
    different and narrower. See the [Authentication guide](/docs/authentication) at `/docs` for
    which one a given route needs and exactly where to get each — this file's own
    `securitySchemes` descriptions below carry the short version.

    See `/docs` for the full narrative documentation this spec is a companion to — Quickstart,
    Authentication, Sending Messages, Webhooks, Connect, and Billing guides, plus this same
    reference rendered as readable pages instead of raw YAML.
servers:
  - url: https://app.tarceel.com
    description: Production
  - url: http://127.0.0.1:3000
    description: Local development

security: []

paths:
  /health:
    get:
      summary: Health check
      tags: [Health]
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }

  /auth/request-link:
    post:
      summary: Request a magic login link
      description: >-
        If the account has authenticator-app (TOTP) login enabled, this responds
        `200 { requiresTotp: true }` and sends no email at all — call POST /auth/totp/login-verify
        instead. Pass `totpFallback: true` to force the normal email flow anyway.
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                mode: { type: string, enum: [login, signup] }
                totpFallback: { type: boolean, description: "Force the email flow even if TOTP is enabled." }
      responses:
        "200":
          description: TOTP-enabled account — no email was sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  requiresTotp: { type: boolean, enum: [true] }
        "202":
          description: A login/signup link was created (and emailed, best-effort)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }

  /auth/verify:
    get:
      summary: Verify a magic link token and receive an account JWT
      tags: [Auth]
      security: []
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Valid, unused, unexpired token
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string, description: "Account JWT, valid 30 days" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/totp/login-verify:
    post:
      summary: Log in with an authenticator-app code, skipping the email step
      description: >-
        Takes a live 6-digit code or an unused backup code and issues the same account JWT
        GET /auth/verify does — only usable once TOTP has been enabled via POST /auth/totp/confirm.
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string, description: "6-digit authenticator code, or a backup code" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string, description: "Account JWT, valid 30 days" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/totp/status:
    get:
      summary: Whether authenticator-app login is enabled for this account
      tags: [Auth]
      security: [{ AccountToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/totp/setup:
    post:
      summary: Start authenticator-app setup
      description: >-
        Generates a new secret and returns it plus an otpauth:// URI to render as a QR code
        client-side. Does not enable TOTP by itself — call POST /auth/totp/confirm with a real
        code from the scanned app to finish. Calling this again before confirming replaces the
        pending secret.
      tags: [Auth]
      security: [{ AccountToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string, description: "Base32 — for manual entry if the QR can't be scanned" }
                  otpauthUri: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/totp/confirm:
    post:
      summary: Confirm authenticator-app setup, enabling TOTP login
      description: >-
        On success, returns 8 one-time backup codes — shown exactly once, same convention as an
        instance API key or personal access token.
      tags: [Auth]
      security: [{ AccountToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "200":
          description: TOTP enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  backupCodes:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }

  /auth/totp/disable:
    post:
      summary: Disable authenticator-app login
      description: >-
        Requires a valid current code (live or backup) — a merely-open session can't silently
        turn this off without proving the second factor still works.
      tags: [Auth]
      security: [{ AccountToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "204": { description: Disabled }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }

  /account/tokens:
    get:
      summary: List this account's personal access tokens
      description: >-
        Never includes the raw token value — only the account/PAT that created a token ever sees
        it, at creation time.
      tags: [Account]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        createdAt: { type: string, format: date-time }
                        revokedAt: { type: string, format: date-time, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      summary: Create a personal access token
      description: >-
        A long-lived stand-in for the account JWT — accepted everywhere `AccountToken` is, with
        no expiry until revoked. Create it while logged in as a human; a script then uses it
        directly without ever completing the magic-link flow. Accepts an existing account
        token or personal access token, same as every other account-level route.
      tags: [Account]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100, example: "CI pipeline" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  name: { type: string }
                  createdAt: { type: string, format: date-time }
                  token: { type: string, description: "Full token (pat_...) — shown only here" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /account/tokens/{tokenId}:
    parameters:
      - name: tokenId
        in: path
        required: true
        schema: { type: string }
    delete:
      summary: Revoke a personal access token
      description: Immediately and permanently stops the token from authenticating. Not reversible — create a new one instead.
      tags: [Account]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: No such token for this account, or it was already revoked }

  /connect/device-code:
    post:
      summary: Start a device-pairing "Connect" request
      description: >-
        Lets any third-party app get an instance's instanceId/API key with one click, instead of
        a human copying it from the dashboard — works the same whether or not the app has a
        public callback URL, unlike an OAuth-style redirect (which needs one), so it fits a
        self-hosted integration just as well as a hosted one. Modeled on RFC 8628's Device
        Authorization Grant field names, not full OAuth compliance. Open `verificationUri` for
        the user, then poll POST /connect/token with the returned `deviceCode` in the background
        until it resolves.
      tags: [Connect]
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                appName:
                  type: string
                  maxLength: 200
                  description: Shown to the user on the approval page and recorded against the resulting API key — always send something identifiable.
                  example: "My App"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  deviceCode:
                    type: string
                    description: Long secret for the polling app — keep it server-side, never show it to the user.
                  userCode: { type: string, example: "WKPX-7QRT" }
                  verificationUri: { type: string, format: uri }
                  expiresIn: { type: integer, description: Seconds until this request expires unapproved (10 minutes). }
                  interval: { type: integer, description: Suggested seconds between POST /connect/token polls. }

  /connect/token:
    post:
      summary: Poll for the outcome of a device-pairing request
      description: >-
        Always `200` with a `status` field — poll on a plain interval, never treat any status
        value as an error. `apiKey`/`instanceId` are only ever present on the single poll call
        that reveals them (`status: approved`) — save the key immediately, it's never shown
        again. Every later poll for the same `deviceCode` returns `status: claimed` with no key —
        `pending` while unresolved, `denied`/`expired` if the user rejected it or it timed out.
      tags: [Connect]
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [deviceCode]
              properties:
                deviceCode: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [pending, approved, denied, expired, claimed] }
                  instanceId: { type: string, nullable: true }
                  apiKey:
                    type: string
                    nullable: true
                    description: Present only on the one poll call that reveals it.
        "404":
          description: Unknown device code
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /connect/device-code/{userCode}:
    parameters:
      - name: userCode
        in: path
        required: true
        schema: { type: string }
    get:
      summary: Details for the dashboard's Connect approval page
      description: Dashboard-only — a third-party integrator never calls this directly.
      tags: [Connect]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  appName: { type: string, nullable: true }
                  createdAt: { type: string, format: date-time }
                  expiresAt: { type: string, format: date-time }
                  status: { type: string, enum: [pending, approved, denied, expired, claimed] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /connect/device-code/{userCode}/approve:
    parameters:
      - name: userCode
        in: path
        required: true
        schema: { type: string }
    post:
      summary: Approve a device-pairing request
      description: >-
        Dashboard-only. Exactly one of `instanceId` (rotates that instance's API key — this app
        supports only one active key per instance at a time) or `createInstance` must be given;
        the latter is subject to the same free-trial 1-instance cap as POST /instances.
      tags: [Connect]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                instanceId: { type: string }
                createInstance:
                  type: object
                  properties:
                    billingMode: { type: string, enum: [fixed, prepaid] }
                    name: { type: string, maxLength: 100 }
      responses:
        "200":
          description: OK — the apiKey itself is never returned here, only ever to the polling app via POST /connect/token
          content:
            application/json:
              schema:
                type: object
                properties:
                  instanceId: { type: string }
                  appName: { type: string, nullable: true }
        "400":
          description: Neither or both of instanceId/createInstance were given
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: Free trial is limited to 1 instance (createInstance path only)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404":
          description: Unknown connect code, or instanceId not owned by this account
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "409":
          description: This connect request is no longer pending (already approved, denied, or expired)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /connect/device-code/{userCode}/deny:
    parameters:
      - name: userCode
        in: path
        required: true
        schema: { type: string }
    post:
      summary: Deny a device-pairing request
      description: Dashboard-only.
      tags: [Connect]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "204": { description: Denied }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: This connect request is no longer pending
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /instances:
    get:
      summary: List your account's instances
      description: >-
        Includes at-a-glance billing stats per instance: `creditBalance` for prepaid, `nextBilling`
        for a fixed-mode instance with an active subscription (null otherwise). `billingCycle` is
        not included here — see GET /instances/{instanceId} for that.
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  instances:
                    type: array
                    items: { $ref: "#/components/schemas/InstanceStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      summary: Create an instance under the authenticated account
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                billingMode: { type: string, enum: [fixed, prepaid], default: fixed }
                name: { type: string, maxLength: 100, description: "Cosmetic display name, shown in the dashboard" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  instanceId: { type: string }
                  apiKey: { type: string, description: "Full key — shown only here" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /instances/{instanceId}:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Instance status
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InstanceStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      summary: Soft-delete the instance (its API key stops working)
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "204": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /instances/{instanceId}/qr:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Current pending QR code, or null once linked
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  qr: { type: string, nullable: true }

  /instances/{instanceId}/pause:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Disconnect the session (can be resumed)
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: paused } } }

  /instances/{instanceId}/resume:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Reconnect a paused instance
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: active } } }

  /instances/{instanceId}/relink:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Get a fresh QR after the session was logged out
      description: >-
        Only valid while `sessionStatus` is `logged_out` — clears the stale linked-device
        credentials and asks the owning session-worker to reconnect on its next poll tick, so
        Baileys issues a brand-new pairing QR. Applies within one poll interval, not
        synchronously with this request.
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: relink_requested } } }
        "409":
          description: Instance is not active, or its session is not currently logged out

  /instances/{instanceId}/api-key/rotate:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Issue a new API key, invalidating the current one
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  apiKey: { type: string, description: "Full key — shown only here" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /instances/{instanceId}/api-key/revoke:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Disable the current API key with no replacement
      tags: [Instances]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "204": { description: Revoked }
        "403": { $ref: "#/components/responses/Forbidden" }

  /instances/{instanceId}/messages/text:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Send a text message
      tags: [Messages]
      security: [{ InstanceApiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, text]
              properties:
                to: { type: string, example: "15551234567" }
                text: { type: string }
      responses:
        "200": { $ref: "#/components/responses/SendResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }

  /instances/{instanceId}/messages/media:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Send an image/video/audio/document, by URL or base64
      tags: [Messages]
      security: [{ InstanceApiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, type]
              properties:
                to: { type: string }
                type: { type: string, enum: [image, video, audio, document] }
                url: { type: string, description: "Exactly one of url/base64 required" }
                base64: { type: string }
                mimetype: { type: string, description: "Required for type=document" }
                filename: { type: string }
                caption: { type: string }
      responses:
        "200": { $ref: "#/components/responses/SendResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }

  /instances/{instanceId}/messages/location:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Send a location pin
      tags: [Messages]
      security: [{ InstanceApiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, latitude, longitude]
              properties:
                to: { type: string }
                latitude: { type: number }
                longitude: { type: number }
                name: { type: string }
                address: { type: string }
      responses:
        "200": { $ref: "#/components/responses/SendResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }

  /instances/{instanceId}/messages/contact:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Send a contact card
      tags: [Messages]
      security: [{ InstanceApiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, fullName, phone]
              properties:
                to: { type: string }
                fullName: { type: string }
                phone: { type: string }
                organization: { type: string }
      responses:
        "200": { $ref: "#/components/responses/SendResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }

  /instances/{instanceId}/messages/{id}/status:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: Message id, as returned by any send endpoint
    get:
      summary: Check a sent message's delivery status
      tags: [Messages]
      security: [{ InstanceApiKey: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [pending, sent, delivered, read, failed] }
        "404": { $ref: "#/components/responses/NotFound" }

  /instances/{instanceId}/webhook:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Current webhook config (secret masked)
      description: |
        **The webhook event contract**: once a URL is configured below, Tarceel POSTs a signed
        `WebhookEnvelope` JSON body to it for four event types — `message.received`
        (`InboundMessage`), `message.status` (`MessageStatusEventData`), `session.status`
        (`SessionStatusEventData`), and `billing.low_balance` (`LowBalanceEventData`); see those
        schemas below for the exact shape of each, and docs/webhooks.md for the full narrative
        with more worked examples. Each delivery carries an `X-Tarceel-Signature` HMAC header (the
        full secret is only ever shown once, on the PUT response below) so you can verify it
        genuinely came from Tarceel.
      tags: [Webhooks]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookConfig" }
    put:
      summary: Set or rotate the webhook URL
      tags: [Webhooks]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string }
      responses:
        "200":
          description: "Full secret — only ever returned here"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookConfig" }
        "400": { $ref: "#/components/responses/BadRequest" }
    delete:
      summary: Remove the webhook config
      tags: [Webhooks]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "204": { description: Removed }

  /instances/{instanceId}/webhook/deliveries:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Recent webhook delivery attempts, most recent first
      tags: [Webhooks]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }

  /instances/{instanceId}/webhook/deliveries/{deliveryId}/retry:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
      - name: deliveryId
        in: path
        required: true
        schema: { type: string }
    post:
      summary: Redrive a dead-lettered delivery — one fresh 5-attempt retry cycle
      description: >-
        Resends the exact original envelope (same `id`, same `data`) to the instance's *current*
        webhook URL/secret, which may differ from the one the original attempt failed against.
        Only a `dead_letter` delivery can be redriven.
      tags: [Webhooks]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }, { InstanceApiKey: [] }]
      responses:
        "202":
          description: Re-enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [redriven] }
        "404":
          description: No such delivery for this instance
        "409":
          description: Not dead-lettered, or the instance has no webhook configured right now

  /instances/{instanceId}/billing:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Billing status (subscription or credit balance)
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BillingInfo" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /instances/{instanceId}/billing/charges:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Usage/charge history (prepaid), most recent first
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  charges:
                    type: array
                    items:
                      type: object
                      properties:
                        messageId: { type: string }
                        credits: { type: integer }
                        createdAt: { type: string, format: date-time }

  /instances/{instanceId}/billing/checkout:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Start a Stripe Checkout session for an existing instance (subscription upgrade or one-time bundle top-up)
      description: >-
        For a prepaid top-up, any existing Stripe Customer Balance credit on this instance's
        account (see docs/billing.md's "Self-serve billing management" section) is applied first —
        `appliedCreditCents` in the response reports how much. If it fully covers the bundle price,
        `checkoutUrl` is null (credits were already granted synchronously, nothing to pay); if it
        only partially covers it, the Checkout Session charges just the discounted remainder and
        the credit is consumed once that payment is confirmed by the webhook.
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                bundleId:
                  type: string
                  description: Required for prepaid instances — see GET /billing/credit-bundles for valid ids.
                billingCycle:
                  type: string
                  enum: [monthly, yearly]
                  description: Fixed-mode only. Defaults to monthly.
                switchToBillingMode:
                  type: string
                  enum: [fixed]
                  description: >-
                    Set only when this instance is currently prepaid and the checkout should
                    switch it to the fixed plan on completion — the Stripe webhook flips
                    Instance.billingMode once payment is confirmed. Any remaining message-credit
                    balance is converted to a dollar value and granted as Stripe Customer Balance
                    credit *before* this checkout is created (creating a Stripe customer first if
                    this instance has never had one) — Stripe then applies it automatically to the
                    checkout's first invoice, correctly carrying over any amount beyond that
                    invoice's price to the next one rather than losing it. The local balance is only
                    deducted after the Stripe grant succeeds, so a transient API error never loses
                    real credit value. See docs/billing.md.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  checkoutUrl:
                    type: string
                    nullable: true
                    description: Null when an existing account credit fully covered the price — nothing to redirect to.
                  appliedCreditCents:
                    type: integer
                    description: Stripe Customer Balance credit applied toward this purchase, in cents. 0 if none. Only ever set for the prepaid top-up path.
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "501":
          description: Stripe not configured on this server yet (see docs/billing.md)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /instances/{instanceId}/billing/upcoming:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Preview of the next scheduled charge for an active fixed subscription
      description: >-
        `upcoming` is null when there's nothing scheduled (no subscription, or prepaid mode).
        Backed by Stripe's invoice preview — never a locally-computed estimate.
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  upcoming:
                    nullable: true
                    allOf: [{ $ref: "#/components/schemas/UpcomingInvoice" }]
        "403": { $ref: "#/components/responses/Forbidden" }

  /instances/{instanceId}/billing/invoices:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Billing history — real Stripe invoices (fixed) or credit-purchase history (prepaid)
      description: >-
        Both billing modes are mapped to the same InvoiceEntry shape so the dashboard renders
        either one identically. Prepaid entries never have a hostedUrl (no Stripe customer is
        ever created for a one-time guest payment).
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoices:
                    type: array
                    items: { $ref: "#/components/schemas/InvoiceEntry" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /instances/{instanceId}/billing/preview-cycle-change:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
      - name: billingCycle
        in: query
        required: true
        schema: { type: string, enum: [monthly, yearly] }
    get:
      summary: Read-only preview of what POST .../change-cycle would actually charge/credit right now
      description: >-
        Applies the exact same validation as the real change-cycle route (active subscription
        required, target cycle must differ, a real Stripe Price must exist) and never changes
        anything — backed by Stripe's own invoice-preview machinery, so the number can't drift from
        what actually happens on confirm. Meant to back a confirmation step before the customer
        commits to a switch.
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  totalCents: { type: integer, description: "Positive = would be charged now; negative = would be credited now." }
                  currency: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { description: No active subscription to change }
        "501":
          description: Stripe not configured, or no real Stripe Price exists yet for the target cycle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /instances/{instanceId}/billing/change-cycle:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Switch an already-active fixed subscription between monthly and yearly
      description: >-
        Requires an active/trialing/past_due/unpaid subscription and a real cycle change (not
        the current one). Any amount owed for the switch is charged immediately
        (Stripe `proration_behavior: always_invoice`) — never deferred to the next invoice.
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [billingCycle]
              properties:
                billingCycle: { type: string, enum: [monthly, yearly] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  billingCycle: { type: string, enum: [monthly, yearly] }
                  status: { type: string }
                  currentPeriodEnd: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /instances/{instanceId}/billing/preview-switch-to-prepaid:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Read-only preview of what POST .../switch-to-prepaid would actually credit right now
      description: >-
        `creditCents` is 0 (not an error) when there's no active subscription to cancel — nothing
        to preview. Meant to back a confirmation step before the customer commits to canceling.
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  creditCents: { type: integer, minimum: 0 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /instances/{instanceId}/billing/switch-to-prepaid:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Switch a fixed instance to pay-as-you-go
      description: >-
        Cancels the Stripe subscription immediately and flips Instance.billingMode to prepaid
        right away — no Checkout is needed to stop paying. The unused portion of the current
        billing period is credited to the customer's Stripe balance (Stripe's own Customer
        Balance feature — `creditCents` in the response is what was granted), applied
        automatically to whatever this customer is charged next, rather than forfeited.
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  billingMode: { type: string, enum: [prepaid] }
                  creditCents: { type: integer, description: "Unused-time credit granted to the Stripe Customer Balance, in cents. 0 if there was no active subscription to cancel." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /instances/{instanceId}/billing/portal:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    post:
      summary: Start a Stripe Billing Portal session for this instance's customer
      description: >-
        Returns a URL to Stripe's own hosted Billing Portal, where the customer can update their
        payment method, view/download past invoices, and manage or cancel their subscription
        directly with Stripe — this app never handles card details itself. Requires this instance
        to already have a Stripe customer (`Instance.stripeCustomerId`, set the first time one is
        needed — a bundle purchase, a fixed-plan checkout, or a credit conversion — independent of
        whether a subscription exists); a `400` here means neither has happened yet. A `400` can
        also mean the Stripe account has no Customer Portal configuration yet (test-mode accounts
        get a default one automatically; live accounts need one created once — see
        docs/billing.md) — Stripe's own rejection message is passed through in that case.
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "501":
          description: Stripe not configured on this server yet (see docs/billing.md)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /instances/{instanceId}/billing/credit-balance:
    parameters:
      - $ref: "#/components/parameters/InstanceId"
    get:
      summary: Stripe Customer Balance credit available on this instance's account
      description: >-
        Reads Stripe's own Customer Balance (docs/billing.md's "Self-serve billing management"
        section) — never a balance tracked in our own database. This credit is applied
        automatically to whatever this customer is charged next (a subscription renewal, a cycle
        switch's prorated invoice, etc). Always `{ creditCents: 0, currency: <platform currency> }`
        (not an error) for an instance that has never had a Stripe customer at all
        (`Instance.stripeCustomerId` unset — no bundle purchase, checkout, or credit conversion has
        happened for it yet).
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreditBalance" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /billing/checkout:
    post:
      summary: Start a Stripe Checkout session for a brand-new instance (no instance is created until Stripe confirms payment)
      description: >-
        Used by the dashboard's "Add Instance" wizard once the tenant's free trial has ended.
        Unlike /instances/{instanceId}/billing/checkout, no Instance row exists yet — the desired
        instance's shape travels as Checkout Session metadata, and is only created by the Stripe
        webhook handler once `checkout.session.completed` actually arrives. See docs/billing.md.
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [billingMode]
              properties:
                billingMode: { type: string, enum: [fixed, prepaid] }
                name: { type: string, maxLength: 100, description: "Display name for the instance once created." }
                billingCycle:
                  type: string
                  enum: [monthly, yearly]
                  description: Fixed-mode only. Defaults to monthly.
                bundleId:
                  type: string
                  description: Required for prepaid — see GET /billing/credit-bundles for valid ids.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  checkoutUrl: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "501":
          description: Stripe not configured on this server yet (see docs/billing.md)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  /billing/checkout-session/{sessionId}/reveal:
    parameters:
      - name: sessionId
        in: path
        required: true
        schema: { type: string }
        description: The Stripe Checkout Session id (from success_url's `?session_id={CHECKOUT_SESSION_ID}`).
    get:
      summary: One-time reveal of the instance+apiKey a pending-instance Checkout just created
      description: >-
        Poll this after a POST /billing/checkout redirect completes — the webhook that creates the
        instance may not have run yet. The row is deleted as part of being read, so it can be
        fetched successfully at most once; a 404 means either "not created yet, keep polling" or
        "already revealed" (indistinguishable by design).
      tags: [Billing]
      security: [{ AccountToken: [] }, { PersonalAccessToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  instanceId: { type: string }
                  apiKey: { type: string, description: "Raw API key — shown here exactly once, same as at normal instance creation." }
        "404":
          description: Not ready yet, or already revealed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }

components:
  parameters:
    InstanceId:
      name: instanceId
      in: path
      required: true
      schema: { type: string }

  securitySchemes:
    AccountToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: "From GET /auth/verify, ~30 days. A personal access token works identically. Full guide: top of /docs."
    PersonalAccessToken:
      type: http
      scheme: bearer
      description: "From dashboard → API Tokens, or POST /account/tokens. No expiry. Works exactly like AccountToken. Full guide: top of /docs."
    InstanceApiKey:
      type: http
      scheme: bearer
      description: "Shown at instance creation/rotation. Full guide: top of /docs."

  schemas:
    ErrorEnvelope:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }

    InstanceStatus:
      type: object
      description: >-
        Shared shape for both GET /instances (list) and GET /instances/{instanceId} (detail) —
        `billingCycle` is only ever present on the detail response, `creditBalance`/`nextBilling`
        only on the list response (see each operation's own description above).
      properties:
        id: { type: string }
        name: { type: string, nullable: true, description: "Cosmetic display name, or null if never set" }
        status: { type: string, enum: [active, paused, deleted] }
        billingMode: { type: string, enum: [fixed, prepaid] }
        billingCycle:
          type: string
          nullable: true
          enum: [monthly, yearly]
          description: "Detail response only. Meaningful for billingMode=fixed; null otherwise."
        sessionStatus:
          type: string
          nullable: true
          enum: [connecting, qr_pending, connected, reconnecting, logged_out]
        createdAt: { type: string, format: date-time }
        creditBalance:
          type: integer
          nullable: true
          description: "List response only. Remaining message credits for a prepaid instance, null for fixed."
        nextBilling:
          type: object
          nullable: true
          description: "List response only. The next renewal date + plan price for a fixed instance with an active subscription, null otherwise."
          properties:
            date: { type: string, format: date-time }
            amountCents: { type: integer }
            currency: { type: string }

    WebhookConfig:
      type: object
      properties:
        url: { type: string, nullable: true }
        secret: { type: string, nullable: true, description: "Full on PUT response, masked on GET" }

    WebhookDelivery:
      type: object
      properties:
        id:
          type: string
          description: Same value as the `id` field embedded in the delivered envelope's own JSON body.
        instanceId: { type: string }
        eventType: { type: string }
        url:
          type: string
          description: Where this delivery was (or, once redriven, will be) sent — the instance's current webhook URL at time of last attempt, not necessarily the one the very first attempt used.
        payload: {}
        status: { type: string, enum: [pending, success, failed, dead_letter] }
        attempt: { type: integer }
        lastError: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        deliveredAt: { type: string, format: date-time, nullable: true }

    # The four payloads actually POSTed to your configured webhook URL — see docs/webhooks.md
    # for the full narrative and worked examples, and the PUT above for a summary. Every delivery
    # is wrapped in WebhookEnvelope; `data`'s shape depends on `event`.
    WebhookEnvelope:
      type: object
      description: The actual top-level JSON body POSTed to your webhook URL for every event type.
      required: [id, event, instanceId, timestamp, data]
      properties:
        id:
          type: string
          description: Stable per-delivery id — identical across every retry attempt and a manual redrive of the same logical delivery. Use this to dedupe a delivery you may see more than once.
        event: { type: string, enum: [message.received, message.status, session.status, billing.low_balance] }
        instanceId: { type: string }
        timestamp: { type: integer, description: "Unix ms when Tarceel noticed the event — not necessarily when it originally occurred." }
        data:
          oneOf:
            - $ref: "#/components/schemas/InboundMessage"
            - $ref: "#/components/schemas/MessageStatusEventData"
            - $ref: "#/components/schemas/SessionStatusEventData"
            - $ref: "#/components/schemas/LowBalanceEventData"

    InboundMessage:
      type: object
      description: data for a `message.received` event.
      required: [id, from, timestamp, type]
      properties:
        id: { type: string }
        from: { type: string, description: "Sender's JID for a direct message, or the group's own JID (ends in @g.us) for a group message." }
        author: { type: string, description: "Present only for a group message — the actual sender's JID (`from` is the group's own JID there)." }
        phone:
          type: string
          description: |
            The sender's real phone-number JID, resolved even when `from`/`author` is a WhatsApp
            privacy LID (`@lid`). Absent only when the sender's strictest phone-number privacy
            setting genuinely discloses no number. Prefer this over `from`/`author` whenever you
            need an actual phone number.
        timestamp: { type: integer, description: Unix seconds. }
        type: { type: string, enum: [text, location, contact, media, other] }
        text: { type: string, description: Present only for type "text". }
        location:
          type: object
          description: Present only for type "location".
          properties:
            latitude: { type: number }
            longitude: { type: number }
            name: { type: string }
            address: { type: string }
        contacts:
          type: array
          description: Present only for type "contact" — one entry for a single shared contact, more than one for a shared contact list.
          items:
            type: object
            required: [vcard]
            properties:
              displayName: { type: string }
              vcard: { type: string, description: "The real vCard (RFC 6350) text WhatsApp sent, not re-parsed into separate fields." }
        media:
          type: object
          description: |
            Present only for type "media" (image/video/audio/document/sticker). Metadata only —
            the actual bytes are not included and there is no download URL. WhatsApp's own media
            URL is encrypted and only decryptable with Baileys' internal media key, so it wouldn't
            be independently usable by your integration anyway.
          properties:
            mediaType: { type: string, enum: [image, video, audio, document, sticker] }
            mimetype: { type: string }
            caption: { type: string, description: Present for image/video/document only. }
            fileName: { type: string, description: Present for document only. }
      example:
        id: "3EB0C6F5BC2D50B88712F0"
        from: "923001234567@s.whatsapp.net"
        timestamp: 1730000000
        type: text
        text: Hello

    MessageStatusEventData:
      type: object
      description: data for a `message.status` event — fired on every outbound-message delivery-status transition.
      required: [id, whatsappId, status]
      properties:
        id:
          type: string
          description: Matches the `id` returned when you sent the message (POST /instances/{instanceId}/messages/text or /media, /location, /contact) — use this to correlate the event back to the message you sent.
        whatsappId:
          type: string
          description: The underlying WhatsApp message id, for consumers that specifically need it — has no relationship to anything else in Tarceel's API and can't be looked up by itself.
        status: { type: string, enum: [pending, sent, delivered, read, failed] }
      example:
        id: "83ad449d-c6ef-49c0-97be-c08b5e452d73"
        whatsappId: "3EB0C6F5BC2D50B88712F0"
        status: delivered

    SessionStatusEventData:
      type: object
      description: data for a `session.status` event — fired whenever the linked-device connection status changes.
      required: [status]
      properties:
        status: { type: string, enum: [disconnected, connecting, qr_pending, connected, reconnecting, logged_out] }
      example:
        status: connected

    LowBalanceEventData:
      type: object
      description: data for a `billing.low_balance` event — fires once when a prepaid instance's balance crosses its configured warning threshold.
      required: [creditBalance]
      properties:
        creditBalance: { type: integer }

    BillingInfo:
      type: object
      properties:
        billingMode: { type: string, enum: [fixed, prepaid] }
        creditBalance: { type: integer }
        lowBalanceThreshold: { type: integer }
        lowBalanceWarnedAt: { type: string, format: date-time, nullable: true }
        subscriptionStatus: { type: string, nullable: true }

    UpcomingInvoice:
      type: object
      properties:
        amountDueCents: { type: integer }
        currency: { type: string }
        nextPaymentAttempt: { type: string, format: date-time, nullable: true }

    InvoiceEntry:
      type: object
      description: >-
        A real Stripe invoice (fixed mode) or a CreditPurchase row (prepaid mode), normalized to
        one shape.
      properties:
        id: { type: string }
        date: { type: string, format: date-time }
        description: { type: string }
        amountCents: { type: integer }
        currency: { type: string }
        status: { type: string }
        hostedUrl:
          type: string
          nullable: true
          description: >-
            "View" link — the Stripe-hosted invoice page (fixed mode) or payment receipt page
            (prepaid mode). Null only if Stripe hasn't generated one yet (fixed mode) or the
            purchase was fully covered by account credit with no underlying Stripe charge to fetch
            a receipt for (prepaid mode).
        downloadUrl:
          type: string
          nullable: true
          description: >-
            "Download" link — a direct PDF for a real Stripe invoice (fixed mode); for a prepaid
            purchase this is the same value as `hostedUrl` (one-time payments don't get a separate
            PDF, only a hosted receipt page), so a client should skip rendering a distinct
            "Download" action when the two are equal.

    CreditBalance:
      type: object
      description: Stripe's own Customer Balance credit — see GET .../billing/credit-balance.
      properties:
        creditCents: { type: integer, minimum: 0 }
        currency: { type: string }

  responses:
    SendResult:
      description: Accepted — id refers to the record, not necessarily delivered yet
      content:
        application/json:
          schema:
            type: object
            properties:
              id: { type: string }
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Unauthorized:
      description: Missing or invalid token/key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Forbidden:
      description: Valid credentials, but not authorized for this instance
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    NotFound:
      description: No such instance/message
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Conflict:
      description: Instance not currently able to send (paused, not connected, opted-out recipient)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    PaymentRequired:
      description: Insufficient credit balance, or a suspended subscription
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

tags:
  - name: Health
  - name: Auth
  - name: Connect
  - name: Instances
  - name: Messages
  - name: Webhooks
  - name: Billing
