> ## 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.

# Create or update a contact

> Upsert a contact by E.164 phone number. Returns `201 Created` on a new contact, `200 OK` on an existing match. A number whose only match is a deleted contact creates a new contact.

Optional fields (`first_name`, `last_name`, `email`, `city`, `state`, `timezone`) apply only when the existing record's value is null — values set previously by a rep or by a prior API call are preserved. This protects human-edited data from being clobbered by automation; to overwrite, use `PATCH /v1/contacts/{id}`.

`tags` only ever adds tags. Every value is checked before anything is saved.

If `timezone` is provided, it must be one of the 20 supported IANA timezones (US, Canada, and a few international zones such as `Europe/London`); anything else returns `422 invalid_timezone`.

A new contact for a number that opted out earlier (for example, one you deleted after an opt-out) starts with `opted_out: true`.

### Create or update a contact

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

**Description:** Create a contact, or match an existing one, by phone number, and add tags. On an existing contact the optional fields are only filled where they are still empty: values a rep or an earlier call set are never overwritten here. To overwrite, use [Update a contact](https://docs.ibubbl.com/api-reference/contacts/update.md).

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

#### Request Body

| Field | Type | Required | Description |
| - | - | - | - |
| `phone` | string (E.164, e.g. `+14155551234`) | Yes | The contact's phone number, and the key it is matched on. Sending the same number again returns the same contact. |
| `first_name` | string, at most 128 characters | No | Only applied if the contact has no first name yet. |
| `last_name` | string, at most 128 characters | No | Only applied if the contact has no last name yet. |
| `email` | string, at most 254 characters | No | Must look like an email address (`name@domain.tld`, no spaces), or `422 invalid_email`. Only applied if the contact has no email yet. |
| `city` | string, at most 128 characters | No | Trimmed; blank counts as not sent. Only applied if the contact has no city yet. |
| `state` | string, at most 128 characters | No | Trimmed, and a 2-letter code is upper-cased (`ny` becomes `NY`). Only applied if the contact has no state yet. |
| `timezone` | string | No | IANA zone used for the contact's sending hours. Must be one of 20 supported zones (the US, Canada and a few international ones, e.g. `Europe/London`). Only applied if the contact has no timezone yet, so set it on the first call, before you send. |
| `tags` | array of strings, at most 20 | No | Tag names to add. Only ever adds: tags the contact has are kept and none are removed. A name matches an existing tag ignoring case and outer spaces (`vip` uses `VIP`); a new name creates the tag. Each name is 1–64 characters with no invisible characters. A newly added tag can start workflows with a "Tag added" trigger. |

Fields not listed above are rejected with `422 validation_error`, and the message names the field. Every value is checked before anything is saved, so a rejected call changes nothing.

```bash cURL theme={null}
curl -X POST https://api.ibubbl.com/v1/contacts \
  -H "Authorization: Bearer $IBUBBL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+14155551234",
    "first_name": "Sam",
    "last_name": "Rivera",
    "email": "sam@example.com",
    "city": "Brooklyn",
    "state": "ny",
    "timezone": "America/New_York",
    "tags": ["VIP", "Spring promo"]
  }'
```

```typescript TypeScript theme={null}
const res = await fetch("https://api.ibubbl.com/v1/contacts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    phone: "+14155551234",
    first_name: "Sam",
    last_name: "Rivera",
    email: "sam@example.com",
    city: "Brooklyn",
    state: "ny",
    timezone: "America/New_York",
    tags: ["VIP", "Spring promo"],
  }),
  signal: AbortSignal.timeout(10_000),
});

const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`);
// 201: a new contact was created; 200: an existing one matched.
console.log(res.status, data.id, data.tags);
```

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

import requests

API_KEY = os.environ["IBUBBL_API_KEY"]

res = requests.post(
    "https://api.ibubbl.com/v1/contacts",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "phone": "+14155551234",
        "first_name": "Sam",
        "last_name": "Rivera",
        "email": "sam@example.com",
        "city": "Brooklyn",
        "state": "ny",
        "timezone": "America/New_York",
        "tags": ["VIP", "Spring promo"],
    },
    timeout=10,
)
data = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
# 201: a new contact was created; 200: an existing one matched.
print(res.status_code, data["id"], data["tags"])
```

```json 201 Created theme={null}
{
  "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-05T00:00:00+00:00"
}
```

```json 422 Unprocessable Entity theme={null}
{
  "detail": {
    "error": "validation_error",
    "message": "company: Extra inputs are not permitted"
  }
}
```

#### Responses

**201 Created**

```json theme={null}
{
  "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-05T00:00:00+00:00"
}
```

