> ## Documentation Index
> Fetch the complete documentation index at: https://apidoc.bulkneo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Send a list menu

> POST /v1/messages/list — a tappable menu grouped into sections.

<Warning>
  **Best effort.** Many WhatsApp clients no longer render interactive messages and will show a plain-text fallback instead. A `200` means the message was accepted for delivery — **not** that the menu will appear. The response carries `"best_effort": true` to say so explicitly. Read [Buttons and lists are best-effort](/guides/interactive-messages) before you build anything on this.
</Warning>

## Notes

**Limits, all enforced before the message leaves us:**

| Field                           | Limit                                                           |
| ------------------------------- | --------------------------------------------------------------- |
| `title`                         | 1–1,024 characters, required                                    |
| `button_text`                   | 1–24 characters, required                                       |
| `description`                   | up to 1,024 characters                                          |
| `footer_text`                   | up to 60 characters                                             |
| `sections`                      | 1–10 sections, required                                         |
| `sections[].title`              | 1–24 characters, required                                       |
| `sections[].rows`               | 1–10 rows per section, required                                 |
| `sections[].rows[].title`       | 1–24 characters, required                                       |
| `sections[].rows[].row_id`      | 1–200 characters, required, **unique across the whole message** |
| `sections[].rows[].description` | up to 72 characters                                             |
| **Total rows**                  | **30 across all sections**                                      |

* `row_id` is what identifies the row someone picked. Two rows sharing an id would make the selection unreadable, so duplicates are rejected.
* Omit a row's `description` entirely rather than sending `""` — an empty string is not accepted.

## Example

```bash cURL theme={null}
curl -X POST https://api.bulkneo.com/v1/messages/list \
  -H "apikey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "instance_id": "YOUR_INSTANCE_ID",
    "to": "919876543210",
    "title": "Book an appointment",
    "description": "Pick a time that suits you",
    "button_text": "View slots",
    "footer_text": "Clinic hours 9am-6pm",
    "sections": [
      {
        "title": "Tomorrow",
        "rows": [
          { "title": "10:00 AM", "row_id": "slot_1000", "description": "With Dr Shah" },
          { "title": "2:30 PM", "row_id": "slot_1430" }
        ]
      },
      {
        "title": "Thursday",
        "rows": [
          { "title": "11:00 AM", "row_id": "slot_thu_1100" }
        ]
      }
    ]
  }'
```

## The response

Successful responses on this endpoint carry two extra fields:

```json theme={null}
{
  "success": true,
  "data": {
    "instance_id": "3f9c1a2b-7d4e-4c81-9f0a-2b6d5e8c1a34",
    "to": "919876543210",
    "type": "list",
    "status": "sent",
    "message_id": "3EB0C1D2F4A5B6C7D8E9",
    "provider_status": "PENDING",
    "best_effort": true,
    "note": "Interactive messages are best-effort: many WhatsApp clients no longer render them and may show a plain-text fallback instead. …"
  },
  "request_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
}
```

<Warning>
  **You cannot receive the selection.** When someone taps a row, their choice goes back to the WhatsApp conversation — there is no webhook and no endpoint that delivers it to your system. `row_id` is meaningful only to a human reading the chat on the phone.
</Warning>


## OpenAPI

````yaml api-reference/openapi.json POST /v1/messages/list
openapi: 3.1.0
info:
  title: BulkNeo WhatsApp API
  version: 1.0.0
  description: >-
    Send WhatsApp messages from your own connected numbers. Every request is
    authenticated with an `apikey` header and names the `instance_id` of the
    number it should be sent from.
servers:
  - url: https://api.bulkneo.com
    description: BulkNeo API
security:
  - apikey: []
tags:
  - name: Messages
    description: Send a message from one of your connected numbers.
  - name: Numbers
    description: Check which numbers are reachable on WhatsApp.
  - name: Instances
    description: Create, connect, inspect and remove your WhatsApp numbers.
  - name: Service
    description: Unauthenticated service health checks.
