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"]
}'
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);
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"])
{
"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"
}
{
"detail": {
"error": "validation_error",
"message": "company: Extra inputs are not permitted"
}
}
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.
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"]
}'
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);
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"])
{
"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"
}
{
"detail": {
"error": "validation_error",
"message": "company: Extra inputs are not permitted"
}
}
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.
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. |
422 validation_error, and the message names the field. Every value is checked before anything is saved, so a rejected call changes nothing.
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"]
}'
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);
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"])
{
"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"
}
{
"detail": {
"error": "validation_error",
"message": "company: Extra inputs are not permitted"
}
}
Responses
201 Created{
"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"
}
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
{
"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"
}
vip matched the existing VIP tag.
401 Unauthorized
{
"detail": {
"error": "unauthorized",
"message": "Invalid or revoked API key"
}
}
message values: Authorization Bearer token required, API key required (use Authorization: Bearer ib_pk_…), Invalid API key format, Customer account not found.
403 Forbidden
{
"detail": {
"error": "forbidden",
"message": "Account is suspended"
}
}
message is then Account is cancelled). Every API-key endpoint returns this.
422 Unprocessable Entity
{
"detail": {
"error": "validation_error",
"message": "company: Extra inputs are not permitted"
}
}
error values:
validation_errorfor a bad shape:phonenot 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 addressinvalid_tag:A tag name needs 1–64 characters and no invisible characters (got '')invalid_timezone:timezoneoutside the supported list.duplicate_tag: a tag name collides with an existing tag in a way iBubbl can’t resolve. Rare; change the name.
{
"detail": {
"error": "internal_error",
"message": "The tag was deleted while it was being added; retry"
}
}
Authorizations
Bearer API key. Format: Authorization: Bearer ib_pk_<64 hex> (70 characters). Generate keys from Dashboard → Settings → API Access.
Body
Fields not listed here are rejected with 422 validation_error.
Phone number in E.164 format (e.g. +14155551234). The unique key for contacts — upserting an existing phone returns the same contact ID.
"+14155551234"
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}.
128"Sam"
Contact's last name. Same sticky-update semantics as first_name.
128"Rivera"
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.
254"sam@example.com"
City. Trimmed; blank is treated as not sent. Same sticky-update semantics as first_name.
128"Brooklyn"
State, province or region. Trimmed, and a 2-letter code is upper-cased (ny → NY). Same sticky-update semantics as first_name.
128"NY"
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.
"America/Los_Angeles"
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.
20["VIP", "Spring promo"]
Response
Existing contact updated.
Unique contact identifier (UUID).
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.
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.
ISO 8601 UTC timestamp the contact was created.
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.
The contact's tag names, sorted case-insensitively.
ISO 8601 UTC timestamp of the opt-out, or null.
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.
ISO 8601 UTC timestamp of the block, or null.