A new contact was created. A number whose only match is a [deleted contact](https://docs.ibubbl.com/api-reference/contacts/delete.md) also creates a new one, with a new `id`; if that number had opted out, the new contact starts with `opted_out: true`. `tags` lists the contact's tag names, sorted.

**200 OK**

```json theme={null}
{
  "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": [
    "VIP"
  ],
  "opted_out": false,
  "opted_out_at": null,
  "blocked": false,
  "blocked_at": null,
  "created_at": "2026-11-05T14:23:45.123Z"
}
```

The number matched an existing contact: empty fields were filled from the request, and the tags were added. `vip` matched the existing `VIP` tag.

**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": "company: Extra inputs are not permitted"
  }
}
```

Other `error` values:

* `validation_error` for a bad shape: `phone` not E.164 (`phone must be E.164 format (e.g. +14155551234)`), a field too long, or more than 20 tags.
* `invalid_email`: `'sam@example' is not an email address`
* `invalid_tag`: `A tag name needs 1–64 characters and no invisible characters (got '')`
* `invalid_timezone`: `timezone` outside the supported list.
* `duplicate_tag`: a tag name collides with an existing tag in a way iBubbl can't resolve. Rare; change the name.

**500 Internal Server Error**

```json theme={null}
{
  "detail": {
    "error": "internal_error",
    "message": "The tag was deleted while it was being added; retry"
  }
}
```

Only if a tag is deleted in the dashboard twice while this call is adding it. Retry the request.


## OpenAPI

````yaml POST /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:
    post:
      tags:
        - Contacts
      summary: Create or update a contact
      description: >-
        Upsert a contact by E.164 phone number. Returns `201 Created` on a new
        contact, `200 OK` on an existing match. A number whose only match is a
        deleted contact creates a new contact.


        Optional fields (`first_name`, `last_name`, `email`, `city`, `state`,
        `timezone`) apply only when the existing record's value is null — values
        set previously by a rep or by a prior API call are preserved. This
        protects human-edited data from being clobbered by automation; to
        overwrite, use `PATCH /v1/contacts/{id}`.


        `tags` only ever adds tags. Every value is checked before anything is
        saved.


        If `timezone` is provided, it must be one of the 20 supported IANA
        timezones (US, Canada, and a few international zones such as
        `Europe/London`); anything else returns `422 invalid_timezone`.


        A new contact for a number that opted out earlier (for example, one you
        deleted after an opt-out) starts with `opted_out: true`.
      operationId: v1_upsert_contact_v1_contacts_post
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactUpsert'
      responses:
        '200':
          description: Existing contact updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '201':
          description: New contact created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '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` (bad request shape, `phone` not E.164, an unknown
            field), `invalid_timezone`, `invalid_email`, `invalid_tag` or
            `duplicate_tag`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '500':
          description: >-
            `internal_error`: a tag was deleted twice while it was being added.
            Retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
components:
  schemas:
    ContactUpsert:
      properties:
        phone:
          type: string
          title: Phone
          description: >-
            Phone number in E.164 format (e.g. `+14155551234`). The unique key
            for contacts — upserting an existing phone returns the same contact
            ID.
          examples:
            - '+14155551234'
        first_name:
          anyOf:
            - type: string
              maxLength: 128
            - type: 'null'
          title: First Name
          description: >-
            Contact's first name. Only applied if the existing record's
            `first_name` is null — rep-edited values are sticky and won't be
            overwritten by automation. To change it, use `PATCH
            /v1/contacts/{id}`.
          examples:
            - Sam
        last_name:
          anyOf:
            - type: string
              maxLength: 128
            - type: 'null'
          title: Last Name
          description: Contact's last name. Same sticky-update semantics as `first_name`.
          examples:
            - Rivera
        email:
          anyOf:
            - type: string
              maxLength: 254
            - type: 'null'
          title: Email
          description: >-
            Email address. Must look like one (`name@domain.tld`, no spaces), or
            the call returns `422 invalid_email`. Same sticky-update semantics
            as `first_name`.
          examples:
            - sam@example.com
        city:
          anyOf:
            - type: string
              maxLength: 128
            - type: 'null'
          title: City
          description: >-
            City. Trimmed; blank is treated as not sent. Same sticky-update
            semantics as `first_name`.
          examples:
            - Brooklyn
        state:
          anyOf:
            - type: string
              maxLength: 128
            - type: 'null'
          title: State
          description: >-
            State, province or region. Trimmed, and a 2-letter code is
            upper-cased (`ny` → `NY`). Same sticky-update semantics as
            `first_name`.
          examples:
            - NY
        timezone:
          anyOf:
            - type: string
            - type: 'null'
          title: Timezone
          description: >-
            IANA timezone for working-hours scheduling. Must be one of the
            supported allowlist (US/Canada timezones, plus common
            international). Sends to this contact respect the timezone for
            outside-hours scheduling. Same sticky-update semantics as names.
          examples:
            - America/Los_Angeles
        tags:
          anyOf:
            - items:
                type: string
              type: array
              maxItems: 20
            - type: 'null'
          title: Tags
          description: >-
            Tag names to add, at most 20. Only ever adds: tags the contact
            already has are kept, and none are removed. A name matches an
            existing tag trimmed and case-insensitively (`vip` reuses `VIP`); a
            new name creates the tag. Each name is 1–64 characters with no
            invisible characters, or the call returns `422 invalid_tag` before
            anything is saved. A newly added tag can start workflows with a "Tag
            added" trigger.
          examples:
            - - VIP
              - Spring promo
      additionalProperties: false
      type: object
      required:
        - phone
      title: ContactUpsert
      description: Fields not listed here are rejected with `422 validation_error`.
      example:
        city: Brooklyn
        email: sam@example.com
        first_name: Sam
        last_name: Rivera
        phone: '+14155551234'
        state: NY
        tags:
          - VIP
          - Spring promo
        timezone: America/New_York
    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
    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
    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.