paths:
  /v1/messages/list:
    post:
      tags:
        - Messages
      summary: Send a list menu
      description: >-
        Sends a tappable menu grouped into sections. BEST EFFORT — many WhatsApp
        clients no longer render interactive messages and show a plain-text
        fallback instead. A 200 means the message was accepted for delivery, not
        that the menu will appear.
      operationId: sendList
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - instance_id
                - to
                - title
                - button_text
                - sections
              properties:
                instance_id:
                  $ref: '#/components/schemas/InstanceId'
                to:
                  $ref: '#/components/schemas/Recipient'
                title:
                  type: string
                  minLength: 1
                  maxLength: 1024
                  description: Heading of the message.
                  example: Book an appointment
                button_text:
                  type: string
                  minLength: 1
                  maxLength: 24
                  description: >-
                    Label on the button that opens the menu. Up to 24
                    characters.
                  example: View slots
                description:
                  type: string
                  maxLength: 1024
                  description: Body text under the title.
                  example: Pick a time that suits you
                footer_text:
                  type: string
                  maxLength: 60
                  description: Small print under the menu. Up to 60 characters.
                sections:
                  type: array
                  minItems: 1
                  maxItems: 10
                  description: >-
                    1 to 10 sections, and no more than 30 rows in total across
                    all of them.
                  items:
                    type: object
                    required:
                      - title
                      - rows
                    properties:
                      title:
                        type: string
                        minLength: 1
                        maxLength: 24
                        description: Section heading. Up to 24 characters.
                        example: Tomorrow
                      rows:
                        type: array
                        minItems: 1
                        maxItems: 10
                        description: 1 to 10 rows in this section.
                        items:
                          type: object
                          required:
                            - title
                            - row_id
                          properties:
                            title:
                              type: string
                              minLength: 1
                              maxLength: 24
                              description: The row label. Up to 24 characters.
                              example: 10:00 AM
                            row_id:
                              type: string
                              minLength: 1
                              maxLength: 200
                              description: >-
                                What identifies this row when it is picked. Must
                                be unique across the whole message.
                              example: slot_1000
                            description:
                              type: string
                              maxLength: 72
                              description: >-
                                Optional second line. Up to 72 characters. Omit
                                it entirely rather than sending an empty string.
                              example: With Dr Shah
                quoted_message_id:
                  $ref: '#/components/schemas/QuotedMessageId'
                quoted_from_me:
                  $ref: '#/components/schemas/QuotedFromMe'
            example:
              instance_id: 3f9c1a2b-7d4e-4c81-9f0a-2b6d5e8c1a34
              to: '919876543210'
              title: Book an appointment
              description: Pick a time that suits you
              button_text: View slots
              sections:
                - title: Tomorrow
                  rows:
                    - title: 10:00 AM
                      row_id: slot_1000
                      description: With Dr Shah
                    - title: 2:30 PM
                      row_id: slot_1430
      responses:
        '200':
          $ref: '#/components/responses/InteractiveSendSuccess'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/InstanceNotFound'
        '409':
          $ref: '#/components/responses/NotConnected'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
