openapi: 3.1.0
info:
  title: Soosh API
  version: "1.0"
  summary: Send WhatsApp messages and manage contacts, templates, broadcasts and webhooks.
  description: |
    The Soosh API lets you do from your own systems what you do in the Soosh
    app: send WhatsApp messages and templates, keep contacts in sync, run
    broadcasts, and receive webhooks when things happen.

    ## Basics

    - The base URL is `https://{host}/v1`.
    - Requests and responses are JSON. The one exception is `POST /v1/media`,
      which takes `multipart/form-data`.
    - IDs are UUIDs. Times are RFC 3339 in UTC, with milliseconds in
      responses. Phone numbers go in as E.164, with or without `+`, and come
      out as `+` followed by digits.
    - Request fields the API doesn't know are refused with `400
      invalid_request`. Responses can gain fields at any time, so ignore the
      ones you don't know. A response field marked as open (for example
      `message.type`) can gain values; handle values you haven't seen.
    - Changes within v1 only add things. Anything breaking becomes `/v2`, with
      at least 6 months of overlap.

    ## Authentication

    Send a workspace key as `Authorization: Bearer soosh_live_…`. Cookies are
    ignored, and browsers get no CORS access to `/v1`.

    ## Lists

    A list returns `{"data": [...], "next_cursor": "…" | null}`. Pass
    `next_cursor` back as `cursor` to get the next page. A null cursor means
    you've reached the end. There's no total count.

    ## Idempotency

    Any `POST` can send an `Idempotency-Key` header. It is kept for 24 hours
    and belongs to the API key that sent it and the keys rotated from it: a
    retry with a rotated key gets the same answer, and the same request
    under the same `Idempotency-Key` from any other of your keys is `422
    idempotency_mismatch`. A repeat of a finished request returns the
    stored response with `Idempotent-Replayed: true`, and `Original-Request-ID`
    naming the request that made it (the body is the first response, byte for
    byte, so an error body's `request_id` is the first request's). This
    includes 4xx responses: a `window_closed` stays `window_closed` for 24
    hours under that key. Sending `POST /v1/messages`, `POST /v1/broadcasts` and `POST
    /v1/contacts` with the header is recommended.

    ## Rate limits

    Every authenticated response carries `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` and `X-RateLimit-Reset` for your workspace. A `429`
    also carries `Retry-After`. If a dependency of the API is down, you get a
    `503` with `Retry-After`.

    ## Errors

    Errors share one shape (see the `Error` schema). Every response, including
    errors, has an `X-Request-ID` header. Quote it when you contact support.
    The codes are fixed; new codes may be added, but existing ones never change
    meaning.
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary

servers:
  - url: https://{host}/v1
    description: Soosh
    variables:
      host:
        default: app.soosh.io
        description: The host your Soosh workspace runs on.

security:
  - bearerAuth: []

tags:
  - name: Contacts
    description: The people you talk to, with their tags and your own fields.
  - name: Numbers
    description: The WhatsApp numbers connected to your workspace.
  - name: Usage
    description: Your workspace's limits and what it has used.
  - name: Messages
    description: Sending messages, and reading the ones you sent and received.
  - name: Media
    description: Files to send in messages, and the files of messages you received.
  - name: Templates
    description: Message templates, from creating one to WhatsApp's review of it.
  - name: Broadcasts
    description: One template sent to many people, now or at a time you choose.
  - name: Chats
    description: Who looks after a chat, and whether it's open or closed.
  - name: Webhooks
    description: The endpoints Soosh sends your workspace's events to.

paths:
  /numbers:
    get:
      operationId: listNumbers
      tags: [Numbers]
      summary: List numbers
      description: |
        Returns the WhatsApp numbers of your workspace, the default number for
        sending first. The list isn't paged: `next_cursor` is always `null`.
        Needs the `numbers:read` scope.

        Tokens, app and business IDs and verify tokens are never included.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The workspace's numbers.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NumberList"
              example:
                data:
                  - id: 3f6c1c52-8b0e-4d55-9d8e-2f4a5e1f9a10
                    name: Sales
                    phone: "+919876543210"
                    display_name: Acme
                    verified_name: Acme Ltd
                    status: active
                    is_default: true
                next_cursor: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Get limits and usage
      description: |
        Returns your workspace's limits, what it has used of them, and the
        API switches that are on. Needs the `usage:read` scope.

        A `limit` of `null` means unlimited. `api_requests_per_minute.used` is
        the number of requests your workspace has made in the current minute,
        this one included. The two rate limits are reported as they are
        enforced, so they are never `null`. `contacts.used` is counted at most
        once a minute, so it can be up to 60 seconds old.

        Counts against your workspace's heavy request limit (10 a minute).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The workspace's limits and features.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Usage"
              example:
                limits:
                  api_requests_per_minute: {limit: 600, used: 12}
                  api_messages_per_second: {limit: 20}
                  contacts: {limit: 10000, used: 812}
                  broadcast_messages_per_month: {limit: 50000, used: 1200, period: "2026-10"}
                  webhook_endpoints: {limit: 3, used: 1}
                  api_keys: {limit: 5, used: 2}
                features: [api.contacts, api.messages, webhooks.messages]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"

  /media:
    post:
      operationId: uploadMedia
      tags: [Media]
      summary: Upload a file
      description: |
        Uploads a file to send in a message. Send it as `multipart/form-data`
        with the file in a field called `file`. Needs the `messages:send`
        scope. Other fields are refused.

        Soosh reads the type from the file itself. The `Content-Type` you send
        for the part, and the file's name, are not used to decide it.

        | Kind | Types | Largest |
        |---|---|---|
        | Image | JPEG, PNG | 5 MB |
        | Video | MP4, 3GPP | 14 MB |
        | Audio | AAC, MP4, MPEG (MP3), AMR, OGG | 14 MB |
        | Document | PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV | 14 MB |

        A file over the size for its type, or a request over the endpoint's
        size limit, is refused with `413 too_large`. Anything else, including
        a file whose content isn't what its name says, is `415
        unsupported_media_type`.

        An upload is kept for 30 days. If a message, or a scheduled or running
        broadcast, uses it, it is kept until that is done. Use the returned
        `id` as `media.id` when you send.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              additionalProperties: false
              properties:
                file:
                  type: string
                  format: binary
                  description: The file to upload.
      responses:
        "201":
          description: The file was stored.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Media"
              example:
                id: 7d1f6e0c-3a52-4c0e-9f5e-0b2a7a4f6c11
                mime_type: image/jpeg
                size: 12345
                filename: a.jpg
                expires_at: "2026-11-13T09:30:00.000Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /messages:
    get:
      operationId: listMessages
      tags: [Messages]
      summary: List messages
      description: |
        Returns a page of your workspace's messages, incoming and outgoing,
        newest first. Needs the `messages:read` scope. Use it to catch up on
        anything you missed, for example after your webhook endpoint was down.

        With `sort=updated_at` the list runs oldest change first: keep the
        last `next_cursor`, or pass `updated_after`, to resume later. A
        message whose status changes moves to the end of that list. A change
        shows in it about 30 seconds after it is saved, so when you resume,
        go back a minute and skip messages you already have.

        Listing is read-only. It never marks messages as read, and the
        contact gets no read receipt.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - name: contact_id
          in: query
          description: Only messages with this contact. A contact of another workspace is `422 invalid_field`.
          required: false
          schema:
            type: string
            format: uuid
        - name: number_id
          in: query
          description: Only messages sent from or to this number. A number of another workspace is `422 invalid_field`.
          required: false
          schema:
            type: string
            format: uuid
        - name: direction
          in: query
          description: Only incoming or only outgoing messages.
          required: false
          schema:
            type: string
            enum: [incoming, outgoing]
        - name: updated_after
          in: query
          description: |
            Only messages changed after this time (RFC 3339), and at least
            30 seconds ago. The list then runs oldest change first: leave `sort`
            out or set it to `updated_at` (`sort=created_at` is `400
            invalid_request`).
          required: false
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: A page of messages.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      operationId: sendMessage
      tags: [Messages]
      summary: Send a message
      description: |
        Checks a message and accepts it for sending. Needs the
        `messages:send` scope, and counts against your workspace's
        `api_messages_per_second` limit.

        The answer is `201` with the message in `status: pending`. It goes to
        WhatsApp straight after, from a queue; follow it with the
        `message.sent` and `message.status` webhooks, or with
        `GET /v1/messages/{id}`. Nothing is saved when a check fails.

        **Recipient.** Send `to` (a phone number with its country code) or
        `contact_id`, not both. A number with no contact gets one, with the
        sending number as its number, and counts against your `contacts`
        limit; a contact you deleted is brought back. Both queue
        `contact.created`.

        **Number.** `number_id` if you send it; otherwise the template's
        number, then the contact's, then your default number. It must be
        `active`, or the send is `422 number_unavailable`.

        **The 24-hour window.** Every type but `template` needs the contact to
        have written to the sending number in the last 24 hours, or it's `422
        window_closed`. The window is per number. If WhatsApp disagrees with
        our record, the message fails later with `error.code` 131047.

        **Templates.** Name the template with `template.id`, or with
        `template.name` and `template.language`; a name without its language is
        `422 invalid_field`. When two of your numbers have a template with that
        name and language, add `number_id`, or it's `422 template_ambiguous`.
        With `number_id`, the template must be that number's. It must be
        approved. `body`, `header.text` and `buttons` must hold exactly the
        keys of the template's `variables`: anything missing or extra is `422
        template_params`, with `details.missing` and `details.unexpected`. A
        template with an image, video or document header needs its file in
        `header.media_id` or `header.url`. A marketing template to a contact
        who opted out is `422 contact_opted_out`. For an authentication
        template, `body` is `{"1": "<code>"}`; the code button is filled in for
        you.

        **Files.** `media_id` is an upload from `POST /v1/media` of your
        workspace that hasn't expired. A `url` must be `https://`; Soosh
        downloads it once, within 10 seconds, without following redirects, and
        reads the type from the file. Either way the file is kept with the
        message.

        Sending an `Idempotency-Key` is recommended. A refused send is
        replayed as it was for 24 hours under that key: a `window_closed`
        stays `window_closed`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendMessageRequest"
            examples:
              text:
                summary: Text to a contact
                value:
                  contact_id: 3f6c2a4e-1b7d-4c58-9e0a-6d2f8b1c4e7a
                  type: text
                  text: {body: Your order has shipped.}
              template:
                summary: Template to a phone number
                value:
                  to: "+919876543210"
                  type: template
                  template:
                    name: order_update
                    language: en
                    body: {"1": Asha, "2": "A-1042"}
                    buttons: {"0": "A-1042"}
      responses:
        "201":
          description: The message was accepted and will be sent.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /messages/{id}:
    parameters:
      - $ref: "#/components/parameters/MessageID"
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Get a message
      description: |
        Returns one message. Needs the `messages:read` scope. Reading it never
        marks it as read.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The message.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /messages/{id}/media:
    get:
      operationId: getMessageMedia
      tags: [Media]
      summary: Download a message's file
      description: |
        Returns the bytes of the file a message carries. Needs the
        `messages:read` scope.

        `Content-Type` is the type recorded for the file, or
        `application/octet-stream` if there is none. The file is always sent as
        an attachment, with `X-Content-Type-Options: nosniff`.

        A message with no file, a file that is no longer stored, and a message
        of another workspace are all `404 not_found`.
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The message's ID.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The file.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Content-Disposition:
              description: |
                `attachment`, with the file's name (RFC 6266). The name is given
                as quoted ASCII and as UTF-8 (`filename*`, RFC 5987).
              required: true
              schema:
                type: string
                pattern: "^attachment; filename=\"[^\"\\r\\n]*\"; filename\\*=UTF-8''[A-Za-z0-9!#$&+.^_`|~%-]+$"
            X-Content-Type-Options:
              description: Always `nosniff`.
              required: true
              schema:
                type: string
                enum: [nosniff]
          content:
            "*/*":
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /contacts:
    get:
      operationId: listContacts
      tags: [Contacts]
      summary: List contacts
      description: |
        Returns a page of your workspace's contacts, newest first. Needs the
        `contacts:read` scope.

        With `sort=updated_at` the list runs oldest update first, so a client
        that syncs can keep the last `next_cursor` and resume from it later.
        A contact that changes moves to the end of that list. A change shows
        in it about 30 seconds after it is saved, so when you resume, go back
        a minute and skip contacts you already have.

        Phone masking settings of the app don't apply to keys: a key with
        `contacts:read` is trusted with the numbers.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - name: phone
          in: query
          description: |
            Only the contact with this number. Any spelling of the number works
            (spaces and dashes are ignored, and `00` counts as `+`). A number
            that isn't valid is `422 invalid_phone`.
          required: false
          schema:
            type: string
        - name: tag
          in: query
          description: |
            Only contacts that have this tag. Repeat it to get contacts that
            have any of the tags (at most 50).
          required: false
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
        - name: created_after
          in: query
          description: Only contacts created after this time (RFC 3339).
          required: false
          schema:
            type: string
            format: date-time
        - name: updated_after
          in: query
          description: Only contacts changed after this time (RFC 3339), and at least 30 seconds ago.
          required: false
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: A page of contacts.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      operationId: createContact
      tags: [Contacts]
      summary: Create a contact
      description: |
        Adds a contact. Needs the `contacts:write` scope, and counts against
        your workspace's `contacts` limit.

        - A contact with the same number already in your workspace is `409
          conflict`, and `error.details.id` is that contact.
        - A contact you deleted earlier with the same number is brought back
          with its history, and the fields you send are applied. That also
          returns `201`.
        - A name you give is the team's name for the contact: a new WhatsApp
          profile name from the customer no longer replaces it. It's
          `whatsapp_name` that follows the customer.
        - Tags you haven't used before join your workspace's tag list in gray.

        Queues `contact.created`. Sending an `Idempotency-Key` is recommended.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateContactRequest"
            example:
              phone: "+91 98765 43210"
              name: Asha
              attributes: {city: Pune, orders: 3}
              tags: [vip]
      responses:
        "201":
          description: The contact was created, or brought back.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /contacts/{id}:
    parameters:
      - $ref: "#/components/parameters/ContactID"
    get:
      operationId: getContact
      tags: [Contacts]
      summary: Get a contact
      description: Returns one contact. Needs the `contacts:read` scope.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The contact.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    patch:
      operationId: updateContact
      tags: [Contacts]
      summary: Update a contact
      description: |
        Changes the fields you send and leaves the others. Needs the
        `contacts:write` scope.

        `attributes` are merged into the contact's: a key with a `null` value
        is removed. A contact can have at most 50 attributes. A name is 1 to
        64 letters, digits or underscores and can't start with `__`; a string
        value is at most 1024 characters.

        An empty `name` takes the team's name off, and the WhatsApp profile name
        shows again. Tags are changed with `PUT` and `DELETE
        /contacts/{id}/tags/{tag}`.

        `contact.updated` is queued only if something changed.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateContactRequest"
            example:
              attributes: {city: Mumbai, plan: null}
      responses:
        "200":
          description: The contact as it is now.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      operationId: deleteContact
      tags: [Contacts]
      summary: Delete a contact
      description: |
        Deletes the contact, as the app does: it's hidden from lists and the
        inbox, and what the AI remembered about the customer is erased. Creating
        a contact with the same number brings it back. Needs the
        `contacts:write` scope. Queues `contact.deleted`.
      security:
        - bearerAuth: []
      responses:
        "204":
          description: The contact was deleted.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /contacts/{id}/tags/{tag}:
    parameters:
      - $ref: "#/components/parameters/ContactID"
      - name: tag
        in: path
        required: true
        description: |
          The tag, 1 to 50 characters. Percent-encode characters such as `/`
          (`Sales%2FEMEA`).
        schema:
          type: string
          minLength: 1
          maxLength: 50
    put:
      operationId: addContactTag
      tags: [Contacts]
      summary: Add a tag to a contact
      description: |
        Adds the tag. Adding one the contact already has changes nothing and
        still returns `200`. A tag you haven't used before joins your
        workspace's tag list in gray. A contact can have at most 50 tags.
        Needs the `contacts:write` scope. `contact.updated` is queued only if
        the tag was added.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The contact as it is now.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      operationId: removeContactTag
      tags: [Contacts]
      summary: Remove a tag from a contact
      description: |
        Removes the tag. Removing one the contact doesn't have changes nothing
        and still returns `200`. Needs the `contacts:write` scope.
        `contact.updated` is queued only if the tag was removed.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The contact as it is now.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Contact"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /agents:
    get:
      operationId: listAgents
      tags: [Chats]
      summary: List agents
      description: |
        Returns the members of your workspace who can be assigned chats, by
        name, and whether each is available now. Email addresses are never
        included. The list isn't paged: `next_cursor` is always `null`.
        Needs the `chats:write` scope.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The agents.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentList"
              example:
                data:
                  - id: 5b1d7d0e-3c2a-4f61-9a0e-6f8f3b2c1d40
                    name: Asha Rao
                    available: true
                next_cursor: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /teams:
    get:
      operationId: listTeams
      tags: [Chats]
      summary: List teams
      description: |
        Returns your workspace's active teams, by name. The list isn't paged:
        `next_cursor` is always `null`. Needs the `chats:write` scope.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The teams.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TeamList"
              example:
                data:
                  - id: 9a4e2f1c-7b3d-4c58-8e21-0d6a5b4c3e72
                    name: Sales
                next_cursor: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /contacts/{id}/assign:
    parameters:
      - $ref: "#/components/parameters/ContactID"
    post:
      operationId: assignChat
      tags: [Chats]
      summary: Assign a chat
      description: |
        Gives the contact's chat to an agent or a team, or to nobody. Send one
        of `{"agent_id": "…"}`, `{"team_id": "…"}` or `{"agent_id": null}`.
        Needs the `chats:write` scope.

        - **To an agent:** they must be able to take chats in your workspace
          (see `GET /v1/agents`). The agent takes the chat over from the
          bot: the bot and AI don't answer the customer until the agent is
          unassigned or the chat is closed or reopened. An active handover
          goes on with the agent; any other handover of the chat ends.
        - **To a team:** needs transfers in your plan (`403
          feature_not_in_plan` otherwise, with `details.feature` set to
          `transfers`). The chat is handed over to the team, or its active
          handover moves to the team, and the team's routing picks an agent.
          It's done at any hour, and the customer gets no out-of-hours
          message.
        - **To nobody:** the chat's agent is cleared, and the bot answers
          the customer again unless the chat has an active handover, which
          stays as it is.

        An agent or team that isn't in your workspace is `422 invalid_field`.
        Assigning a chat where it already is changes nothing and still
        returns `200`. `chat.assigned` is queued only if something changed.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AssignChatRequest"
            example:
              agent_id: 5b1d7d0e-3c2a-4f61-9a0e-6f8f3b2c1d40
      responses:
        "200":
          description: The chat as it is now.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatState"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /contacts/{id}/close:
    parameters:
      - $ref: "#/components/parameters/ContactID"
    post:
      operationId: closeChat
      tags: [Chats]
      summary: Close a chat
      description: |
        Closes the contact's chat and ends its active handover. The bot
        answers the customer again, even if an agent is still assigned. It
        opens again when the customer writes, or with
        `POST /v1/contacts/{id}/reopen`.
        Closing a closed chat changes nothing (it keeps when it was first
        closed) and still returns `200`. Takes no body (send none, or `{}`; anything
        else is `400 invalid_request`). Needs the
        `chats:write` scope. `chat.closed` is queued only if the chat was
        open.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The chat as it is now.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatState"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /contacts/{id}/reopen:
    parameters:
      - $ref: "#/components/parameters/ContactID"
    post:
      operationId: reopenChat
      tags: [Chats]
      summary: Reopen a chat
      description: |
        Reopens the contact's chat, with the bot answering the customer
        again, even if an agent is still assigned. Reopening an open chat
        changes nothing else and still returns `200`. Takes no body (send none, or `{}`; anything
        else is `400 invalid_request`). Needs the `chats:write` scope.
        `chat.reopened` is queued only if the chat was closed.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The chat as it is now.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatState"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List templates
      description: |
        Returns a page of your workspace's templates, newest first. Needs the
        `templates:read` scope. Every filter is optional, and the ones you give
        all have to match.

        With `sort=updated_at` the list runs oldest update first, so a client
        that syncs can keep the last `next_cursor` and resume from it later.
        A template whose status changes moves to the end of that list. A
        change shows in it about 30 seconds after it is saved, so when you
        resume, go back a minute and skip templates you already have.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Sort"
        - name: number_id
          in: query
          description: |
            Only templates on this number (see `GET /v1/numbers`). A number
            that isn't in your workspace matches nothing.
          required: false
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          description: Only templates with this status, for example `APPROVED`. Case doesn't matter.
          required: false
          schema:
            type: string
        - name: name
          in: query
          description: Only templates with exactly this name (every language of it).
          required: false
          schema:
            type: string
        - name: language
          in: query
          description: Only templates in this language, for example `en` or `pt_BR`.
          required: false
          schema:
            type: string
        - name: category
          in: query
          description: Only templates in this category, for example `MARKETING`. Case doesn't matter.
          required: false
          schema:
            type: string
      responses:
        "200":
          description: A page of templates.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TemplateList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      operationId: createTemplate
      tags: [Templates]
      summary: Create a template
      description: |
        Creates a template and submits it to WhatsApp for approval, then
        returns it with the status `PENDING`. Needs the `templates:write`
        scope. Watch for the approval with `GET /v1/templates/{id}`.

        - Only text headers can be made here. A template with an image, video
          or document header is `422 invalid_field` on `header.format`: those
          are made in the app.
        - Authentication templates are made in the app too.
        - A template is the same one only in the same language. The same name
          and language on the same number is `409 conflict`, and
          `error.details.id` is the existing template. The same name in
          another language is a new template.
        - If a body, header or link has variables, give an example for each
          (`body_examples`, `header.example`, `buttons[].example`), as
          WhatsApp requires them for review.
        - A `COPY_CODE` button needs an `example` code. Its value is required
          again when you send the template: `variables.buttons` of the
          template lists it, and `POST /v1/messages` must supply it.
        - If WhatsApp refuses the template, or can't be reached, the
          template is kept as a `DRAFT` in the app, and the response is `422
          invalid_field` with WhatsApp's message and `error.details.id` set to
          the draft.

        Sending an `Idempotency-Key` is recommended.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTemplateRequest"
            example:
              number_id: 0f6d3c1e-5b7a-4c52-9a55-3f6a2d5c8b10
              name: order_update
              language: en
              category: UTILITY
              header: {format: TEXT, text: "Order {{1}}", example: "A-1001"}
              body: "Hi {{1}}, your order is on its way."
              body_examples: [Asha]
              footer: Thanks for shopping with us
              buttons:
                - {type: URL, text: Track, url: "https://x.co/{{1}}", example: "A-1001"}
      responses:
        "201":
          description: The template was created and submitted to WhatsApp.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /templates/{id}:
    parameters:
      - $ref: "#/components/parameters/TemplateID"
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Get a template
      description: Returns one template. Needs the `templates:read` scope.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The template.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      operationId: deleteTemplate
      tags: [Templates]
      summary: Delete a template
      description: |
        Deletes the template on WhatsApp, by its ID, so only this language of
        the name goes, and then in Soosh. Needs the `templates:write` scope.

        A template that a scheduled, queued, processing or paused broadcast
        uses can't be deleted: that is `409 invalid_state`. Cancel the
        broadcast or wait for it to finish. A draft that was never submitted
        is only removed from Soosh. If WhatsApp refuses to delete the
        template, it stays, and the response is `422 invalid_field` with
        WhatsApp's message.
      security:
        - bearerAuth: []
      responses:
        "204":
          description: The template was deleted.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"

  /broadcasts:
    get:
      operationId: listBroadcasts
      tags: [Broadcasts]
      summary: List broadcasts
      description: |
        Returns a page of your workspace's broadcasts, newest first, made over
        the API or in the app (campaigns still being drafted in the app aren't
        listed). Needs the `broadcasts:read` scope.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          description: Only broadcasts with this status. Any other value is `400 invalid_request`.
          required: false
          schema:
            type: string
            enum: [preparing, scheduled, queued, processing, paused, completed, cancelled, failed, missed]
        - name: created_after
          in: query
          description: Only broadcasts made after this time (RFC 3339).
          required: false
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: A page of broadcasts.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BroadcastList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      operationId: createBroadcast
      tags: [Broadcasts]
      summary: Create a broadcast
      description: |
        Sends one approved template to up to 10,000 recipients you list, or
        to your contacts with some tags (`audience`), now or at `send_at`.
        Pass exactly one of `recipients` and `audience`. Needs the
        `broadcasts:write` scope. Counts against your workspace's heavy
        request limit (10 a minute), and its messages against
        `broadcast_messages_per_month`.

        Everything is checked before anything is saved:

        - The template is found by `template.id`, or by `template.name` and
          `template.language` on the sending number. With the same name on
          more than one number, pass `number_id` (`422 template_ambiguous`).
          It must be approved (`422 template_not_approved`), and the number
          active (`422 number_unavailable`). Authentication templates can't
          be broadcast; send them with `POST /v1/messages`.
        - A template with an image, video or document header needs
          `template.header.media_id`, an upload from `POST /v1/media`.
        - Every recipient's `body`, `header` and `buttons` keys must match
          `Template.variables` exactly. If any don't, nothing is saved and the
          response is one `422 template_params`, whose `error.details.rows`
          lists up to 100 bad rows as `{index, missing, unexpected}`.
        - The month's sent messages, plus those being sent, plus the pending
          recipients of your other queued, processing or scheduled broadcasts
          that send that month, plus this broadcast's recipients, must fit in
          `broadcast_messages_per_month` (`422 limit_reached`). The check is a
          guide: a broadcast that reaches the limit while sending pauses, with
          `paused_reason` saying so.

        Numbers that aren't valid, repeats of a number already listed (the
        two ways WhatsApp writes Mexican and Argentine mobiles count as one
        number), and, for a MARKETING template, contacts who opted out of
        marketing messages are left out and counted in `skipped`. If that
        leaves nobody, the response is `422 invalid_field`.

        **With `audience`**, the broadcast is returned at once as
        `preparing`, and its contacts are collected in the background, a
        moment later: the contacts that already existed when you made the
        broadcast and, when they are collected, have any of `tags` and none
        of `exclude_tags`, cleaned as above. A contact added after you made
        the broadcast isn't sent to, even if it has the tags; a contact that
        existed already is sent to if it has the tags when collected, even if
        it was tagged after you made the broadcast. Each variable
        says where its value comes from (`from`), with a `fallback` for a
        contact without a usable value; a fallback is required for
        `contact.name` and `attribute:` sources. The monthly allowance is
        checked once the audience is collected: if it doesn't fit, or nobody
        can be sent to, the broadcast becomes `failed` with `paused_reason`
        saying why. Otherwise it sends, or becomes `scheduled`. Watch it with
        `GET /v1/broadcasts/{id}`.

        The broadcast runs as the key that made it: if the key is revoked or
        loses `broadcasts:write`, it pauses with `paused_reason` "API access
        for this broadcast was removed". Sending an `Idempotency-Key` is
        recommended.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        description: Up to 4 MB.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBroadcastRequest"
            example:
              name: Diwali offer
              template:
                name: diwali
                language: en
              recipients:
                - to: "+919876543210"
                  name: Asha
                  body: {"1": Asha}
                  buttons: {"0": ASHA10}
              send_at: "2026-11-01T04:30:00Z"
      responses:
        "201":
          description: |
            The broadcast was created. With `audience`, it is `preparing`.
            Sent now, it is `processing`, or
            `queued` while its messages are queued: usually a moment, but if
            queueing has to be retried it stays `queued` for up to about 5
            minutes and then sends. With `send_at`, it is `scheduled`. If its
            key can no longer run it when it starts, it is `paused`, with
            `paused_reason` saying so.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Broadcast"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"

  /broadcasts/{id}:
    parameters:
      - $ref: "#/components/parameters/BroadcastID"
    get:
      operationId: getBroadcast
      tags: [Broadcasts]
      summary: Get a broadcast
      description: Returns one broadcast, with its counts so far. Needs the `broadcasts:read` scope.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The broadcast.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Broadcast"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /broadcasts/{id}/recipients:
    parameters:
      - $ref: "#/components/parameters/BroadcastID"
    get:
      operationId: listBroadcastRecipients
      tags: [Broadcasts]
      summary: List a broadcast's recipients
      description: |
        Returns a page of a broadcast's recipients and what happened to each
        message, newest first. Needs the `broadcasts:read` scope. A broadcast
        still `preparing` lists the recipients collected so far.

        Phone masking settings of the app don't apply to keys: a key with
        `broadcasts:read` sees recipients' full numbers.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          description: Only recipients with this status. Any other value is `400 invalid_request`.
          required: false
          schema:
            type: string
            enum: [pending, sent, delivered, read, failed]
      responses:
        "200":
          description: A page of recipients.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BroadcastRecipientList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /broadcasts/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/BroadcastID"
    post:
      operationId: cancelBroadcast
      tags: [Broadcasts]
      summary: Cancel a broadcast
      description: |
        Stops a broadcast for good. Works while it is `preparing`,
        `scheduled`, `queued`, `processing` or `paused`; any other status
        (including one that completed a moment before) is `409
        invalid_state`. Messages already being sent finish; the rest are
        never sent and stay `pending`. Takes no body (send none, or `{}`; anything
        else is `400 invalid_request`). Needs the
        `broadcasts:write` scope.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The broadcast, now `cancelled`.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Broadcast"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /broadcasts/{id}/resume:
    parameters:
      - $ref: "#/components/parameters/BroadcastID"
    post:
      operationId: resumeBroadcast
      tags: [Broadcasts]
      summary: Resume a broadcast
      description: |
        Carries on with a `paused` broadcast; any other status is `409
        invalid_state`. Takes no body (send none, or `{}`; anything
        else is `400 invalid_request`). Needs the `broadcasts:write` scope.

        The checks of a start run again first: the template must still be
        approved (`422 template_not_approved`), and its pending recipients
        must fit in `broadcast_messages_per_month` (`422 limit_reached`). If
        a check fails, it stays paused.

        From then on the broadcast runs as the key that resumed it, so a
        broadcast paused because its key was revoked ("API access for this
        broadcast was removed") is resumed with another key, or by a person
        in the app. It sends now (`processing`), or goes back to `scheduled`
        if its `send_at` is still to come, or to `preparing` if it was paused
        before its audience was collected.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The broadcast as it is now.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Broadcast"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"

  /webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhook endpoints
      description: |
        Returns a page of your workspace's webhook endpoints, newest first.
        Needs the `webhooks:read` scope. Secrets and custom header values are
        never returned.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of webhook endpoints.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Create a webhook endpoint
      description: |
        Adds an endpoint that Soosh sends events to. Needs the `webhooks:write`
        scope, plus the read scope of every event it subscribes to:
        `messages:read` for `message.*`, `contacts:read` for `contact.*`,
        `templates:read` for `template.*`, `broadcasts:read` for `broadcast.*`
        and `chats:write` for `chat.*` (`403 scope_missing`). Each event's
        switch must be in your plan (`403 feature_not_in_plan`), and the
        workspace's `webhook_endpoints` limit applies (`422 limit_reached`).

        The URL must be `https://`, with no username or password, and can't
        point to an internal or private address. Custom headers: at most 10,
        names of 1 to 64 letters, digits or hyphens, values of at most 1024
        characters with no line breaks or control characters. `Host`,
        `Content-Length`, `Content-Type`, `Content-Encoding`,
        `Transfer-Encoding`, `TE`, `Trailer`, `Connection`, `Keep-Alive`,
        `Upgrade`, `Expect`, `User-Agent`, `Proxy-*` and `Webhook-*` can't be
        set.

        The response carries the endpoint's signing `secret`. This is the only
        time the API shows it, so store it now; people who can edit webhooks
        can show it again in the app.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWebhookRequest"
            example:
              url: https://hooks.example.com/soosh
              description: Order updates
              events: [message.status, contact.created]
              headers: {X-Api-Token: abc123}
      responses:
        "201":
          description: The endpoint was created. Its secret is in the body, this once.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControlNoStore"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookWithSecret"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /webhooks/{id}:
    parameters:
      - $ref: "#/components/parameters/WebhookID"
    get:
      operationId: getWebhook
      tags: [Webhooks]
      summary: Get a webhook endpoint
      description: Returns one endpoint, without its secret. Needs the `webhooks:read` scope.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The endpoint.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Webhook"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    patch:
      operationId: updateWebhook
      tags: [Webhooks]
      summary: Change a webhook endpoint
      description: |
        Changes the fields you send; the others keep their values. Needs the
        `webhooks:write` scope, and the read scope of every event the endpoint
        will send, including events it already has. The rules of `POST
        /webhooks` apply to the endpoint as it will be, except that an event
        it already has stays when its switch has since left your plan.

        `headers` replaces the custom headers. Send a header with the value
        `********` (as the API returns it) to keep its value. A `url` on
        another server (scheme, host or port) needs every custom header's
        value sent again: a kept or `********` value is `422 invalid_field`
        on `headers.<name>`, so a saved value is only ever sent where it was
        set. A new `url` or new `headers` also lift a pause. An endpoint
        named after its URL's host is renamed after the new one.

        `enabled: true` turns back on an endpoint Soosh turned off, and makes
        it yours: an endpoint turned back on by a key belongs to that key,
        and stops when that key does.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateWebhookRequest"
            example:
              enabled: true
      responses:
        "200":
          description: The endpoint as it is now.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Webhook"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      description: |
        Deletes the endpoint. Events waiting to be sent to it are never sent.
        Needs the `webhooks:write` scope.
      security:
        - bearerAuth: []
      responses:
        "204":
          description: The endpoint was deleted.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
  /webhooks/{id}/rotate-secret:
    parameters:
      - $ref: "#/components/parameters/WebhookID"
    post:
      operationId: rotateWebhookSecret
      tags: [Webhooks]
      summary: Rotate a webhook endpoint's secret
      description: |
        Gives the endpoint a new signing secret, returned this once. The old
        secret keeps signing for `overlap_hours` (24 by default, at most 168;
        0 stops it at once), and meanwhile every request carries both
        signatures in `webhook-signature`, separated by a space, so you can
        switch over without missing an event. Needs the `webhooks:write`
        scope and the read scope of every event the endpoint sends, as a
        change does.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RotateWebhookSecretRequest"
            example:
              overlap_hours: 24
      responses:
        "200":
          description: The new secret, shown this once.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/XRequestID"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
            Original-Request-ID:
              $ref: "#/components/headers/OriginalRequestID"
            Cache-Control:
              $ref: "#/components/headers/CacheControlNoStore"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSecret"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/Unavailable"
webhooks:
  # Events sent to webhook endpoints (spec §7). Every event is a POST of the
  # envelope (EventEnvelope), signed as Standard Webhooks; `data` is the
  # object below, the same object the API returns.
  message.received:
    post:
      operationId: messageReceivedEvent
      security: []
      tags: [Webhooks]
      summary: A customer's message was stored
      description: >-
        Sent when a message from a customer is stored. `data` is the Message.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessageReceivedEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  message.sent:
    post:
      operationId: messageSentEvent
      security: []
      tags: [Webhooks]
      summary: WhatsApp accepted an outgoing message
      description: >-
        Sent when WhatsApp accepts any outgoing message: from a person, an API key, the chatbot, the AI, an automation, a broadcast or the WhatsApp Business app. `data` is the Message.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessageSentEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  message.status:
    post:
      operationId: messageStatusEvent
      security: []
      tags: [Webhooks]
      summary: A message's status moved forward
      description: >-
        Sent when a message is delivered, read or fails. A status only moves forward: a late or repeated status from WhatsApp sends nothing. Order by `at`, the time WhatsApp reported it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessageStatusEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  contact.created:
    post:
      operationId: contactCreatedEvent
      security: []
      tags: [Webhooks]
      summary: A contact was added
      description: >-
        Sent for every new contact, however it came in (a message, the app, the API, an import, a broadcast or the WhatsApp Business app), and for a deleted contact brought back. `data` is the Contact.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactCreatedEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  contact.updated:
    post:
      operationId: contactUpdatedEvent
      security: []
      tags: [Webhooks]
      summary: A contact was changed
      description: >-
        Sent when a contact's name, attributes, tags, opt-out or number changed. `changed` lists only the fields that changed. A change to the customer's WhatsApp profile name alone sends nothing.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactUpdatedEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  contact.deleted:
    post:
      operationId: contactDeletedEvent
      security: []
      tags: [Webhooks]
      summary: A contact was deleted
      description: >-
        Sent when a contact is deleted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactDeletedEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  template.status:
    post:
      operationId: templateStatusEvent
      security: []
      tags: [Webhooks]
      summary: A template's status changed
      description: >-
        Sent when WhatsApp reports a new status for a template, with the status it had and WhatsApp's reason.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TemplateStatusEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  chat.assigned:
    post:
      operationId: chatAssignedEvent
      security: []
      tags: [Webhooks]
      summary: A chat changed hands
      description: >-
        Sent when the agent or team a chat is with changed: in the app, over the API, by the chatbot or by a handover, including a handover ending (resumed, the chat closed, or closed by the SLA). `agent_id` is the contact's assigned agent (its `chat.assigned_agent_id`) and `team_id` the team of its active handover, both as they are once the change is made, so the event always matches the contact. Both are null once nobody has it. A change that moves neither, such as a handover to the general queue, sends nothing.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChatAssignedEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  chat.closed:
    post:
      operationId: chatClosedEvent
      security: []
      tags: [Webhooks]
      summary: A chat was closed
      description: >-
        Sent when a chat is closed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChatClosedEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  chat.reopened:
    post:
      operationId: chatReopenedEvent
      security: []
      tags: [Webhooks]
      summary: A chat was reopened
      description: >-
        Sent when a closed chat is reopened, including by the customer writing again; `by` is then null.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChatReopenedEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.
  broadcast.status:
    post:
      operationId: broadcastStatusEvent
      security: []
      tags: [Webhooks]
      summary: A broadcast's status changed
      description: >-
        Sent on every change of a broadcast's status, from when it is created. `data` is the Broadcast.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BroadcastStatusEvent"
      responses:
        "200":
          description: Any 2xx response says the event was received.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: soosh_live_ key
      description: |
        A workspace key, created in Settings. Send it as
        `Authorization: Bearer soosh_live_…`. A missing, malformed, unknown,
        revoked or expired key all get the same `401 unauthorized`.

  parameters:
    ContactID:
      name: id
      in: path
      required: true
      description: The contact's ID.
      schema:
        type: string
        format: uuid
    MessageID:
      name: id
      in: path
      required: true
      description: The message's ID.
      schema:
        type: string
        format: uuid
    TemplateID:
      name: id
      in: path
      required: true
      description: The template's ID.
      schema:
        type: string
        format: uuid
    BroadcastID:
      name: id
      in: path
      required: true
      description: The broadcast's ID.
      schema:
        type: string
        format: uuid
    WebhookID:
      name: id
      in: path
      required: true
      description: The webhook endpoint's ID.
      schema:
        type: string
        format: uuid
    Limit:
      name: limit
      in: query
      description: How many items to return, from 1 to 100.
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    Cursor:
      name: cursor
      in: query
      description: |
        The `next_cursor` of the previous page. A cursor that was altered, or
        that was made for a different `sort`, is refused with `400
        invalid_request`.
      required: false
      schema:
        type: string
        minLength: 1
    Sort:
      name: sort
      in: query
      description: |
        `created_at` (the default) lists newest first. `updated_at` lists
        oldest update first, so a client that syncs can resume where it
        stopped. It lists only changes at least 30 seconds old: a change
        still being saved can carry an earlier time than one already
        listed, and the wait lets almost every one of them land first. It
        can't promise every one, so poll with an overlap: resume a minute
        before your last cursor or `updated_after`, and skip rows whose
        `updated_at` you already have.
      required: false
      schema:
        type: string
        enum: [created_at, updated_at]
        default: created_at
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Makes a `POST` safe to retry. 1 to 255 printable ASCII characters,
        kept for 24 hours for the API key that sent it and the keys rotated
        from it.
      required: false
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: "^[\\x20-\\x7E]+$"

  headers:
    XRequestID:
      description: |
        The ID of this request. A valid `X-Request-ID` you send (up to 64
        characters of `A-Z a-z 0-9 _ -`) is reused; otherwise one is made.
        It's also in the `request_id` of an error.
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 64
        pattern: "^[A-Za-z0-9_-]+$"
    XRateLimitLimit:
      description: How many requests your workspace can make per minute.
      required: true
      schema:
        type: integer
        minimum: 0
    XRateLimitRemaining:
      description: How many requests your workspace has left in this window.
      required: true
      schema:
        type: integer
        minimum: 0
    XRateLimitReset:
      description: Seconds until the window resets.
      required: true
      schema:
        type: integer
        minimum: 0
    XRateLimitLimitAfter:
      description: |
        How many requests your workspace can make per minute. On an error,
        present once the request got as far as the rate limit (after the key
        and its allowed IPs were checked).
      required: false
      schema:
        type: integer
        minimum: 0
    XRateLimitRemainingAfter:
      description: How many requests your workspace has left in this window, once the request got as far as the rate limit.
      required: false
      schema:
        type: integer
        minimum: 0
    XRateLimitResetAfter:
      description: Seconds until the window resets, once the request got as far as the rate limit.
      required: false
      schema:
        type: integer
        minimum: 0
    RetryAfter:
      description: Seconds to wait before trying again.
      required: true
      schema:
        type: integer
        minimum: 1
    Allow:
      description: The methods the path does take.
      required: true
      schema:
        type: string
        minLength: 1
    CacheControl:
      description: |
        `no-store` on a replay (`Idempotent-Replayed: true`), whose stored
        answer can hold a secret, and on answers that hold one.
      required: false
      schema:
        type: string
        examples: [no-store]
    CacheControlNoStore:
      description: |
        `no-store`: the answer holds a signing secret, which no cache may keep.
      required: true
      schema:
        type: string
        enum: [no-store]
    IdempotentReplayed:
      description: |
        `true` when this is the stored response of an earlier request with the
        same `Idempotency-Key`. A replay also carries `Original-Request-ID`.
      required: false
      schema:
        type: string
        enum: ["true"]
    OriginalRequestID:
      description: |
        On a replay (`Idempotent-Replayed: true`), the `X-Request-ID` of the
        request that made the stored response. The body is that response byte
        for byte, so an error body's `request_id` is this ID, while
        `X-Request-ID` is the replay's own.
      required: false
      schema:
        type: string

  schemas:
    Contact:
      type: object
      description: A person you talk to on WhatsApp.
      required:
        - id
        - phone
        - whatsapp_user_id
        - name
        - whatsapp_name
        - attributes
        - tags
        - marketing_opt_out
        - number_id
        - chat
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        phone:
          type: [string, "null"]
          description: |
            The number as `+` and its digits. `null` for a contact known only by
            a WhatsApp username, who has `whatsapp_user_id` instead.
          examples: ["+919876543210"]
        whatsapp_user_id:
          type: [string, "null"]
          description: The contact's WhatsApp username ID, if WhatsApp has given us one.
        name:
          type: [string, "null"]
          description: |
            The name your business gave the contact, in the app or through the
            API. `null` if nobody has. A new WhatsApp profile name never
            replaces it.
        whatsapp_name:
          type: [string, "null"]
          description: The contact's own WhatsApp profile name.
        attributes:
          $ref: "#/components/schemas/ContactAttributes"
        tags:
          type: array
          items:
            type: string
        marketing_opt_out:
          type: boolean
          description: Whether the contact opted out of marketing messages.
        number_id:
          type: [string, "null"]
          format: uuid
          description: The WhatsApp number the contact is usually messaged from.
        chat:
          type: object
          description: |
            The contact's chat. `last_inbound_at` covers any number. The
            24-hour window is per number, so don't work it out from this field.
          required: [status, assigned_agent_id, last_message_at, last_inbound_at]
          properties:
            status:
              type: string
              enum: [open, closed]
            assigned_agent_id:
              type: [string, "null"]
              format: uuid
            last_message_at:
              type: [string, "null"]
              format: date-time
            last_inbound_at:
              type: [string, "null"]
              format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    Agent:
      type: object
      description: Someone a chat can be assigned to. Never includes an email address.
      required: [id, name, available]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        available:
          type: boolean
          description: Whether the agent has set themselves as available now.
    AgentList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Agent"
        next_cursor:
          type: "null"
          description: Always `null`, because agents aren't paged.
    Team:
      type: object
      description: A team a chat can be handed to.
      required: [id, name]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
    TeamList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Team"
        next_cursor:
          type: "null"
          description: Always `null`, because teams aren't paged.
    AssignChatRequest:
      type: object
      description: |
        Exactly one of `agent_id` (an agent's ID, or `null` to unassign the
        chat) and `team_id`.
      additionalProperties: false
      minProperties: 1
      maxProperties: 1
      properties:
        agent_id:
          type: [string, "null"]
          format: uuid
          description: The agent to give the chat to, from `GET /v1/agents`. `null` unassigns it.
        team_id:
          type: string
          format: uuid
          description: The team to hand the chat to, from `GET /v1/teams`. Needs transfers in your plan.
    ChatState:
      type: object
      description: |
        Where a contact's chat is after an assign, close or reopen. `contact`
        is the whole contact only when your key also has the `contacts:read`
        scope; otherwise it is `null`, so `chats:write` alone never reads a
        contact's number, name, attributes or tags.
      required: [contact_id, status, assigned_agent_id, team_id, contact]
      additionalProperties: false
      properties:
        contact_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [open, closed]
        assigned_agent_id:
          type: [string, "null"]
          format: uuid
          description: The agent the chat is assigned to, or `null`.
        team_id:
          type: [string, "null"]
          format: uuid
          description: The team the chat is handed over to, while it has an active handover; `null` otherwise.
        contact:
          description: The contact as it is now, with `contacts:read`; `null` without it.
          oneOf:
            - $ref: "#/components/schemas/Contact"
            - type: "null"
    ContactAttributes:
      type: object
      description: |
        Your own fields on the contact. Values are a string (up to 1024
        characters), a number, a boolean or `null`. Names are 1 to 64 letters,
        digits or underscores, and don't start with `__`. A contact can have at
        most 50. In a request, a `null` removes an attribute and doesn't count.
      additionalProperties:
        type: [string, number, boolean, "null"]
    ContactList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Contact"
        next_cursor:
          type: [string, "null"]
          description: Pass it as `cursor` to get the next page. `null` means the end.
    CreateContactRequest:
      type: object
      required: [phone]
      additionalProperties: false
      properties:
        phone:
          type: string
          description: |
            The number with its country code, with or without `+`. Spaces, dashes,
            dots and brackets are ignored, and a leading `00` counts as `+`.
          examples: ["+919876543210"]
        name:
          type: string
          maxLength: 255
          description: The name your business gives the contact.
        attributes:
          $ref: "#/components/schemas/ContactAttributes"
        tags:
          type: array
          maxItems: 50
          items:
            type: string
            minLength: 1
            maxLength: 50
        number_id:
          type: string
          format: uuid
          description: The WhatsApp number to message the contact from (see `GET /numbers`).
        marketing_opt_out:
          type: boolean
    UpdateContactRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          maxLength: 255
          description: The name your business gives the contact. Empty takes it off.
        attributes:
          type: object
          description: |
            Merged into the contact's attributes. A `null` value removes the
            key. The result can have at most 50 attributes.
          additionalProperties:
            type: [string, number, boolean, "null"]
        number_id:
          type: string
          format: uuid
        marketing_opt_out:
          type: boolean
    Number:
      type: object
      description: A WhatsApp number connected to your workspace.
      required: [id, name, phone, display_name, verified_name, status, is_default]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: The name you gave the number in Soosh.
        phone:
          type: string
          description: The number as `+` and its digits, or empty if Soosh doesn't know it yet.
          examples: ["+919876543210"]
        display_name:
          type: string
          description: The name WhatsApp shows for the number.
        verified_name:
          type: string
          description: The business name WhatsApp has verified for the number.
        status:
          type: string
          description: |
            `active`, or `disconnected` for a number that isn't ready to send
            (it is still being set up, or WhatsApp has disconnected it). Every
            other state is reported as one of these two.
          enum: [active, disconnected]
        is_default:
          type: boolean
          description: Whether messages are sent from this number when none is chosen.
    NumberList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Number"
        next_cursor:
          type: "null"
          description: Always `null`, because numbers aren't paged.
    Limit:
      type: object
      description: One limit and, where it has a count, what is used of it.
      required: [limit]
      properties:
        limit:
          type: [integer, "null"]
          minimum: 0
          description: The limit. `null` is unlimited.
        used:
          type: integer
          minimum: 0
          description: What the workspace uses of the limit now.
        period:
          type: string
          description: The month a monthly limit counts, as `YYYY-MM` in UTC.
          examples: ["2026-10"]
    Usage:
      type: object
      required: [limits, features]
      properties:
        limits:
          type: object
          required:
            - api_requests_per_minute
            - api_messages_per_second
            - contacts
            - broadcast_messages_per_month
            - webhook_endpoints
            - api_keys
          properties:
            api_requests_per_minute:
              allOf:
                - $ref: "#/components/schemas/Limit"
              required: [limit, used]
            api_messages_per_second:
              $ref: "#/components/schemas/Limit"
            contacts:
              allOf:
                - $ref: "#/components/schemas/Limit"
              required: [limit, used]
            broadcast_messages_per_month:
              allOf:
                - $ref: "#/components/schemas/Limit"
              required: [limit, used, period]
            webhook_endpoints:
              allOf:
                - $ref: "#/components/schemas/Limit"
              required: [limit, used]
            api_keys:
              allOf:
                - $ref: "#/components/schemas/Limit"
              required: [limit, used]
        features:
          type: array
          description: |
            The API and webhook switches that are on for your workspace,
            sorted: `api.*` and `webhooks.*`. Open: switches may be added.
          items:
            type: string
          examples:
            - [api.messages, api.contacts, webhooks.messages]
    Media:
      type: object
      description: A file uploaded with `POST /media`.
      required: [id, mime_type, size, filename, expires_at]
      properties:
        id:
          type: string
          format: uuid
        mime_type:
          type: string
          description: The type Soosh found by reading the file.
          examples: [image/jpeg]
        size:
          type: integer
          description: The size in bytes.
        filename:
          type: string
          description: The file's name, without any directory.
        expires_at:
          type: string
          format: date-time
          description: |
            When the upload is deleted, 30 days after it was made. A message, or
            a scheduled or running broadcast, that uses it keeps it longer.
    Actor:
      type: object
      description: Who did something.
      required: [type, id, name]
      properties:
        type:
          type: string
          description: Open enum; values may be added.
          enum: [user, api_key, bot, ai, automation, broadcast, whatsapp_app, system]
        id:
          type: [string, "null"]
          format: uuid
          description: The person, key or broadcast. `null` for actors without one, such as the chatbot.
        name:
          type: string
    Message:
      type: object
      description: A WhatsApp message, incoming or outgoing.
      required: [id, contact_id, number_id, direction, type, status, text, media, template, interactive,
        reply_to_message_id, whatsapp_message_id, error, sent_by, broadcast_id, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
        contact_id:
          type: string
          format: uuid
        number_id:
          type: [string, "null"]
          format: uuid
          description: The number it was sent from or to. `null` if that number was removed.
        direction:
          type: string
          enum: [incoming, outgoing]
        type:
          type: string
          description: |
            Open enum: handle values you don't know. Known values are `text`,
            `image`, `video`, `audio`, `document`, `sticker`, `template`,
            `interactive`, `button_reply`, `list_reply`, `flow_reply`,
            `location`, `contact`, `reaction` and `unsupported`.
          examples: [text]
        status:
          type: string
          description: |
            `pending` (accepted, not yet with WhatsApp), `sent`, `delivered`,
            `read` or `failed` for outgoing messages; `received` for incoming
            ones.
          enum: [pending, sent, delivered, read, failed, received]
        text:
          description: |
            The text. For location, contact, reaction, reply and flow messages
            it's a readable summary.
          oneOf:
            - type: "null"
            - type: object
              required: [body]
              properties:
                body:
                  type: string
        media:
          description: The file the message carries, or a template's header file.
          oneOf:
            - type: "null"
            - type: object
              required: [url, mime_type, filename, caption]
              properties:
                url:
                  type: string
                  description: Where to download it, `/v1/messages/{id}/media`.
                mime_type:
                  type: [string, "null"]
                filename:
                  type: [string, "null"]
                caption:
                  type: [string, "null"]
        template:
          description: The template it was sent with.
          oneOf:
            - type: "null"
            - type: object
              required: [id, name, language]
              properties:
                id:
                  type: [string, "null"]
                  format: uuid
                name:
                  type: string
                language:
                  type: [string, "null"]
        interactive:
          description: Reply buttons or a link button the message offered.
          oneOf:
            - type: "null"
            - type: object
              required: [type, body, buttons, url, display_text]
              properties:
                type:
                  type: string
                  enum: [buttons, cta_url]
                body:
                  type: string
                buttons:
                  type: array
                  items:
                    $ref: "#/components/schemas/MessageButton"
                url:
                  type: [string, "null"]
                display_text:
                  type: [string, "null"]
        reply_to_message_id:
          type: [string, "null"]
          format: uuid
          description: The message it quotes.
        whatsapp_message_id:
          type: [string, "null"]
          description: WhatsApp's ID for the message, once WhatsApp has it.
        error:
          description: Why the message failed.
          oneOf:
            - type: "null"
            - type: object
              required: [code, message]
              properties:
                code:
                  type: integer
                  description: WhatsApp's error code, or 0 if it isn't known.
                message:
                  type: string
        sent_by:
          description: Who sent an outgoing message. `null` for incoming ones.
          oneOf:
            - type: "null"
            - $ref: "#/components/schemas/Actor"
        broadcast_id:
          type: [string, "null"]
          format: uuid
          description: The broadcast that sent it.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    MessageButton:
      type: object
      required: [id, title]
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 256
        title:
          type: string
          minLength: 1
          maxLength: 20
    MessageList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Message"
        next_cursor:
          type: [string, "null"]
          description: Pass it as `cursor` to get the next page. `null` means the end.
    SendMessageRequest:
      type: object
      description: |
        A message to send. Exactly one of `to` and `contact_id`, and the one
        content object that goes with `type`: `text`, `media` (for `image`,
        `video`, `audio` and `document`), `template` or `interactive`.
      additionalProperties: false
      required: [type]
      properties:
        to:
          type: string
          description: |
            The phone number, with its country code. Spaces, dashes, dots and
            brackets are ignored, and `00` counts as `+`.
          examples: ["+919876543210"]
        contact_id:
          type: string
          format: uuid
        number_id:
          type: string
          format: uuid
          description: The number to send from.
        type:
          type: string
          enum: [text, image, video, audio, document, template, interactive]
        text:
          type: object
          additionalProperties: false
          required: [body]
          properties:
            body:
              type: string
              minLength: 1
              maxLength: 4096
            preview_url:
              type: boolean
              default: false
              description: Show a preview of the first link in the text.
        media:
          type: object
          description: The file, as exactly one of `media_id` and `url`.
          additionalProperties: false
          properties:
            media_id:
              type: string
              format: uuid
              description: An upload from `POST /v1/media`.
            url:
              type: string
              format: uri
              description: An `https://` link to the file.
            caption:
              type: string
              maxLength: 1024
              description: Not for audio.
            filename:
              type: string
              description: The name the customer sees for a document.
        template:
          type: object
          description: |
            The template, by `id` or by `name` and `language` (required with
            `name`), and its variables.
          additionalProperties: false
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            language:
              type: string
              examples: [en]
            body:
              type: object
              description: The body's variables, keyed exactly as `variables.body` lists them.
              additionalProperties:
                type: string
            header:
              type: object
              description: |
                The header's variable as `text`, or the file of an image,
                video or document header as one of `media_id` and `url`.
              additionalProperties: false
              properties:
                text:
                  type: string
                media_id:
                  type: string
                  format: uuid
                url:
                  type: string
                  format: uri
            buttons:
              type: object
              description: |
                Button variables by the button's index, keyed exactly as
                `variables.buttons` lists them: a link's suffix, or a coupon
                code.
              additionalProperties:
                type: string
        interactive:
          type: object
          description: |
            Up to 3 reply buttons (`buttons`), or one link button
            (`cta_url`, with `url` and `display_text`).
          additionalProperties: false
          required: [type, body]
          properties:
            type:
              type: string
              enum: [buttons, cta_url]
            body:
              type: string
              minLength: 1
              maxLength: 1024
            buttons:
              type: array
              minItems: 1
              maxItems: 3
              description: Each `id` is unique.
              items:
                $ref: "#/components/schemas/MessageButton"
            url:
              type: string
              format: uri
              description: An `https://` link.
            display_text:
              type: string
              minLength: 1
              maxLength: 20
        reply_to_message_id:
          type: string
          format: uuid
          description: A message of the same chat to quote.
    ErrorCode:
      type: string
      description: |
        What went wrong. The codes are fixed; new codes may be added, but
        existing ones never change meaning, so handle codes you don't know by
        the HTTP status.
      enum:
        - invalid_request
        - unauthorized
        - ip_not_allowed
        - scope_missing
        - feature_not_in_plan
        - workspace_closed
        - not_found
        - method_not_allowed
        - conflict
        - invalid_state
        - idempotency_in_progress
        - too_large
        - unsupported_media_type
        - invalid_field
        - invalid_phone
        - idempotency_mismatch
        - window_closed
        - template_not_approved
        - template_params
        - template_ambiguous
        - contact_opted_out
        - number_unavailable
        - limit_reached
        - rate_limited
        - internal_error
        - unavailable
    Error:
      type: object
      description: The body of every error response.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              $ref: "#/components/schemas/ErrorCode"
            message:
              type: string
              description: What went wrong, in plain English for a developer.
            field:
              type: string
              description: |
                A JSON path to the field at fault, for example
                `template.name`. Left out when no single field is at fault.
            details:
              type: object
              additionalProperties: true
              description: |
                More about the error. Left out unless the code lists it:
                `scope_missing` (`scope`), `feature_not_in_plan` (`feature`),
                `conflict` (`id`, the existing object), `invalid_field` (`id`,
                only when a template WhatsApp refused was kept as a draft),
                `template_params`
                (`missing`, `unexpected`, and for broadcasts `rows`) and
                `limit_reached` (`limit`, `limit_value`, `used`).
            request_id:
              type: string
              description: The `X-Request-ID` of the response.
    Template:
      type: object
      description: A WhatsApp message template.
      required:
        - id
        - name
        - language
        - category
        - status
        - rejection_reason
        - quality
        - number_id
        - parameter_format
        - header
        - body
        - footer
        - buttons
        - variables
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          examples: [order_update]
        language:
          type: string
          examples: [en]
        category:
          type: string
          description: "`MARKETING`, `UTILITY` or `AUTHENTICATION`, as WhatsApp has it. Open: values may be added."
          examples: [UTILITY]
        status:
          type: string
          description: |
            Where the template is in WhatsApp's review, as WhatsApp reports it:
            `DRAFT` (saved here, not yet submitted), `PENDING`, `APPROVED`,
            `REJECTED`, `PAUSED`, `DISABLED`, `IN_APPEAL`, `PENDING_DELETION`,
            `DELETED` and so on. Open: values may be added. Only `APPROVED`
            templates can be sent.
          examples: [APPROVED]
        rejection_reason:
          type: [string, "null"]
          description: WhatsApp's reason, while the template is `REJECTED`. `null` otherwise.
        quality:
          type: string
          description: "WhatsApp's quality rating: `GREEN`, `YELLOW`, `RED` or `UNKNOWN`. Open: values may be added."
          examples: [GREEN]
        number_id:
          type: [string, "null"]
          format: uuid
          description: The number the template is on (see `GET /v1/numbers`).
        parameter_format:
          type: string
          enum: [positional, named]
          description: |
            `positional` templates use `{{1}}`, `{{2}}` for variables; `named`
            ones use `{{name}}`.
        header:
          description: The header, or `null` if there is none.
          oneOf:
            - $ref: "#/components/schemas/TemplateHeader"
            - type: "null"
        body:
          type: string
          examples: ["Hi {{1}}, your order is on its way."]
        footer:
          type: [string, "null"]
        buttons:
          type: array
          items:
            $ref: "#/components/schemas/TemplateButton"
        variables:
          $ref: "#/components/schemas/TemplateVariables"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    TemplateHeader:
      type: object
      required: [format]
      properties:
        format:
          type: string
          description: |
            `TEXT`, or the format of a media header (`IMAGE`, `VIDEO`,
            `DOCUMENT`) or `LOCATION` on a template made in the app. Open:
            values may be added.
        text:
          type: string
          description: The header text. Only on a `TEXT` header.
    TemplateButton:
      type: object
      required: [type]
      properties:
        type:
          type: string
          description: |
            `QUICK_REPLY`, `URL`, `PHONE_NUMBER` or `COPY_CODE`, or another
            type of a template made in the app. Open: values may be added.
        text:
          type: string
        url:
          type: string
          description: The link of a `URL` button. A `{{1}}` at the end is a variable.
        phone_number:
          type: string
    TemplateVariables:
      type: object
      description: |
        The exact variables a message that sends this template must supply
        (see `POST /v1/messages`). Positional templates use `"1"`, `"2"`;
        named ones use the names. `buttons` is keyed by the button's index in
        `buttons`, as a string. A `URL` button with a variable in its link
        needs `"1"`, and so does a `COPY_CODE` button: its code is required
        every time you send the template.
      required: [header, body, buttons]
      properties:
        header:
          type: array
          items:
            type: string
        body:
          type: array
          items:
            type: string
        buttons:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          examples: [{"0": ["1"]}]
    TemplateList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Template"
        next_cursor:
          type: [string, "null"]
          description: Pass it as `cursor` to get the next page. `null` means the end.
    CreateTemplateRequest:
      type: object
      required: [number_id, name, language, category, body]
      additionalProperties: false
      properties:
        number_id:
          type: string
          format: uuid
          description: The number to make the template on (see `GET /v1/numbers`).
        name:
          type: string
          description: |
            Lowercase letters, numbers and underscores, up to 255 characters.
            Unique per number and language.
          examples: [order_update]
        language:
          type: string
          description: A WhatsApp language code, such as `en` or `pt_BR`.
          examples: [en]
        category:
          type: string
          enum: [MARKETING, UTILITY]
          description: Authentication templates are made in the app.
        header:
          type: object
          description: A text header. Image, video and document headers are made in the app.
          required: [format, text]
          additionalProperties: false
          properties:
            format:
              type: string
              enum: [TEXT]
            text:
              type: string
              description: Up to 60 characters, with at most one variable.
            example:
              type: string
              description: An example for the header's variable. Required when it has one.
        body:
          type: string
          description: |
            Up to 1024 characters. Variables are `{{1}}`, `{{2}}` in order, or
            `{{name}}`; not both.
        body_examples:
          type: array
          items:
            type: string
          description: |
            One example for each variable of the body, in the order they first
            appear. Required when the body has variables.
        footer:
          type: string
          description: Up to 60 characters.
        buttons:
          type: array
          description: Up to 10 buttons. Keep quick replies together, before or after the others.
          items:
            type: object
            required: [type]
            additionalProperties: false
            properties:
              type:
                type: string
                enum: [QUICK_REPLY, URL, PHONE_NUMBER, COPY_CODE]
              text:
                type: string
                description: Up to 25 characters. Not used by `COPY_CODE`.
              url:
                type: string
                description: For `URL`. May end in `{{1}}`.
              phone_number:
                type: string
                description: For `PHONE_NUMBER`, for example `+14155550123`.
              example:
                type: string
                description: |
                  For `URL` with a `{{1}}`: an example for the variable. For
                  `COPY_CODE`: the sample code (up to 20 characters).

    Broadcast:
      type: object
      description: One template sent to many people.
      required:
        - id
        - name
        - status
        - template
        - number_id
        - send_at
        - started_at
        - completed_at
        - paused_reason
        - counts
        - skipped
        - created_by
        - created_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        status:
          type: string
          description: |
            Open enum. `preparing`: its audience is being collected.
            `scheduled`: waiting for `send_at`. `queued`: about to send.
            `processing`: sending. `paused`: stopped, by a person or for
            `paused_reason`. `missed`: it couldn't start at its time.
          enum: [preparing, scheduled, queued, processing, paused, completed, cancelled, failed, missed]
        template:
          type: [object, "null"]
          description: The template it sends; `null` if the template was deleted.
          required: [id, name, language]
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            language:
              type: string
        number_id:
          type: [string, "null"]
          format: uuid
          description: The number it sends from; `null` if the number was removed.
        send_at:
          type: [string, "null"]
          format: date-time
          description: When a scheduled broadcast sends; `null` for one sent at once.
        started_at:
          type: [string, "null"]
          format: date-time
        completed_at:
          type: [string, "null"]
          format: date-time
        paused_reason:
          type: [string, "null"]
          description: Why it stopped, in plain words for people, such as the monthly limit.
        counts:
          type: object
          description: |
            What happened to its messages so far. `replied` and `clicked`
            count recipients who replied, or tapped a quick-reply button,
            after its message.
          required: [total, sent, delivered, read, failed, replied, clicked]
          properties:
            total:
              type: integer
            sent:
              type: integer
            delivered:
              type: integer
            read:
              type: integer
            failed:
              type: integer
            replied:
              type: integer
            clicked:
              type: integer
        skipped:
          type: object
          description: The recipients left out, by why.
          required: [opted_out, duplicate, invalid_phone]
          properties:
            opted_out:
              type: integer
              description: Contacts who opted out of marketing messages (MARKETING templates only).
            duplicate:
              type: integer
              description: A number already listed.
            invalid_phone:
              type: integer
        created_by:
          $ref: "#/components/schemas/Actor"
        created_at:
          type: string
          format: date-time
    CreateBroadcastRequest:
      type: object
      description: Pass exactly one of `recipients` and `audience`.
      required: [name, template]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        number_id:
          type: string
          format: uuid
          description: The number to send from. Left out, the template's number.
        template:
          type: object
          description: |
            The template, by `id`, or by `name` and `language`, with its
            header file if it has an image, video or document header.
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            language:
              type: string
            header:
              type: object
              required: [media_id]
              properties:
                media_id:
                  type: string
                  format: uuid
                  description: An upload from `POST /v1/media`, kept until the broadcast has sent.
        recipients:
          type: array
          minItems: 1
          maxItems: 10000
          items:
            $ref: "#/components/schemas/BroadcastRecipient"
        audience:
          $ref: "#/components/schemas/BroadcastAudience"
        send_at:
          type: string
          format: date-time
          description: When to send, 5 minutes to 30 days from now. Left out, it sends now.
    BroadcastRecipient:
      type: object
      required: [to]
      description: |
        One recipient, with the template's variables keyed exactly as in
        `Template.variables`.
      properties:
        to:
          type: string
          description: The phone number, E.164 with or without `+`.
          examples: ["+919876543210"]
        name:
          type: string
          maxLength: 255
        body:
          type: object
          additionalProperties:
            type: string
          examples: [{"1": Asha}]
        header:
          type: object
          description: The value of the template's header variable.
          required: [text]
          properties:
            text:
              type: string
        buttons:
          type: object
          description: A link button's suffix or a coupon code, by the button's index.
          additionalProperties:
            type: string
          examples: [{"0": ASHA10}]
    BroadcastAudience:
      type: object
      required: [tags]
      description: |
        The contacts that already existed when you made the broadcast and,
        when they are collected (a moment later), have any of `tags` and none
        of `exclude_tags`. Contacts added after you made it aren't included.
        `body`, `header` and `buttons` give a source for exactly the keys of
        `Template.variables`.
      properties:
        tags:
          type: array
          minItems: 1
          maxItems: 20
          items:
            type: string
            minLength: 1
            maxLength: 50
          examples: [[vip]]
        exclude_tags:
          type: array
          maxItems: 20
          items:
            type: string
            minLength: 1
            maxLength: 50
          examples: [[churned]]
        body:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/AudienceValue"
          examples: [{"1": {from: contact.name, fallback: there}}]
        header:
          type: object
          description: The source of the template's header variable.
          required: [text]
          properties:
            text:
              $ref: "#/components/schemas/AudienceValue"
        buttons:
          type: object
          description: The source of a link button's suffix or a coupon code, by the button's index.
          additionalProperties:
            $ref: "#/components/schemas/AudienceValue"
    AudienceValue:
      type: object
      required: [from]
      description: |
        Where a variable's value comes from for each contact. When the
        contact has no value, or one WhatsApp wouldn't take (a line break, too
        long), `fallback` is used.
      properties:
        from:
          type: string
          description: |
            `contact.name`, `contact.phone` (as `+` and digits),
            `attribute:<name>` (one of the contact's `attributes`) or
            `text:<value>` (the same for everyone).
          examples: [contact.name, "attribute:city", "text:Diwali"]
        fallback:
          type: string
          minLength: 1
          description: Required for `contact.name` and `attribute:` sources.
    BroadcastList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Broadcast"
        next_cursor:
          type: [string, "null"]
          description: Pass it as `cursor` to get the next page. `null` means the end.
    BroadcastRecipientResult:
      type: object
      description: One recipient of a broadcast, and what happened to its message.
      required: [phone, name, status, message_id, error, sent_at, delivered_at, read_at, replied_at]
      properties:
        phone:
          type: string
          description: E.164, with `+`.
          examples: ["+919876543210"]
        name:
          type: string
        status:
          type: string
          description: Open enum.
          enum: [pending, sent, delivered, read, failed]
        message_id:
          type: [string, "null"]
          format: uuid
          description: The message sent (see `GET /v1/messages/{id}`); `null` until it is sent.
        error:
          type: [object, "null"]
          description: Why it failed; `null` unless it did.
          required: [code, message]
          properties:
            code:
              type: integer
              description: WhatsApp's error code, or 0.
            message:
              type: string
        sent_at:
          type: [string, "null"]
          format: date-time
        delivered_at:
          type: [string, "null"]
          format: date-time
        read_at:
          type: [string, "null"]
          format: date-time
        replied_at:
          type: [string, "null"]
          format: date-time
          description: When they first replied after it.
    BroadcastRecipientList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/BroadcastRecipientResult"
        next_cursor:
          type: [string, "null"]
          description: Pass it as `cursor` to get the next page. `null` means the end.
    WebhookEventType:
      type: string
      description: An event an endpoint can subscribe to. New events may be added.
      enum: [message.received, message.sent, message.status, contact.created, contact.updated, contact.deleted, template.status, chat.assigned, chat.closed, chat.reopened, broadcast.status]
    WebhookHeaders:
      type: object
      description: |
        Custom headers sent with every request, by name. Values are never
        returned: each reads `********`.
      maxProperties: 10
      propertyNames:
        pattern: "^[A-Za-z0-9-]{1,64}$"
      additionalProperties:
        type: string
        maxLength: 1024
    Webhook:
      type: object
      required: [id, url, description, events, headers, status, disabled_reason, paused_until, previous_secret_expires_at, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          description: Where Soosh sends the events.
        description:
          type: string
          maxLength: 500
        events:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEventType"
        headers:
          $ref: "#/components/schemas/WebhookHeaders"
        status:
          type: string
          enum: [active, paused, disabled]
          description: |
            `active`; `paused` while the endpoint keeps failing (Soosh tries
            again by itself at `paused_until`, and events wait meanwhile); or
            `disabled` when it's turned off.
        disabled_reason:
          type: [string, "null"]
          enum: [failing, no_secret, key_revoked, key_expired, key_scopes, null]
          description: |
            Why Soosh turned the endpoint off: `failing` after a day of
            nothing but failures; `no_secret` when it has no signing secret
            (nothing is ever sent unsigned: rotate the secret, then turn it
            back on). An endpoint a key created belongs to that
            key, and moves to its replacement when the key is rotated; it is
            turned off when the key is revoked (`key_revoked`), expires
            without a replacement (`key_expired`), or loses `webhooks:write`
            or the read scope of an event it sends (`key_scopes`). `null`
            otherwise, including when a person turned it off. Turn it back on
            with `enabled: true`; deliveries that were waiting are still sent
            if you do within a day of it being turned off.
        paused_until:
          type: [string, "null"]
          format: date-time
          description: When a paused endpoint is tried again; `null` unless `status` is `paused`.
        previous_secret_expires_at:
          type: [string, "null"]
          format: date-time
          description: During a rotation, when the old secret stops signing; `null` otherwise.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WebhookWithSecret:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: |
                The signing secret (`whsec_` and base64), shown this once.
                The HMAC key is the base64-decoded part after `whsec_`.
              examples: ["whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"]
    WebhookList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Webhook"
        next_cursor:
          type: [string, "null"]
          description: Pass it as `cursor` to get the next page. `null` means the end.
    CreateWebhookRequest:
      type: object
      required: [url, events]
      additionalProperties: false
      properties:
        url:
          type: string
          description: An `https://` URL on the internet.
        description:
          type: string
          maxLength: 500
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            $ref: "#/components/schemas/WebhookEventType"
        headers:
          $ref: "#/components/schemas/WebhookHeaders"
    UpdateWebhookRequest:
      type: object
      additionalProperties: false
      properties:
        url:
          type: string
        description:
          type: string
          maxLength: 500
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            $ref: "#/components/schemas/WebhookEventType"
        headers:
          $ref: "#/components/schemas/WebhookHeaders"
        enabled:
          type: boolean
          description: "`false` turns the endpoint off; `true` turns it back on."
    RotateWebhookSecretRequest:
      type: object
      additionalProperties: false
      properties:
        overlap_hours:
          type: integer
          minimum: 0
          maximum: 168
          default: 24
          description: How long the old secret keeps signing. 0 stops it at once.
    WebhookSecret:
      type: object
      required: [secret, previous_secret_expires_at]
      properties:
        secret:
          type: string
          description: The new signing secret, shown this once.
        previous_secret_expires_at:
          type: [string, "null"]
          format: date-time
          description: When the old secret stops signing; `null` when it already has.
    EventEnvelope:
      type: object
      description: >-
        What every webhook event carries. `id` is the same for every endpoint
        and every retry: use it to drop duplicates. Events may arrive out of
        order; order them by the times in `data`.
      required: [id, type, api_version, created_at, workspace_id, data]
      properties:
        id:
          type: string
          pattern: "^evt_"
        type:
          type: string
          description: The event, such as `message.status`. New events may be added.
        api_version:
          type: string
          example: "2026-10-14"
        created_at:
          type: string
          format: date-time
        workspace_id:
          type: string
          format: uuid
        data:
          description: The event's object.
    MessageReceivedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [message.received]
            data:
              $ref: "#/components/schemas/Message"
    MessageSentEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [message.sent]
            data:
              $ref: "#/components/schemas/Message"
    MessageStatusEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [message.status]
            data:
              type: object
              required: [message_id, contact_id, number_id, broadcast_id, status, at, error]
              properties:
                message_id:
                  type: string
                  format: uuid
                contact_id:
                  type: string
                  format: uuid
                number_id:
                  type: [string, "null"]
                  format: uuid
                broadcast_id:
                  type: [string, "null"]
                  format: uuid
                status:
                  type: string
                  description: Open enum; values may be added.
                  enum: [sent, delivered, read, failed]
                at:
                  type: string
                  format: date-time
                  description: When WhatsApp reported the status.
                error:
                  description: Why the message failed.
                  oneOf:
                    - type: "null"
                    - type: object
                      required: [code, message]
                      properties:
                        code:
                          type: integer
                          description: WhatsApp's error code, or 0 when it isn't known.
                        message:
                          type: string
    ContactCreatedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [contact.created]
            data:
              $ref: "#/components/schemas/Contact"
    ContactUpdatedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [contact.updated]
            data:
              type: object
              required: [contact, changed]
              properties:
                contact:
                  $ref: "#/components/schemas/Contact"
                changed:
                  type: array
                  description: The fields that changed. Open enum; values may be added.
                  minItems: 1
                  items:
                    type: string
                    enum: [name, attributes, tags, number_id, marketing_opt_out]
    ContactDeletedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [contact.deleted]
            data:
              type: object
              required: [contact_id, phone]
              properties:
                contact_id:
                  type: string
                  format: uuid
                phone:
                  type: [string, "null"]
    TemplateStatusEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [template.status]
            data:
              type: object
              required: [template, previous_status, reason]
              properties:
                template:
                  $ref: "#/components/schemas/Template"
                previous_status:
                  type: string
                reason:
                  type: [string, "null"]
                  description: WhatsApp's reason, such as why it was rejected.
    ChatAssignedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [chat.assigned]
            data:
              type: object
              required: [contact_id, agent_id, team_id, by]
              properties:
                contact_id:
                  type: string
                  format: uuid
                agent_id:
                  type: [string, "null"]
                  format: uuid
                  description: The contact's assigned agent once the change is made, as `chat.assigned_agent_id` shows it.
                team_id:
                  type: [string, "null"]
                  format: uuid
                  description: The team of the chat's active handover once the change is made; null when no team holds it.
                by:
                  $ref: "#/components/schemas/Actor"
    ChatClosedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [chat.closed]
            data:
              $ref: "#/components/schemas/ChatStateEventData"
    ChatReopenedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [chat.reopened]
            data:
              $ref: "#/components/schemas/ChatStateEventData"
    ChatStateEventData:
      type: object
      required: [contact_id, by]
      properties:
        contact_id:
          type: string
          format: uuid
        by:
          description: Who closed or reopened the chat; null when the customer reopened it by writing.
          oneOf:
            - type: "null"
            - $ref: "#/components/schemas/Actor"
    BroadcastStatusEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          required: [type, data]
          properties:
            type:
              type: string
              enum: [broadcast.status]
            data:
              $ref: "#/components/schemas/Broadcast"
  responses:
    BadRequest:
      description: |
        `invalid_request`: malformed JSON or multipart, an unknown field, a
        wrong type, a body over the limit, or a bad cursor.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
        Idempotent-Replayed:
          $ref: "#/components/headers/IdempotentReplayed"
        Original-Request-ID:
          $ref: "#/components/headers/OriginalRequestID"
        Cache-Control:
          $ref: "#/components/headers/CacheControl"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: |
        `unauthorized`: the key is missing, malformed, unknown, revoked or
        expired. The reason isn't revealed.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: |
        `ip_not_allowed` (the client IP isn't in the key's allowed IPs),
        `scope_missing` (`details.scope`), `feature_not_in_plan`
        (`details.feature`) or `workspace_closed`.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
        Idempotent-Replayed:
          $ref: "#/components/headers/IdempotentReplayed"
        Original-Request-ID:
          $ref: "#/components/headers/OriginalRequestID"
        Cache-Control:
          $ref: "#/components/headers/CacheControl"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: |
        `not_found`: no such path or object. Also returned for objects that
        belong to another workspace.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
        Idempotent-Replayed:
          $ref: "#/components/headers/IdempotentReplayed"
        Original-Request-ID:
          $ref: "#/components/headers/OriginalRequestID"
        Cache-Control:
          $ref: "#/components/headers/CacheControl"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    MethodNotAllowed:
      description: '`method_not_allowed`: the path exists, but not for this method.'
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        Allow:
          $ref: "#/components/headers/Allow"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: |
        `conflict` (a duplicate; `details.id` is the existing object),
        `invalid_state` (for example cancelling a completed broadcast) or
        `idempotency_in_progress` (the same `Idempotency-Key` is still
        running).
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
        Idempotent-Replayed:
          $ref: "#/components/headers/IdempotentReplayed"
        Original-Request-ID:
          $ref: "#/components/headers/OriginalRequestID"
        Cache-Control:
          $ref: "#/components/headers/CacheControl"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    PayloadTooLarge:
      description: |
        `too_large`: the file is over the size for its type, or the request is
        over the size limit of the endpoint.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
        Idempotent-Replayed:
          $ref: "#/components/headers/IdempotentReplayed"
        Original-Request-ID:
          $ref: "#/components/headers/OriginalRequestID"
        Cache-Control:
          $ref: "#/components/headers/CacheControl"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    UnsupportedMediaType:
      description: |
        `unsupported_media_type`: the file isn't a type the API accepts, or the
        request body is compressed. Request bodies can't have a
        `Content-Encoding`; send them as they are.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
        Idempotent-Replayed:
          $ref: "#/components/headers/IdempotentReplayed"
        Original-Request-ID:
          $ref: "#/components/headers/OriginalRequestID"
        Cache-Control:
          $ref: "#/components/headers/CacheControl"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    UnprocessableEntity:
      description: |
        The request was understood but a rule failed: `invalid_field`
        (`field`), `invalid_phone`, `idempotency_mismatch`, `window_closed`,
        `template_not_approved`, `template_params`, `template_ambiguous`,
        `contact_opted_out`, `number_unavailable` or `limit_reached`.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
        Idempotent-Replayed:
          $ref: "#/components/headers/IdempotentReplayed"
        Original-Request-ID:
          $ref: "#/components/headers/OriginalRequestID"
        Cache-Control:
          $ref: "#/components/headers/CacheControl"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    TooManyRequests:
      description: '`rate_limited`: wait `Retry-After` seconds.'
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitReset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    InternalError:
      description: '`internal_error`: something went wrong on our side.'
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unavailable:
      description: |
        `unavailable`: the database, Redis or the queue can't be reached. Try
        again after `Retry-After` seconds.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/XRequestID"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimitAfter"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemainingAfter"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitResetAfter"
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
