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

# List contacts

> List your contacts, newest first. Deleted contacts are never listed.

Results are paginated: `has_more` tells you whether there is another page, and you get it by passing the `id` of the last contact as `starting_after`. Pages are stable while contacts are added: nothing is skipped or repeated. See [Pagination](/concepts/pagination).

Filters combine with AND. A filter value that matches nothing (including a tag you don't have) returns an empty page. An unknown or repeated query parameter returns `422 validation_error`.

Lists allow 120 requests a minute per account, across all your API keys and all lists. Over that, the answer is `429 rate_limited` with a `Retry-After` header in seconds.

### List contacts

**Method:** `GET` | **Endpoint:** `/v1/contacts`

**Description:** List your contacts, newest first, with their tags, opt-out and block state. Search by name, email or phone, or filter by tag, opt-out or block. Deleted contacts are never listed.

**Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**.

#### Query parameters

All optional. Filters combine with AND.

| Parameter | Type | Description |
| - | - | - |
| `limit` | integer | How many contacts to return, `1` to `100`. Defaults to `50`. Outside that range is `422 validation_error`, not rounded. |
| `starting_after` | string (UUID) | The `id` of the last contact on the page you already have. Returns the contacts after it. Leave it out for the first page. See [Pagination](https://docs.ibubbl.com/concepts/pagination.md). |
| `search` | string | Contacts matching **every** word, ignoring case, as part of the first name, last name, email or phone number: `sam rivera` finds Sam Rivera, and `example.com` finds everyone with an email there. A word written like a phone number also matches by its digits, so `415-555-1234` and `(415)` find `+14155551234`. `%` and `_` match themselves. The characters `"`, `\` and `*` are ignored. Up to 100 characters. |
| `tag` | string | Contacts with this tag, matched ignoring case and outer spaces: `vip` finds contacts tagged `VIP`. If you have two tags that differ only by case, contacts with either are listed (once each). |
| `opted_out` | boolean | `true`: only contacts who have [opted out](https://docs.ibubbl.com/concepts/suppression.md). `false`: only the others. |
| `blocked` | boolean | `true`: only contacts you've [blocked](https://docs.ibubbl.com/api-reference/contacts/block.md). `false`: only the others. |

Any other query parameter, or one given twice, returns `422 validation_error` naming it. A misspelt `startingAfter` is refused instead of quietly returning the first page again.

A filter that matches nothing returns an empty page, including a `tag` you don't have. A contact deleted since you received it is no longer a valid `starting_after` (`422 invalid_cursor`): restart from the first page.

**Rate limit:** 120 list requests per 60 seconds per account, across all your API keys and all list endpoints. Over that, you get `429 rate_limited` with a `Retry-After` header; see [Rate limits](https://docs.ibubbl.com/concepts/limits.md#rate-limits).

```bash cURL theme={null}
# Sent as ?limit=50&search=sam%20rivera&tag=VIP
curl -G https://api.ibubbl.com/v1/contacts \
  -H "Authorization: Bearer $IBUBBL_API_KEY" \
  --data-urlencode "limit=50" \
  --data-urlencode "search=sam rivera" \
  --data-urlencode "tag=VIP"

# Next page: add the id of the last contact you received.
#   --data-urlencode "starting_after=c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"
```

```typescript TypeScript theme={null}
const params = new URLSearchParams({
  limit: "50",
  search: "sam rivera",
  tag: "VIP",
});

const res = await fetch(`https://api.ibubbl.com/v1/contacts?${params}`, {
  headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}` },
  signal: AbortSignal.timeout(10_000),
});

const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`);
for (const contact of data.data) {
  console.log(contact.id, contact.phone, contact.first_name, contact.opted_out);
}
// More? Repeat with starting_after = data.data[data.data.length - 1].id
console.log("has_more:", data.has_more);
```

```python Python theme={null}
import os

import requests

API_KEY = os.environ["IBUBBL_API_KEY"]

res = requests.get(
    "https://api.ibubbl.com/v1/contacts",
    headers={"Authorization": f"Bearer {API_KEY}"},
    params={"limit": 50, "search": "sam rivera", "tag": "VIP"},
    timeout=10,
)
data = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
for contact in data["data"]:
    print(contact["id"], contact["phone"], contact["first_name"], contact["opted_out"])
# More? Repeat with starting_after = data["data"][-1]["id"]
print("has_more:", data["has_more"])
```

```json 200 OK theme={null}
{
  "data": [
    {
      "id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f",
      "phone": "+14155551234",
      "first_name": "Sam",
      "last_name": "Rivera",
      "email": "sam@example.com",
      "city": "Brooklyn",
      "state": "NY",
      "timezone": "America/New_York",
      "tags": [
        "Spring promo",
        "VIP"
      ],
      "opted_out": false,
      "opted_out_at": null,
      "blocked": false,
      "blocked_at": null,
      "created_at": "2026-11-05T14:23:45.123+00:00"
    }
  ],
  "has_more": false
}
```

```json 422 Unprocessable Entity theme={null}
{
  "detail": {
    "error": "invalid_cursor",
    "message": "starting_after is not the id of one of your items"
  }
}
```

```json 429 Too Many Requests theme={null}
{
  "detail": {
    "error": "rate_limited",
    "message": "Too many list requests; wait 18s (limit 120 a minute per account)."
  }
}
```

#### Responses

**200 OK**

`data` holds up to `limit` contacts, newest first: by `created_at` (when the contact was created), then by `id` for contacts created in the same microsecond. Each item is exactly what [Retrieve a contact](https://docs.ibubbl.com/api-reference/contacts/retrieve.md) returns for that contact. `has_more` is `true` when more contacts match; to get them, repeat the request with `starting_after` set to the `id` of the last item in `data`. An empty `data` comes with `has_more: false`.

```json theme={null}
{
  "data": [
    {
      "id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f",
      "phone": "+14155551234",
      "first_name": "Sam",
      "last_name": "Rivera",
      "email": "sam@example.com",
      "city": "Brooklyn",
      "state": "NY",
      "timezone": "America/New_York",
      "tags": [
        "Spring promo",
        "VIP"
      ],
      "opted_out": false,
      "opted_out_at": null,
      "blocked": false,
      "blocked_at": null,
      "created_at": "2026-11-05T14:23:45.123+00:00"
    }
  ],
  "has_more": false
}
```

**401 Unauthorized**

```json theme={null}
{
  "detail": {
    "error": "unauthorized",
    "message": "Invalid or revoked API key"
  }
}
```

Other `message` values: `Authorization Bearer token required`, `API key required (use Authorization: Bearer ib_pk_…)`, `Invalid API key format`, `Customer account not found`.

**403 Forbidden**

```json theme={null}
{
  "detail": {
    "error": "forbidden",
    "message": "Account is suspended"
  }
}
```

Your iBubbl account is suspended or cancelled (`message` is then `Account is cancelled`). Every API-key endpoint returns this.

**422 Unprocessable Entity**

```json theme={null}
{
  "detail": {
    "error": "validation_error",
    "message": "Unknown query parameter: startingAfter. Allowed: blocked, limit, opted_out, search, starting_after, tag"
  }
}
```

`validation_error`: a query parameter is unknown, repeated or invalid (`limit` outside 1–100, a `starting_after` that isn't a UUID, `opted_out` or `blocked` other than `true` / `false`, an empty or over-long `search` or `tag`, or a `search` with nothing left to match once `"`, `\` and `*` are removed). `invalid_cursor`: `starting_after` is a UUID but not the ID of one of your contacts, or that contact has been deleted.

**429 Too Many Requests**

```text theme={null}
Retry-After: 18
```

```json theme={null}
{
  "detail": {
    "error": "rate_limited",
    "message": "Too many list requests; wait 18s (limit 120 a minute per account)."
  }
}
```

Your account made more than 120 list requests in the last 60 seconds. `Retry-After` is the number of whole seconds until the oldest of them stops counting. Wait that long, then send the same request again; the refused request didn't count. The limit is checked after the API key, so a bad key is still `401`.


## OpenAPI

````yaml GET /v1/contacts
openapi: 3.1.0
info:
  title: iBubbl API
  version: v1
  description: >-
    The iBubbl REST API. Send iMessages, manage contacts, and subscribe to
    webhook events from your backend.


    Authenticate every request with a Bearer API key (`ib_pk_<hex>`) — generate
    one from your dashboard's API Access settings. See the
    [Authentication](/authentication) guide.
servers:
  - url: https://api.ibubbl.com
security:
  - BearerAuth: []
tags:
  - name: Messages
  - name: Contacts
  - name: Conversations
  - name: Webhooks
  - name: Inbound Events
paths:
  /v1/contacts:
    get:
      tags:
        - Contacts
      summary: List contacts
      description: >-
        List your contacts, newest first. Deleted contacts are never listed.


        Results are paginated: `has_more` tells you whether there is another
        page, and you get it by passing the `id` of the last contact as
        `starting_after`. Pages are stable while contacts are added: nothing is
        skipped or repeated. See [Pagination](/concepts/pagination).


        Filters combine with AND. A filter value that matches nothing (including
        a tag you don't have) returns an empty page. An unknown or repeated
        query parameter returns `422 validation_error`.


        Lists allow 120 requests a minute per account, across all your API keys
        and all lists. Over that, the answer is `429 rate_limited` with a
        `Retry-After` header in seconds.
      operationId: v1_list_contacts_v1_contacts_get
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            maximum: 100
            minimum: 1
            description: How many items to return, 1 to 100. Defaults to 50.
            default: 50
            title: Limit
          description: How many items to return, 1 to 100. Defaults to 50.
        - name: starting_after
          in: query
          required: false
          schema:
            anyOf:
              - type: string
                format: uuid
              - type: 'null'
            description: >-
              The `id` of the last item on the page you already have. Returns
              the items that come after it, in the same order. Leave it out for
              the first page. An `id` that isn't one of yours returns `422
              invalid_cursor`. A contact deleted since is no longer a valid
              cursor.
            title: Starting After
          description: >-
            The `id` of the last item on the page you already have. Returns the
            items that come after it, in the same order. Leave it out for the
            first page. An `id` that isn't one of yours returns `422
            invalid_cursor`. A contact deleted since is no longer a valid
            cursor.
        - name: search
          in: query
          required: false
          schema:
            anyOf:
              - type: string
                minLength: 1
                maxLength: 100
              - type: 'null'
            description: >-
              Only contacts matching every word, case-insensitively, as part of
              the first name, last name, email or phone number: `sam rivera`
              finds Sam Rivera. A word written like a phone number also matches
              by its digits, so `415-555-1234` finds `+14155551234`. The
              characters `"`, `\` and `*` are ignored. Up to 100 characters.
            title: Search
          description: >-
            Only contacts matching every word, case-insensitively, as part of
            the first name, last name, email or phone number: `sam rivera` finds
            Sam Rivera. A word written like a phone number also matches by its
            digits, so `415-555-1234` finds `+14155551234`. The characters `"`,
            `\` and `*` are ignored. Up to 100 characters.
        - name: opted_out
          in: query
          required: false
          schema:
            anyOf:
              - type: boolean
              - type: 'null'
            description: '`true`: only opted-out contacts. `false`: only the others.'
            title: Opted Out
          description: '`true`: only opted-out contacts. `false`: only the others.'
        - name: blocked
          in: query
          required: false
          schema:
            anyOf:
              - type: boolean
              - type: 'null'
            description: '`true`: only blocked contacts. `false`: only the others.'
            title: Blocked
          description: '`true`: only blocked contacts. `false`: only the others.'
        - name: tag
          in: query
          required: false
          schema:
            anyOf:
              - type: string
                minLength: 1
                maxLength: 64
              - type: 'null'
            description: >-
              Only contacts with this tag, matched trimmed and
              case-insensitively (`vip` matches `VIP`).
            title: Tag
          description: >-
            Only contacts with this tag, matched trimmed and case-insensitively
            (`vip` matches `VIP`).
      responses:
        '200':
          description: A page of contacts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
        '401':
          description: Missing or invalid API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '403':
          description: '`forbidden`: your iBubbl account is suspended or cancelled.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '422':
          description: >-
            `validation_error` (a bad, unknown or repeated query parameter,
            `limit` outside 1–100, or a `search` with nothing to match) or
            `invalid_cursor` (`starting_after` is not one of your contacts).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '429':
          description: >-
            `rate_limited`: more than 120 list requests in 60 seconds from your
            account (all keys and all lists count together). Wait `Retry-After`
            seconds, then send the same request again.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
components:
  schemas:
    ContactList:
      properties:
        data:
          items:
            $ref: '#/components/schemas/Contact'
          type: array
          title: Data
          description: >-
            Up to `limit` contacts, newest first: by `created_at`, then by `id`
            for contacts created in the same microsecond.
        has_more:
          type: boolean
          title: Has More
          description: >-
            Whether more contacts match after the last one in `data`. To get
            them, repeat the request with `starting_after` set to that contact's
            `id`.
      type: object
      required:
        - data
        - has_more
      title: ContactList
    ApiError:
      properties:
        detail:
          allOf:
            - $ref: '#/components/schemas/ApiErrorDetail'
          description: >-
            Error envelope. Every `/v1` error response wraps a stable `error`
            code and a human-readable `message` in `detail`.
      type: object
      required:
        - detail
      title: ApiError
      example:
        detail:
          error: validation_error
          message: >-
            Exactly one of contact_id, to, chat_guid, or participant_phones is
            required
    Contact:
      properties:
        id:
          type: string
          title: Id
          description: Unique contact identifier (UUID).
        phone:
          type: string
          title: Phone
          description: >-
            Contact's phone number as first seen. Usually E.164; a contact first
            created by an inbound SMS can carry the sender's bare national form
            (e.g. `4155551234`). Upserting any form of the same number returns
            this contact.
        first_name:
          anyOf:
            - type: string
            - type: 'null'
          title: First Name
        last_name:
          anyOf:
            - type: string
            - type: 'null'
          title: Last Name
        email:
          anyOf:
            - type: string
            - type: 'null'
          title: Email
        city:
          anyOf:
            - type: string
            - type: 'null'
          title: City
        state:
          anyOf:
            - type: string
            - type: 'null'
          title: State
        timezone:
          anyOf:
            - type: string
            - type: 'null'
          title: Timezone
          description: >-
            IANA timezone used for this contact's sending hours, or null if not
            set yet. When it is null and the number is a US or Canadian one,
            iBubbl infers the zone from the area code and saves it here when the
            contact's first message is processed; otherwise your account's
            timezone is used. Once set, an upsert never replaces it, so pass
            `timezone` on the first upsert, before you send, or change it with
            PATCH.
        tags:
          items:
            type: string
          type: array
          title: Tags
          description: The contact's tag names, sorted case-insensitively.
        opted_out:
          type: boolean
          title: Opted Out
          description: >-
            True if this person has opted out: a STOP reply, a rep in the
            dashboard, a Do Not Disturb in your connected GoHighLevel account,
            or `POST /v1/contacts/{id}/opt-out`. When true, every send to this
            contact returns `422 contact_opted_out`. An opt-out can't be undone,
            and it belongs to the number: a contact deleted and created again is
            still opted out.
        opted_out_at:
          anyOf:
            - type: string
            - type: 'null'
          title: Opted Out At
          description: ISO 8601 UTC timestamp of the opt-out, or null.
        blocked:
          type: boolean
          title: Blocked
          description: >-
            True if you blocked this contact (dashboard or `POST
            /v1/contacts/{id}/block`). Sends to them return `422
            contact_blocked` until you unblock them. Unlike an opt-out, a block
            is yours to undo, and it fires no webhook.
          default: false
        blocked_at:
          anyOf:
            - type: string
            - type: 'null'
          title: Blocked At
          description: ISO 8601 UTC timestamp of the block, or null.
        created_at:
          type: string
          title: Created At
          description: ISO 8601 UTC timestamp the contact was created.
      type: object
      required:
        - id
        - phone
        - opted_out
        - created_at
      title: Contact
      example:
        blocked: false
        city: Brooklyn
        created_at: '2026-05-17T14:23:45.123Z'
        email: sam@example.com
        first_name: Sam
        id: c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f
        last_name: Rivera
        opted_out: false
        phone: '+14155551234'
        state: NY
        tags:
          - Spring promo
          - VIP
        timezone: America/Los_Angeles
    ApiErrorDetail:
      properties:
        error:
          type: string
          title: Error
          description: >-
            Stable machine-readable error code. Use this for branching logic in
            your integration; the human-readable `message` is subject to change.
            See the Error Reference for the catalog.
          examples:
            - validation_error
        message:
          type: string
          title: Message
          description: Human-readable description of what went wrong.
          examples:
            - >-
              Exactly one of contact_id, to, chat_guid, or participant_phones is
              required
      type: object
      required:
        - error
        - message
      title: ApiErrorDetail
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ib_pk_<64 hex>
      description: >-
        Bearer API key. Format: `Authorization: Bearer ib_pk_<64 hex>` (70
        characters). Generate keys from Dashboard → Settings → API Access.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.