components:
  schemas:
    InstanceId:
      type: string
      format: uuid
      description: >-
        Which of your connected numbers to send FROM. Copy it from your numbers
        screen in the portal, or from `GET /v1/instances`. It must belong to
        your account.
      example: 3f9c1a2b-7d4e-4c81-9f0a-2b6d5e8c1a34
    Recipient:
      type: string
      description: >-
        The recipient, with country code and no leading zero — for example
        `919876543210`. Spaces, dashes, brackets and a leading `+` are accepted
        and stripped; 6 to 15 digits must remain.
      example: '919876543210'
    QuotedMessageId:
      type: string
      maxLength: 128
      description: >-
        Send this message as a reply, quoting an earlier one. Use the
        `data.message_id` returned by a previous send.
      example: 3EB0C1D2F4A5B6C7D8E9
    QuotedFromMe:
      type: boolean
      default: false
      description: >-
        Set `true` when the quoted message is one you sent from this number.
        Only valid together with `quoted_message_id`.
    SendData:
      type: object
      properties:
        instance_id:
          type: string
          format: uuid
        to:
          type: string
        type:
          type: string
          description: The message type you sent.
        status:
          type: string
          description: Always `sent` — the message was accepted for delivery.
        message_id:
          type:
            - string
            - 'null'
          description: >-
            The message's own id. Keep it: it is what you quote or react to
            later.
        provider_status:
          type:
            - string
            - 'null'
          description: >-
            The delivery state at the moment of sending, for example `PENDING`.
            It is a snapshot, not a delivery receipt.
    Error:
      type: object
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          properties:
            code:
              type: string
              description: >-
                Stable machine-readable code. Branch on this, not on the
                message.
            message:
              type: string
              description: Human-readable explanation. The wording may change.
            details:
              type: object
              description: Extra safe context, present on some errors.
        request_id:
          type: string
          format: uuid
          description: Quote this when asking support about a request.
  responses:
    InteractiveSendSuccess:
      description: >-
        Accepted for delivery — best effort. `best_effort` and `note` are on
        every interactive response, because whether the recipient's WhatsApp
        renders it is outside anyone's control.
      headers:
        X-RateLimit-Scope:
          $ref: '#/components/headers/RateLimitScope'
        X-RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                const: true
              data:
                allOf:
                  - $ref: '#/components/schemas/SendData'
                  - type: object
                    properties:
                      best_effort:
                        type: boolean
                        const: true
                      note:
                        type: string
              request_id:
                type: string
                format: uuid
          example:
            success: true
            data:
              instance_id: 3f9c1a2b-7d4e-4c81-9f0a-2b6d5e8c1a34
              to: '919876543210'
              type: buttons
              status: sent
              message_id: 3EB0C1D2F4A5B6C7D8E9
              provider_status: PENDING
              best_effort: true
              note: >-
                Interactive messages are best-effort: many WhatsApp clients no
                longer render them and may show a plain-text fallback instead. A
                successful response means the message was accepted for delivery,
                not that the buttons will appear. Do not depend on it for
                anything critical — send a text message with clear instructions
                as a fallback.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    BadRequest:
      description: >-
        `invalid_request` a field is missing or malformed (the message names it,
        including its position in a list) · `invalid_media_url` the `url` is not
        a public http(s) link to a file.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: invalid_request
              message: Field "text" is required and must be a non-empty string.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    Unauthorized:
      description: >-
        `missing_api_key` no `apikey` header was sent · `invalid_api_key` the
        key is unknown or revoked.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: invalid_api_key
              message: Invalid or revoked API key.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    PaymentRequired:
      description: >-
        `no_active_subscription` no usable plan on this account ·
        `subscription_expired` the plan has run out.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: subscription_expired
              message: >-
                Your subscription has expired. Renew it to continue sending
                messages.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    Forbidden:
      description: >-
        `account_suspended` · `access_expired` · `trial_quota_exceeded` ·
        `instance_limit_reached` · `activation_unavailable`.


        On `instance_limit_reached`, `details.limit_source` tells you which cap
        you hit: `plan` (the plan you are on — upgrade it) or `account` (a cap
        set on your account — ask your provider to raise it). Branch on that,
        never on the wording of the message.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: trial_quota_exceeded
              message: >-
                Trial message quota reached (500 messages). Upgrade to a paid
                plan to keep sending.
              details:
                trial_message_limit: 500
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    InstanceNotFound:
      description: >-
        `instance_not_found` — no such instance on your account. The same answer
        is given for an id that does not exist and one that belongs to someone
        else.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: instance_not_found
              message: >-
                Instance not found. Check the instance_id and that it belongs to
                your account.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    NotConnected:
      description: >-
        `instance_not_connected` — the number is not connected. Do not retry:
        pause and have someone re-scan the QR.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: instance_not_connected
              message: >-
                This WhatsApp instance is not connected. Connect it before
                sending messages.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    Unprocessable:
      description: '`invalid_recipient` — the recipient is not a valid WhatsApp number.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: invalid_recipient
              message: The recipient number is not a valid WhatsApp number.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    RateLimited:
      description: >-
        `rate_limit_exceeded` — too many requests this minute. Wait for the
        number of seconds in the `Retry-After` header. `details.scope` and the
        `X-RateLimit-Scope` header say WHICH allowance you exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
        X-RateLimit-Scope:
          $ref: '#/components/headers/RateLimitScope'
        X-RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: rate_limit_exceeded
              message: Rate limit exceeded, retry in 12s.
              details:
                retry_after_seconds: 12
                limit_per_minute: 20
                scope: messages
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    InternalError:
      description: >-
        `internal_error` — something went wrong on our side. Quote the
        `request_id` to support.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: internal_error
              message: An unexpected error occurred.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    UpstreamUnavailable:
      description: >-
        `upstream_unavailable` — the messaging service is temporarily
        unavailable. Safe to retry after a short pause.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: upstream_unavailable
              message: >-
                The messaging service is temporarily unavailable. Please retry
                shortly.
            request_id: b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
  headers:
    RateLimitScope:
      description: >-
        WHICH allowance the other X-RateLimit-* headers describe. `messages`
        covers every /v1/messages/* endpoint and /v1/numbers/check together;
        `instances` is the separate, much larger budget for /v1/instances/*. If
        you cache a limit, cache it against its scope.
      schema:
        type: string
        enum:
          - messages
          - instances
      example: messages
    RateLimitLimit:
      description: Requests allowed per minute on this scope.
      schema:
        type: integer
      example: 20
    RateLimitRemaining:
      description: Requests left in the current minute on this scope.
      schema:
        type: integer
      example: 17
    RateLimitReset:
      description: >-
        Seconds until the allowance next goes up by one. Present on successful
        responses too, so you can pace yourself without first earning a 429. The
        window is a rolling minute, so this is when the oldest request ages out
        — not a fixed reset instant. If `X-RateLimit-Remaining` is above 0 you
        can send now.
      schema:
        type: integer
      example: 43
  securitySchemes:
    apikey:
      type: apiKey
      in: header
      name: apikey
      description: >-
        Your API key, sent in an `apikey` request header. Never in the URL,
        never as a Bearer token.

````