# Add a tag to a contact Source: https://docs.ibubbl.com/api-reference/contacts/add-tag POST /v1/contacts/{contact_id}/tags Add one tag. Idempotent: a tag the contact already has answers `200` unchanged. The name matches an existing tag trimmed and case-insensitively; a new name creates the tag. A newly added tag can start workflows with a "Tag added" trigger. ### Add a tag to a contact **Method:** `POST` | **Endpoint:** `/v1/contacts/{contact_id}/tags` **Description:** Add one tag to a contact. Calling it again with the same name changes nothing, so it is safe to retry. A newly added tag can start workflows with a "Tag added" trigger. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body The path parameter `contact_id` is the contact's ID (a UUID). | Field | Type | Required | Description | | - | - | - | - | | `name` | string, 1–64 characters | Yes | The tag name, with no invisible characters. It matches an existing tag ignoring case and outer spaces; a new name creates the tag. | Fields not listed above are rejected with `422 validation_error`, and the message names the field. ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f/tags \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Spring promo"}' ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}/tags`, { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Spring promo" }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.tags); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" res = requests.post( f"https://api.ibubbl.com/v1/contacts/{contact_id}/tags", headers={"Authorization": f"Bearer {API_KEY}"}, json={"name": "Spring promo"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["tags"]) ``` ```json 200 OK 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-05T14:23:45.123Z" } ``` ```json 422 Unprocessable Entity theme={null} { "detail": { "error": "invalid_tag", "message": "A tag name needs 1–64 characters and no invisible characters" } } ``` #### Responses **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": [ "Spring promo", "VIP" ], "opted_out": false, "opted_out_at": null, "blocked": false, "blocked_at": null, "created_at": "2026-11-05T14:23:45.123Z" } ``` The contact, with all its tags. The same response comes back if the contact already had the 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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a malformed ID, a deleted contact, and a contact that belongs to another account. **422 Unprocessable Entity** ```json theme={null} { "detail": { "error": "invalid_tag", "message": "A tag name needs 1–64 characters and no invisible characters" } } ``` Other `error` values: `validation_error` for a bad shape or an unknown field (`color: Extra inputs are not permitted`); `duplicate_tag`, rare, when the name collides with an existing tag in a way iBubbl can't resolve. **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. # Block a contact Source: https://docs.ibubbl.com/api-reference/contacts/block POST /v1/contacts/{contact_id}/block A block is yours, on this contact: sends to them return `422 contact_blocked` (group conversations they're in are not affected). Their inbound messages are still stored and still fire `message.inbound`, but don't mark the conversation unread, notify anyone or start workflows. Unlike an opt-out, a block fires no webhook and can be undone with `DELETE /v1/contacts/{id}/block`. A contact deleted and created again starts unblocked. Idempotent: blocking a blocked contact answers `200` and keeps the original `blocked_at`. ### Block a contact **Method:** `POST` | **Endpoint:** `/v1/contacts/{contact_id}/block` **Description:** Stop one-to-one messages to a contact until you [unblock](/api-reference/contacts/unblock) them. A block is your choice and can be undone; an [opt-out](/api-reference/contacts/opt-out) is the contact's and can't. See [Block or opt out](/concepts/suppression#block-or-opt-out). **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameter `contact_id` is the contact's ID (a UUID). #### What a block does * **Sends are refused.** One-to-one sends to the contact, from the API, the dashboard or an automation, return `422 contact_blocked`. Group conversations they're in are not affected. * **Their messages still arrive.** Inbound messages from them are stored and still fire [`message.inbound`](/webhooks/events), but they don't mark the conversation unread, notify your team, or start workflows. * **No webhook fires**, for the block or the unblock. * **It's safe to call twice.** Blocking a blocked contact answers `200` and keeps the original `blocked_at`. * **It belongs to this contact.** If the contact is [deleted](/api-reference/contacts/delete) and the number later comes back as a new contact, the new one isn't blocked. ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f/block \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}/block`, { method: "POST", 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}`); console.log(data.blocked, data.blocked_at); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" res = requests.post( f"https://api.ibubbl.com/v1/contacts/{contact_id}/block", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["blocked"], data["blocked_at"]) ``` ```json 200 OK 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": true, "blocked_at": "2026-11-11T09:20:44.902+00:00", "created_at": "2026-11-05T14:23:45.123+00:00" } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` #### Responses **200 OK** The contact, with `blocked: true`. ```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": true, "blocked_at": "2026-11-11T09:20:44.902+00:00", "created_at": "2026-11-05T14:23:45.123+00:00" } ``` **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a malformed ID, a deleted contact, and a contact that belongs to another account. # Delete a contact Source: https://docs.ibubbl.com/api-reference/contacts/delete DELETE /v1/contacts/{contact_id} Delete a contact. It disappears from the list and from `GET /v1/contacts/{id}`, and every endpoint that takes its `id` (including `POST /v1/messages` with `contact_id`) returns `404 not_found`. Deleting it again also returns `404`. What stays: its messages and conversations, which `GET /v1/messages` still lists under the old `contact_id`; messages already accepted for it and still waiting, which still go out (cancel them in the dashboard to stop them); and its opt-out, which belongs to the number. Upserting the same number, sending to it with `to`, or an inbound message from it creates a **new** contact with a new `id`. Names, tags and a block are not carried over; an opt-out is. ### Delete a contact **Method:** `DELETE` | **Endpoint:** `/v1/contacts/{contact_id}` **Description:** Remove a contact from your account. It disappears from [List contacts](/api-reference/contacts/list) and from every endpoint that takes its ID. Its message history stays. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameter `contact_id` is the contact's ID (a UUID). #### What deleting does * **The ID stops working.** [Retrieve](/api-reference/contacts/retrieve), [Update](/api-reference/contacts/update), the tag, opt-out and block endpoints, and [Send a message](/api-reference/messages/send) with this `contact_id` all return `404 not_found`. Deleting it again also returns `404`. * **History stays.** Its messages and conversations are kept, and [List messages](/api-reference/messages/list) still returns them with the old `contact_id`. * **Waiting messages still go out.** A message already accepted for the contact and still waiting (held for [sending hours](/concepts/working-hours), or queued) is not cancelled. Cancel it in the dashboard first if it shouldn't be sent. * **The number can come back as a new contact.** [Creating or updating a contact](/api-reference/contacts/upsert) with the same number, sending to it with `to`, or an inbound message from it creates a **new** contact with a new `id`. Names, tags and a [block](/api-reference/contacts/block) are not carried over. * **An opt-out is never lost.** An [opt-out](/api-reference/contacts/opt-out) belongs to the phone number, not the contact. A new contact for an opted-out number starts with `opted_out: true`, and sends to it return `422 contact_opted_out`. Deleting a contact is not a way to message someone who opted out. No webhook fires for a deletion. ```bash cURL theme={null} curl -X DELETE https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}`, { method: "DELETE", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}` }, signal: AbortSignal.timeout(10_000), }); // 204 has no body; only an error does. if (!res.ok) { const data = await res.json(); throw new Error(`${res.status} ${data.detail?.error}`); } console.log("deleted:", res.status); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" res = requests.delete( f"https://api.ibubbl.com/v1/contacts/{contact_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) # 204 has no body; only an error does. if not res.ok: data = res.json() raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print("deleted:", res.status_code) ``` ```text 204 No Content theme={null} (empty body) ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` #### Responses **204 No Content** The contact is deleted. The response has no body. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a malformed ID, a contact that is already deleted, and a contact that belongs to another account. If a retried delete gets `404`, the first attempt already deleted it. # List contacts Source: https://docs.ibubbl.com/api-reference/contacts/list GET /v1/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](/concepts/pagination). | | `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](/concepts/suppression). `false`: only the others. | | `blocked` | boolean | `true`: only contacts you've [blocked](/api-reference/contacts/block). `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](/concepts/limits#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](/api-reference/contacts/retrieve) 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`. # Opt a contact out Source: https://docs.ibubbl.com/api-reference/contacts/opt-out POST /v1/contacts/{contact_id}/opt-out Record that this person opted out of messages, as a STOP reply does. **This can't be undone**, through the API or the dashboard: every later send to this number answers `422 contact_opted_out`, even after the contact is deleted and created again. The first opt-out fires a `contact.opted_out` webhook with `source: "api"`. New sends are refused from then on, but a message accepted earlier and still waiting (held for sending hours, or queued) can still go out; cancel it in the dashboard to stop it. Idempotent: a contact already opted out (in any way) answers `200` with its current state, and no second webhook fires. ### Opt a contact out **Method:** `POST` | **Endpoint:** `/v1/contacts/{contact_id}/opt-out` **Description:** Record that this person doesn't want your messages, the same as when they reply STOP. Use it to bring opt-outs from your CRM or another channel into iBubbl. > [!WARNING] > > **An opt-out can't be undone.** There is no opt-in endpoint, and the dashboard can't reverse it either. From then on every send to this number answers `422 contact_opted_out`, from the API, the dashboard and automations alike. It belongs to the phone number: deleting the contact and creating it again doesn't clear it. Only call this for a person who asked not to be messaged. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameter `contact_id` is the contact's ID (a UUID). #### What happens * `opted_out` becomes `true` and `opted_out_at` is set. * The first opt-out for this person fires a [`contact.opted_out`](/webhooks/events) webhook with `source: "api"`. * **It's safe to call twice.** If the person has already opted out, in any way and under any form of their number (a STOP reply, a rep, GoHighLevel Do Not Disturb, or an earlier call), you get `200` with the contact as it is, and no second webhook fires. * **Messages already accepted can still go out.** New sends are refused from now on, but a message accepted earlier and still waiting (held for [sending hours](/concepts/working-hours), or queued) is not withdrawn. Cancel it in the dashboard if it must not be sent. See [Suppression & Opt-Outs](/concepts/suppression). To stop messaging someone only for now, [block](/api-reference/contacts/block) them instead: a block can be undone. ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f/opt-out \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}/opt-out`, { method: "POST", 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}`); console.log(data.opted_out, data.opted_out_at); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" res = requests.post( f"https://api.ibubbl.com/v1/contacts/{contact_id}/opt-out", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["opted_out"], data["opted_out_at"]) ``` ```json 200 OK 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": true, "opted_out_at": "2026-11-11T09:12:03.418+00:00", "blocked": false, "blocked_at": null, "created_at": "2026-11-05T14:23:45.123+00:00" } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` #### Responses **200 OK** The contact, with `opted_out: true`. On a repeat call, `opted_out_at` is the time of the first opt-out. ```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": true, "opted_out_at": "2026-11-11T09:12:03.418+00:00", "blocked": false, "blocked_at": null, "created_at": "2026-11-05T14:23:45.123+00:00" } ``` **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a malformed ID, a deleted contact, and a contact that belongs to another account. Nothing was recorded. # Remove a tag from a contact Source: https://docs.ibubbl.com/api-reference/contacts/remove-tag DELETE /v1/contacts/{contact_id}/tags/{name} Remove a tag by name (URL-encode it), matched trimmed and case-insensitively. Idempotent: a tag the contact doesn't have answers `200` unchanged. Removing a tag starts no workflow. ### Remove a tag from a contact **Method:** `DELETE` | **Endpoint:** `/v1/contacts/{contact_id}/tags/{name}` **Description:** Take a tag off a contact by name, ignoring case and outer spaces. Calling it for a tag the contact doesn't have changes nothing. Removing a tag starts no workflow, and the tag itself stays available for other contacts. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameters are `contact_id`, the contact's ID (a UUID), and `name`, the tag name, URL-encoded (`Spring%20promo`). If two tags differ only by case (`VIP` and `vip`), both come off. ```bash cURL theme={null} # URL-encode the tag name: "Spring promo" becomes Spring%20promo. curl -X DELETE https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f/tags/vip \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const tag = "vip"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}/tags/${encodeURIComponent(tag)}`, { method: "DELETE", 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}`); console.log(data.tags); ``` ```python Python theme={null} import os from urllib.parse import quote import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" tag = "vip" res = requests.delete( f"https://api.ibubbl.com/v1/contacts/{contact_id}/tags/{quote(tag, safe='')}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["tags"]) ``` ```json 200 OK 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" ], "opted_out": false, "opted_out_at": null, "blocked": false, "blocked_at": null, "created_at": "2026-11-05T14:23:45.123Z" } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` #### Responses **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": [ "Spring promo" ], "opted_out": false, "opted_out_at": null, "blocked": false, "blocked_at": null, "created_at": "2026-11-05T14:23:45.123Z" } ``` The contact, with the tags it has left. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a malformed ID, a deleted contact, and a contact that belongs to another account. # Retrieve a contact Source: https://docs.ibubbl.com/api-reference/contacts/retrieve GET /v1/contacts/{contact_id} Fetch a single contact by ID. Returns `404 not_found` for both missing contacts and contacts owned by a different customer — existence is not leaked across tenants. ### Retrieve a contact **Method:** `GET` | **Endpoint:** `/v1/contacts/{contact_id}` **Description:** Fetch one contact by its ID, with its tags and whether it has opted out. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameter `contact_id` is the contact's ID (a UUID), from Create or update a contact, a send response, or a webhook event. ```bash cURL theme={null} curl https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}`, { 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}`); console.log(data.phone, data.opted_out); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" res = requests.get( f"https://api.ibubbl.com/v1/contacts/{contact_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["phone"], data["opted_out"]) ``` ```json 200 OK 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-05T14:23:45.123Z" } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` #### Responses **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": [ "Spring promo", "VIP" ], "opted_out": false, "opted_out_at": null, "blocked": false, "blocked_at": null, "created_at": "2026-11-05T14:23:45.123Z" } ``` When `opted_out` is `true`, every send to this contact returns `422 contact_opted_out`. When `blocked` is `true`, one-to-one sends return `422 contact_blocked` until you [unblock](/api-reference/contacts/unblock) them. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a malformed ID, a deleted contact, and a contact that belongs to another account. # Unblock a contact Source: https://docs.ibubbl.com/api-reference/contacts/unblock DELETE /v1/contacts/{contact_id}/block Undo a block: sends to this contact are accepted again. An opt-out is not affected. Idempotent: unblocking a contact that isn't blocked answers `200` unchanged. ### Unblock a contact **Method:** `DELETE` | **Endpoint:** `/v1/contacts/{contact_id}/block` **Description:** Undo a [block](/api-reference/contacts/block): sends to the contact are accepted again, and their messages count as unread and start workflows as before. An opt-out is not affected: if the contact has opted out, sends still return `422 contact_opted_out`. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameter `contact_id` is the contact's ID (a UUID). Unblocking a contact that isn't blocked answers `200` unchanged. No webhook fires. ```bash cURL theme={null} curl -X DELETE https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f/block \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}/block`, { method: "DELETE", 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}`); console.log(data.blocked); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" res = requests.delete( f"https://api.ibubbl.com/v1/contacts/{contact_id}/block", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["blocked"]) ``` ```json 200 OK 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-05T14:23:45.123+00:00" } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` #### Responses **200 OK** The contact, with `blocked: false` and `blocked_at: null`. ```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-05T14:23:45.123+00:00" } ``` **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a malformed ID, a deleted contact, and a contact that belongs to another account. # Update a contact Source: https://docs.ibubbl.com/api-reference/contacts/update PATCH /v1/contacts/{contact_id} Overwrite the fields you send. Fields you leave out are not touched; a field sent as `null` is cleared. Unlike the upsert, this replaces values a rep set. `phone` can't be changed. Tags have their own endpoints. ### Update a contact **Method:** `PATCH` | **Endpoint:** `/v1/contacts/{contact_id}` **Description:** Overwrite the fields you send. Fields you leave out are not touched, and a field sent as `null` is cleared. Unlike Create or update a contact, this replaces values a rep set. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body The path parameter `contact_id` is the contact's ID (a UUID). Send at least one field; an empty body changes nothing and returns the contact. | Field | Type | Required | Description | | - | - | - | - | | `first_name` | string or `null`, at most 128 characters | No | New first name; `null` clears it. | | `last_name` | string or `null`, at most 128 characters | No | New last name; `null` clears it. | | `email` | string or `null`, at most 254 characters | No | Must look like an email address, or `422 invalid_email`; `null` clears it. | | `city` | string or `null`, at most 128 characters | No | Trimmed; blank or `null` clears it. | | `state` | string or `null`, at most 128 characters | No | Trimmed, a 2-letter code upper-cased; blank or `null` clears it. | | `timezone` | string or `null` | No | One of the supported IANA zones, or `422 invalid_timezone`. `null` makes it automatic again: iBubbl infers the zone from a US or Canadian area code. | Fields not listed above are rejected with `422 validation_error`, and the message names the field. `phone` can't be changed, and tags have their own endpoints: [Add a tag](/api-reference/contacts/add-tag) and [Remove a tag](/api-reference/contacts/remove-tag). ```bash cURL theme={null} curl -X PATCH https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Sam", "email": "sam.rivera@example.com", "city": null }' ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}`, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ first_name: "Sam", email: "sam.rivera@example.com", city: null, // null clears the field }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.first_name, data.email, data.city); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" res = requests.patch( f"https://api.ibubbl.com/v1/contacts/{contact_id}", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "first_name": "Sam", "email": "sam.rivera@example.com", "city": None, # None (JSON null) clears the field }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["first_name"], data["email"], data["city"]) ``` ```json 200 OK theme={null} { "id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "phone": "+14155551234", "first_name": "Sam", "last_name": "Rivera", "email": "sam.rivera@example.com", "city": null, "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" } ``` ```json 422 Unprocessable Entity theme={null} { "detail": { "error": "validation_error", "message": "phone: Extra inputs are not permitted" } } ``` #### Responses **200 OK** ```json theme={null} { "id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "phone": "+14155551234", "first_name": "Sam", "last_name": "Rivera", "email": "sam.rivera@example.com", "city": null, "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" } ``` `first_name` and `email` were replaced, `city` was cleared, and every other field kept its value. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a malformed ID, a deleted contact, and a contact that belongs to another account. **422 Unprocessable Entity** ```json theme={null} { "detail": { "error": "validation_error", "message": "phone: Extra inputs are not permitted" } } ``` Other `error` values: `validation_error` for any other unknown field (including `tags`) or a value too long; `invalid_email` (`'sam' is not an email address`); `invalid_timezone`. # Create or update a contact Source: https://docs.ibubbl.com/api-reference/contacts/upsert POST /v1/contacts 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](/api-reference/contacts/update). **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](/api-reference/contacts/delete) 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. # Mark a conversation read Source: https://docs.ibubbl.com/api-reference/conversations/mark-read POST /v1/conversations/{conversation_id}/read Mark every message you've received in this conversation read. It clears the unread badge in the iBubbl inbox and, when read receipts are on for your account, sends the contact a read receipt: they see "Read" under their latest message on iMessage, and on RCS (Android). SMS has no read receipts. A receipt goes out only when there was something unread, and never to a group, a blocked contact or one who opted out. `read_receipt` says whether one was queued, and why not. Idempotent: calling again with nothing new answers `200` with `marked_count: 0`. Counts toward the conversation-actions rate limit (300 a minute per account). ### Mark a conversation read **Method:** `POST` | **Endpoint:** `/v1/conversations/{conversation_id}/read` **Description:** Mark every message you've received in a conversation as read, and send the contact a read receipt: on iMessage they see **Read** under their latest message. It works on the whole conversation at once, because that's how read receipts work on iMessage and RCS. See [Tapbacks, read receipts and typing](/guides/reactions-read-receipts-typing#read-receipts). **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. **Rate limit:** 300 conversation actions per 60 seconds per account, shared with tapbacks and typing. Over that, `429 rate_limited` with a `Retry-After` header; see [Rate limits](/concepts/limits#rate-limits). #### Request Body None. The path parameter `conversation_id` is the conversation's ID (a UUID): `conversation_id` on every [message](/api-reference/messages/retrieve) and on every [`message.*` webhook](/webhooks/events). #### How it behaves * **The messages are marked read in iBubbl.** The conversation's unread badge clears in the dashboard too, for your whole team. * **A receipt goes out only when there's something new.** `read_receipt: "queued"` means the contact will see Read within a few seconds. `"skipped"` means no receipt goes out, and `read_receipt_skipped_reason` says why. * **Never in a group, and never to a blocked or opted-out contact.** The messages are still marked read in iBubbl. * **Read receipts are on by default.** To turn them off for your account, email [support@ibubbl.com](mailto:support@ibubbl.com); calls then answer `skipped` with `receipts_off`. * **It's safe to call twice.** A second call with nothing new answers `200` with `marked_count: 0`. | `read_receipt_skipped_reason` | Meaning | | - | - | | `nothing_unread` | Nothing new to mark since the last read. | | `receipts_off` | Read receipts are turned off for your account. | | `group` | A group conversation: receipts are never sent in groups. | | `contact_blocked` | You [blocked](/api-reference/contacts/block) the contact. | | `contact_opted_out` | The contact opted out. | | `opt_out_check_unavailable` | iBubbl couldn't check the contact's opt-out, so it sent no receipt. Call again later if it matters. | ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/conversations/3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d/read \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const conversationId = "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d"; const res = await fetch(`https://api.ibubbl.com/v1/conversations/${conversationId}/read`, { method: "POST", 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}`); console.log(data.marked_count, data.read_receipt, data.read_receipt_skipped_reason); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] conversation_id = "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d" res = requests.post( f"https://api.ibubbl.com/v1/conversations/{conversation_id}/read", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["marked_count"], data["read_receipt"], data["read_receipt_skipped_reason"]) ``` ```json 200 OK theme={null} { "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "marked_count": 2, "read_receipt": "queued", "read_receipt_skipped_reason": null } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Conversation not found" } } ``` #### Responses **200 OK** | Field | Description | | - | - | | `conversation_id` | The conversation. | | `marked_count` | How many received messages this call marked read (`0` if none were unread). | | `read_receipt` | `queued`: the contact will see Read. `skipped`: no receipt goes out. | | `read_receipt_skipped_reason` | Why it was skipped (table above), or `null` when queued. | ```json theme={null} { "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "marked_count": 0, "read_receipt": "skipped", "read_receipt_skipped_reason": "nothing_unread" } ``` **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Conversation not found" } } ``` Also returned for a malformed ID and a conversation that belongs to another account. **429 Too Many Requests** ```json theme={null} { "detail": { "error": "rate_limited", "message": "Too many conversation actions; wait 12s (limit 300 a minute per account)." } } ``` Wait the `Retry-After` header's seconds, then send the same request again. # Start typing Source: https://docs.ibubbl.com/api-reference/conversations/start-typing POST /v1/conversations/{conversation_id}/typing Show the contact the typing bubble (the three dots) in this conversation. It lasts 10 seconds: call again every 5 seconds to keep it going. Sending a message clears it on the contact's phone; so does `DELETE`, or simply not calling again. Typing shows only in a 1:1 iMessage conversation the contact has already messaged. Anywhere else the call answers `200` with `status: "ignored"` and an `ignored_reason`, and does nothing. Typing is a signal to the contact, so it's refused for a blocked contact or one who opted out. Counts toward the conversation-actions rate limit (300 a minute per account); calls closer than 3 seconds apart are answered without doing anything new. ### Start typing **Method:** `POST` | **Endpoint:** `/v1/conversations/{conversation_id}/typing` **Description:** Show the contact the typing bubble (the three animated dots) in an iMessage conversation, for example while your AI writes a reply. It lasts 10 seconds; call again every 5 seconds to keep it going. Sending a message clears it. See [Typing while your AI thinks](/guides/reactions-read-receipts-typing#typing-while-your-ai-thinks). **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. **Rate limit:** 300 conversation actions per 60 seconds per account, shared with tapbacks and mark read. Over that, `429 rate_limited` with a `Retry-After` header; see [Rate limits](/concepts/limits#rate-limits). A refresh every 5 seconds is 12 calls a minute per conversation. #### Request Body None. The path parameter `conversation_id` is the conversation's ID (a UUID): `conversation_id` on every [message](/api-reference/messages/retrieve) and on [`message.inbound`](/webhooks/events). #### How it behaves * **It lasts 10 seconds.** `expires_at` says when it stops unless you call again. The dots appear on the phone within about a second. * **Keep it going by calling every 5 seconds.** Calls less than 3 seconds apart are answered `started` without doing anything new, and keep the earlier `expires_at`. * **Sending ends it.** When you [send a message](/api-reference/messages/send), it replaces the dots straight away. If you stop calling instead, or call [Stop typing](/api-reference/conversations/stop-typing), iBubbl stops typing, but the dots can stay on the contact's iPhone for up to about a minute. * **iMessage, one-to-one only.** Where typing can't be shown, the call answers `200` with `status: "ignored"` and does nothing: | `ignored_reason` | Meaning | | - | - | | `group` | A group conversation. | | `not_imessage` | The contact is known not to use iMessage (an SMS or RCS contact). | | `no_chat` | The contact hasn't messaged this line yet, so there's no chat to type in. | * **Blocked and opted-out contacts are refused.** Typing is a signal to the contact, so a [blocked](/api-reference/contacts/block) contact gets `422 contact_blocked` and an opted-out one `422 contact_opted_out`. * **No webhook** fires for typing. ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/conversations/3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d/typing \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const conversationId = "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d"; const res = await fetch(`https://api.ibubbl.com/v1/conversations/${conversationId}/typing`, { method: "POST", 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}`); // "started", or "ignored" with ignored_reason where typing can't be shown. console.log(data.status, data.ignored_reason, data.expires_at); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] conversation_id = "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d" res = requests.post( f"https://api.ibubbl.com/v1/conversations/{conversation_id}/typing", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") # "started", or "ignored" with ignored_reason where typing can't be shown. print(data["status"], data["ignored_reason"], data["expires_at"]) ``` ```json 200 OK theme={null} { "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "typing": true, "status": "started", "ignored_reason": null, "expires_at": "2026-11-11T16:04:49.293232Z" } ``` ```json 422 Unprocessable Entity theme={null} { "detail": { "error": "contact_opted_out", "message": "This contact has opted out." } } ``` #### Responses **200 OK** | Field | Description | | - | - | | `conversation_id` | The conversation. | | `typing` | `true` when the dots are on after this call. | | `status` | `started` (or kept going), or `ignored`. | | `ignored_reason` | `group`, `not_imessage` or `no_chat` when ignored; otherwise `null`. | | `expires_at` | When the dots stop unless you call again (UTC, 10 seconds after the call that started or extended them). `null` unless `started`. | ```json theme={null} { "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "typing": false, "status": "ignored", "ignored_reason": "group", "expires_at": null } ``` **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Conversation not found" } } ``` Also returned for a malformed ID and a conversation that belongs to another account. **422 Unprocessable Entity** ```json theme={null} { "detail": { "error": "contact_blocked", "message": "This contact is blocked." } } ``` `contact_blocked` or `contact_opted_out`, in a one-to-one conversation. Nothing was shown. Group conversations are answered `ignored` instead. **429 Too Many Requests** ```json theme={null} { "detail": { "error": "rate_limited", "message": "Too many conversation actions; wait 12s (limit 300 a minute per account)." } } ``` Wait the `Retry-After` header's seconds, then call again. **503 Service Unavailable** ```json theme={null} { "detail": { "error": "opt_out_check_unavailable", "message": "Couldn't check the contact's opt-out. Try again." } } ``` iBubbl couldn't confirm the contact hasn't opted out, so it showed nothing. Retry after a short backoff. # Stop typing Source: https://docs.ibubbl.com/api-reference/conversations/stop-typing DELETE /v1/conversations/{conversation_id}/typing Stop showing the typing bubble. You don't need this before sending: a sent message clears it on its own. Some phones keep showing the bubble for up to a minute after a stop that isn't followed by a message. Typing shows only in a 1:1 iMessage conversation the contact has already messaged. Anywhere else the call answers `200` with `status: "ignored"` and an `ignored_reason`, and does nothing. Idempotent: stopping when nothing is typing answers `200` `stopped`. Counts toward the conversation-actions rate limit (300 a minute per account). ### Stop typing **Method:** `DELETE` | **Endpoint:** `/v1/conversations/{conversation_id}/typing` **Description:** Stop the typing bubble you started with [Start typing](/api-reference/conversations/start-typing), when you decide not to reply. You don't need it before sending: a message you send clears the dots on its own. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. **Rate limit:** 300 conversation actions per 60 seconds per account, shared with tapbacks, mark read and start typing. Over that, `429 rate_limited` with a `Retry-After` header; see [Rate limits](/concepts/limits#rate-limits). #### Request Body None. The path parameter `conversation_id` is the conversation's ID (a UUID). #### How it behaves * **iBubbl stops at once; some phones take longer.** The dots may stay on the contact's iPhone for up to about a minute after a stop that isn't followed by a message. Sending a message always clears them straight away. * **It's safe to call twice**, or when nothing is typing: the answer is `200` with `status: "stopped"`. * **Never refused for a block or an opt-out.** In a group, an SMS or RCS conversation, or one the contact hasn't messaged yet, it answers `ignored` with the same `ignored_reason` as Start typing. ```bash cURL theme={null} curl -X DELETE https://api.ibubbl.com/v1/conversations/3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d/typing \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const conversationId = "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d"; const res = await fetch(`https://api.ibubbl.com/v1/conversations/${conversationId}/typing`, { method: "DELETE", 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}`); console.log(data.status); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] conversation_id = "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d" res = requests.delete( f"https://api.ibubbl.com/v1/conversations/{conversation_id}/typing", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["status"]) ``` ```json 200 OK theme={null} { "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "typing": false, "status": "stopped", "ignored_reason": null, "expires_at": null } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Conversation not found" } } ``` #### Responses **200 OK** The same fields as [Start typing](/api-reference/conversations/start-typing), with `typing: false` and `status` `stopped` (or `ignored`). ```json theme={null} { "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "typing": false, "status": "stopped", "ignored_reason": null, "expires_at": null } ``` **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Conversation not found" } } ``` Also returned for a malformed ID and a conversation that belongs to another account. **429 Too Many Requests** ```json theme={null} { "detail": { "error": "rate_limited", "message": "Too many conversation actions; wait 12s (limit 300 a minute per account)." } } ``` Wait the `Retry-After` header's seconds, then call again. # Receive an event Source: https://docs.ibubbl.com/api-reference/inbound-events/receive POST /v1/events/{token} Receive a webhook from a connected third-party app (for example Recharge) and start any workflow whose **Webhook received** trigger matches it. The URL you paste into the sending app carries its own credential, so this endpoint takes no `Authorization` header. Create a source in **Settings → Inbound events** to get the full URL; the token is shown once. **Any app (secret URL)** sources need no signature: the URL is the credential, so keep it private. They accept JSON or `application/x-www-form-urlencoded` bodies and at most 60 requests a minute. Beyond that the answer is `429` while a workflow is listening on the source; with none listening, the event is accepted and dropped. **Signed** sources must sign every request. For Recharge, that is the `X-Recharge-Hmac-Sha256` header computed with your **API Client Secret** — which is not the same value as your API token. An unsigned or mis-signed request returns `403 invalid_signature`. **Redeliveries are safe.** Providers resend events, and Recharge's dunning fires one event per charge retry. Each workflow's `event_id_path` identifies the underlying object, so repeat deliveries of the same event fold onto the run that already exists rather than messaging the customer again. A `200` means the event was accepted, not that it matched anything — check `runs_started`, and see the source's status in Settings when it is unexpectedly `0`. ### Receive an event **Method:** `POST` | **Endpoint:** `/v1/events/{token}` **Description:** The URL a connected app (Recharge, your own system, or a no-code tool such as Zapier or Make) posts its events to. An event that matches an active workflow's "Webhook received" trigger starts a run. See [Inbound events](/inbound-events/overview) for setup. **Authentication:** No API key. The `token` in the path (`ibev_` followed by 64 hex characters, copied from the event source in the dashboard) identifies the source. A signed source must also send its signature header: Recharge sends `X-Recharge-Hmac-Sha256`; a generic signed source sends `X-iBubbl-Signature: t=,v1=`, an HMAC-SHA256 of `{t}.{raw body}` with the source's signing secret, no more than 5 minutes old. A secret-URL source sends no signature and is limited to 60 requests a minute. #### Request Body | Field | Type | Required | Description | | - | - | - | - | | (the app's event) | object | Yes | The connected app's own payload, sent unchanged as JSON (or form-encoded), at most 256 KB. iBubbl doesn't require any fields; your workflow maps the ones it needs. The signature is checked against these exact bytes, so don't re-serialize the body. | ```bash cURL theme={null} # No API key: the token in the URL identifies the source. # Sign the exact bytes you send, with the source's signing secret. BODY='{"topic": "order/created", "customer": {"phone": "+14155551234", "first_name": "Sam"}}' T=$(date +%s) SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$IBUBBL_EVENT_SECRET" | awk '{print $NF}') curl -X POST "https://api.ibubbl.com/v1/events/$IBUBBL_EVENT_SOURCE_TOKEN" \ -H "Content-Type: application/json" \ -H "X-iBubbl-Signature: t=$T,v1=$SIG" \ --data-raw "$BODY" ``` ```typescript TypeScript theme={null} import { createHmac } from "node:crypto"; // No API key: the token in the URL identifies the source. const token = process.env.IBUBBL_EVENT_SOURCE_TOKEN; const secret = process.env.IBUBBL_EVENT_SECRET ?? ""; // Serialize once, then sign and send that same string. const body = JSON.stringify({ topic: "order/created", customer: { phone: "+14155551234", first_name: "Sam" }, }); const t = Math.floor(Date.now() / 1000); const v1 = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"); const res = await fetch(`https://api.ibubbl.com/v1/events/${token}`, { method: "POST", headers: { "Content-Type": "application/json", "X-iBubbl-Signature": `t=${t},v1=${v1}`, }, body, signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.runs_started); ``` ```python Python theme={null} import hashlib import hmac import json import os import time import requests # No API key: the token in the URL identifies the source. TOKEN = os.environ["IBUBBL_EVENT_SOURCE_TOKEN"] SECRET = os.environ["IBUBBL_EVENT_SECRET"] # Serialize once, then sign and send those same bytes. body = json.dumps({ "topic": "order/created", "customer": {"phone": "+14155551234", "first_name": "Sam"}, }).encode() t = str(int(time.time())) v1 = hmac.new(SECRET.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest() res = requests.post( f"https://api.ibubbl.com/v1/events/{TOKEN}", headers={"Content-Type": "application/json", "X-iBubbl-Signature": f"t={t},v1={v1}"}, data=body, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["runs_started"]) ``` ```json 200 OK theme={null} { "received": true, "runs_started": 1 } ``` ```json 403 Forbidden theme={null} { "detail": { "error": "invalid_signature", "message": "Signature verification failed." } } ``` #### Responses **200 OK** ```json theme={null} { "received": true, "runs_started": 1 } ``` Every request that passes the signature check gets 200, including one that starts no run (`runs_started: 0`), so the sending app never retries or disables the hook. The reason an event started nothing is recorded on the event source. **403 Forbidden** ```json theme={null} { "detail": { "error": "invalid_signature", "message": "Signature verification failed." } } ``` The signature header is missing or doesn't match. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "No such event source." } } ``` The token is malformed, unknown or revoked. **413 Content Too Large** ```json theme={null} { "detail": { "error": "payload_too_large", "message": "Request body too large." } } ``` **429 Too Many Requests** ```json theme={null} { "detail": { "error": "rate_limited", "message": "Too many events for this source; try again in a minute." } } ``` Only a secret-URL source over 60 requests a minute while a workflow is listening on it. Without a listener, the extra events are dropped with 200. # List messages Source: https://docs.ibubbl.com/api-reference/messages/list GET /v1/messages List your messages, inbound and outbound, newest first. Use it to catch up after your webhook endpoint was down, or to read a conversation's history. Results are paginated: `has_more` tells you whether there is another page, and you get it by passing the `id` of the last message as `starting_after`. Pages are stable while new messages arrive: nothing is skipped or repeated. See [Pagination](/concepts/pagination). Filters combine with AND. A filter value that matches nothing (including another account's `contact_id` or `chat_guid`) returns an empty page. An unknown or repeated query parameter returns `422 validation_error`. Messages stay listed after their contact is deleted or blocked. 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 messages **Method:** `GET` | **Endpoint:** `/v1/messages` **Description:** List your messages, inbound and outbound, newest first. Use it to catch up after your webhook endpoint was down (see [Recover from downtime](/guides/webhook-retries#recover-from-downtime)), or to read a conversation's history. **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 messages 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 message on the page you already have. Returns the messages after it. Leave it out for the first page. See [Pagination](/concepts/pagination). | | `contact_id` | string (UUID) | Messages with this contact: the one-to-one conversation, plus group messages they sent or that were addressed to them as the group's primary contact. For one whole conversation, use `conversation_id`. | | `conversation_id` | string (UUID) | Messages in this conversation, one-to-one or group. | | `chat_guid` | string | Messages in the iMessage group with this `chat_guid`. It changes when someone is added to the group; the group's `conversation_id` doesn't. | | `direction` | string | `inbound` (from contacts) or `outbound` (from your line, sent through the API, the dashboard or an automation). | | `status` | string | One of `queued`, `pending`, `sent`, `delivered`, `read`, `failed`, `expired`, `cancelled`. | | `created_after` | string (ISO 8601) | Messages with `created_at` strictly after this time. Include a time zone: `2026-11-10T09:30:00Z`. In a URL, write `Z`, or encode `+` as `%2B` (an unencoded `+` reads as a space and is rejected). | | `created_before` | string (ISO 8601) | Messages with `created_at` strictly before this time. Same format. | 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 `contact_id`, `conversation_id` or `chat_guid` from another account. Messages stay listed after their contact is deleted or blocked. **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](/concepts/limits#rate-limits). ```bash cURL theme={null} # Sent as ?limit=50&created_after=2026-11-10T09%3A30%3A00Z curl -G https://api.ibubbl.com/v1/messages \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ --data-urlencode "limit=50" \ --data-urlencode "created_after=2026-11-10T09:30:00Z" # Next page: add the id of the last message you received. # --data-urlencode "starting_after=8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b" ``` ```typescript TypeScript theme={null} const params = new URLSearchParams({ limit: "50", created_after: "2026-11-10T09:30:00Z", }); const res = await fetch(`https://api.ibubbl.com/v1/messages?${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 message of data.data) { console.log(message.created_at, message.direction, message.status, message.body); } // 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/messages", headers={"Authorization": f"Bearer {API_KEY}"}, params={"limit": 50, "created_after": "2026-11-10T09:30:00Z"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") for message in data["data"]: print(message["created_at"], message["direction"], message["status"], message["body"]) # More? Repeat with starting_after = data["data"][-1]["id"] print("has_more:", data["has_more"]) ``` ```json 200 OK theme={null} { "data": [ { "id": "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c", "direction": "inbound", "created_at": "2026-11-10T15:06:41.518204Z", "status": "delivered", "to": "+14155551234", "body": "Thanks! Can we talk tomorrow?", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": "imessage", "sent_at": "2026-11-10T15:06:40.902Z", "held_until": null, "held_reason": null, "effect": null, "reply_to_message_id": null, "attachments": [] }, { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "direction": "outbound", "created_at": "2026-11-10T15:04:10.112730Z", "status": "read", "to": "+14155551234", "body": "Hey Sam — quick check-in about your order.", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": "imessage", "sent_at": "2026-11-10T15:04:12.381Z", "held_until": null, "held_reason": null, "effect": null, "reply_to_message_id": null, "attachments": [] } ], "has_more": true } ``` ```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` messages, newest first: by `created_at`, then by `id` for messages recorded in the same microsecond. Each item is exactly what [Retrieve a message](/api-reference/messages/retrieve) returns for that message. `has_more` is `true` when more messages 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`. * `direction` is `inbound` or `outbound`. * `created_at` is when iBubbl recorded the message, in UTC with microseconds. It never changes, which is why the list sorts and filters by it. `sent_at` is when the message went out or arrived, and can be earlier than `created_at` for an inbound message. * For an inbound message, `status` is `delivered`, or `read` once someone on your account has read it in the dashboard. `to` is the contact's number (who sent it), and `channel` is how it arrived. ```json theme={null} { "data": [ { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "direction": "outbound", "created_at": "2026-11-10T15:04:10.112730Z", "status": "read", "to": "+14155551234", "body": "Hey Sam — quick check-in about your order.", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": "imessage", "sent_at": "2026-11-10T15:04:12.381Z", "held_until": null, "held_reason": null, "effect": null, "reply_to_message_id": null, "attachments": [] } ], "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: chat_guid, contact_id, conversation_id, created_after, created_before, direction, limit, starting_after, status" } } ``` `validation_error`: a query parameter is unknown, repeated or invalid (`limit` outside 1–100, an ID that isn't a UUID, a time without a time zone, an unknown `direction` or `status`). `invalid_cursor`: `starting_after` is a UUID but not the ID of one of your messages. **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`. # Add a tapback Source: https://docs.ibubbl.com/api-reference/messages/react POST /v1/messages/{message_id}/reactions Send one of the six iMessage tapbacks (`love`, `like`, `dislike`, `laugh`, `emphasize`, `question`) on a message you received. It goes out over iMessage, or RCS for an Android contact; it can't go out by SMS. The tapback is accepted as `pending`. When it reaches the contact's phone, `message.reaction.added` fires with `direction: "outbound"`. If it can't be sent, `message.reaction.failed` fires with a `failed_reason`. One tapback per message, as in iMessage: a different type replaces your previous one (`message.reaction.removed` fires for it if it was sent). Sending the type that's already on the message answers `200` with that reaction and sends nothing. Counts toward the conversation-actions rate limit (300 a minute per account). ### Add a tapback **Method:** `POST` | **Endpoint:** `/v1/messages/{message_id}/reactions` **Description:** React to a message a contact sent you with one of the six iMessage tapbacks: ❤️ `love`, 👍 `like`, 👎 `dislike`, 😂 `laugh`, ‼️ `emphasize` or ❓ `question`. It goes out over iMessage, or over RCS to an Android contact. It can't go out by SMS. See [Tapbacks, read receipts and typing](/guides/reactions-read-receipts-typing). **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. **Rate limit:** 300 conversation actions per 60 seconds per account (tapbacks, mark read and typing together, across all your API keys). Over that, you get `429 rate_limited` with a `Retry-After` header; see [Rate limits](/concepts/limits#rate-limits). #### Request Body | Field | Type | Required | Description | | - | - | - | - | | `type` | string | Yes | `love`, `like`, `dislike`, `laugh`, `emphasize` or `question`. Lowercase. Custom emoji tapbacks can't be sent. | The path parameter `message_id` is the ID of a message you **received**: the `message_id` of a [`message.inbound`](/webhooks/events) event, or the `id` of an inbound message from [List messages](/api-reference/messages/list). Fields not listed above are rejected with `422 validation_error`. #### How it behaves * **It's accepted first, sent second.** The answer is `201` with `status: "pending"`. When the tapback reaches the contact's phone, [`message.reaction.added`](/webhooks/events) fires with `direction: "outbound"`. If it can't be sent, [`message.reaction.failed`](/webhooks/events) fires with a `failed_reason`, usually within seconds and always within 10 minutes. * **One tapback per message.** As in iMessage, a different type **replaces** your previous tapback on that message: the new one is `201`, and the old one ends as `removed`. If the old one had reached the phone, `message.reaction.removed` fires for it. Tapbacks sent from the dashboard count as yours too. * **It's safe to call twice.** Sending the type that's already on the message answers `200` with that same reaction and sends nothing. * **Groups work.** A tapback on a message in a group conversation goes to the group. * **Blocked and opted-out contacts get none.** In a one-to-one conversation, a [blocked](/api-reference/contacts/block) contact gets `422 contact_blocked` and an [opted-out](/concepts/suppression) one `422 contact_opted_out`, as for a send. * **Not over SMS.** If the message came in by SMS, or the conversation's latest messages are going out by SMS, the answer is `422 sms_not_supported`. If iBubbl can't tell yet, it's accepted, and a tapback the line then can't send fires `message.reaction.failed` with `failed_reason: "sms_target"`. ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/messages/8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b/reactions \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "love"}' ``` ```typescript TypeScript theme={null} const messageId = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b"; const res = await fetch(`https://api.ibubbl.com/v1/messages/${messageId}/reactions`, { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ type: "love" }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); // 201: a new tapback; 200: that tapback was already on the message. console.log(res.status, data.id, data.status); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] message_id = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b" res = requests.post( f"https://api.ibubbl.com/v1/messages/{message_id}/reactions", headers={"Authorization": f"Bearer {API_KEY}"}, json={"type": "love"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") # 201: a new tapback; 200: that tapback was already on the message. print(res.status_code, data["id"], data["status"]) ``` ```json 201 Created theme={null} { "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", "message_id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "type": "love", "emoji": "❤️", "direction": "outbound", "status": "pending", "failed_reason": null, "created_at": "2026-11-11T16:03:25.403057Z", "removed_at": null } ``` ```json 422 Unprocessable Entity theme={null} { "detail": { "error": "sms_not_supported", "message": "This conversation is on SMS, where tapbacks can't be sent. They work over iMessage and RCS." } } ``` #### Responses **201 Created** The tapback is accepted and on its way. | Field | Description | | - | - | | `id` | The reaction's ID: the `reaction_id` in `message.reaction.*` events, and the ID to [remove it](/api-reference/messages/remove-reaction) with. | | `message_id` | The message the tapback is on. | | `conversation_id` | That message's conversation. | | `type` / `emoji` | The tapback, as its name and as an emoji. | | `direction` | Always `outbound`: a tapback you sent. | | `status` | `pending` (accepted, not on the phone yet), `sent` (on the phone; `message.reaction.added` has fired), `failed` (never sent; see `failed_reason`) or `removed` (you removed it, or a newer tapback replaced it). | | `failed_reason` | Why a `failed` tapback wasn't sent: `sms_target`, `target_not_found`, `bb_rejected`, `messages_error`, `expired`, `retract_rejected` or `target_unsent`. Otherwise `null`. See [`message.reaction.failed`](/webhooks/events). | | `created_at` / `removed_at` | When you sent it, and when it was removed or replaced (UTC; `removed_at` is `null` while it's active). | ```json theme={null} { "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", "message_id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "type": "love", "emoji": "❤️", "direction": "outbound", "status": "pending", "failed_reason": null, "created_at": "2026-11-11T16:03:25.403057Z", "removed_at": null } ``` **200 OK** That type is already on the message. The body is the existing reaction (its `status` may already be `sent`); nothing new is sent. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Message not found" } } ``` Also returned for a malformed ID and a message that belongs to another account. **422 Unprocessable Entity** ```json theme={null} { "detail": { "error": "invalid_target", "message": "Only messages you received can get a tapback." } } ``` Nothing was sent. The `error` is one of: * `validation_error`: no `type`, a `type` that isn't one of the six, or an unknown field. * `invalid_target`: the message is one you sent. Tapbacks go only on messages you received. * `contact_blocked`: a one-to-one conversation with a contact you [blocked](/api-reference/contacts/block). * `contact_opted_out`: a one-to-one conversation with a contact who opted out. * `sms_not_supported`: the message came in by SMS, or the conversation is going out by SMS. * `target_unsent` / `target_partly_unsent`: the contact unsent the message, or part of it. **429 Too Many Requests** ```json theme={null} { "detail": { "error": "rate_limited", "message": "Too many conversation actions; wait 12s (limit 300 a minute per account)." } } ``` Wait the `Retry-After` header's seconds, then send the same request again. **503 Service Unavailable** ```json theme={null} { "detail": { "error": "opt_out_check_unavailable", "message": "Couldn't check the contact's opt-out. Try again." } } ``` iBubbl couldn't confirm the contact hasn't opted out, so it sent nothing. Retry after a short backoff. # Remove a tapback Source: https://docs.ibubbl.com/api-reference/messages/remove-reaction DELETE /v1/messages/{message_id}/reactions/{reaction_id} Take back a tapback you sent. It's removed from the contact's phone, and `message.reaction.removed` fires when that happens. Only a tapback from you, on this message, can be removed. Idempotent: removing one already removed (or replaced, or failed) answers `200` with it unchanged. Counts toward the conversation-actions rate limit (300 a minute per account). ### Remove a tapback **Method:** `DELETE` | **Endpoint:** `/v1/messages/{message_id}/reactions/{reaction_id}` **Description:** Take back a tapback you [added](/api-reference/messages/react). It disappears from the message on the contact's phone, and [`message.reaction.removed`](/webhooks/events) fires when it does. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. **Rate limit:** 300 conversation actions per 60 seconds per account, shared with tapbacks, mark read and typing. Over that, `429 rate_limited` with a `Retry-After` header; see [Rate limits](/concepts/limits#rate-limits). #### Request Body None. The path parameters are the message's `message_id` and the reaction's `id` (`reaction_id`), both UUIDs. #### How it behaves * **Only your own tapback, on that message.** A contact's reaction, or a reaction on another message, answers `404 not_found`. * **The answer comes first.** It returns `200` with `status: "removed"` at once. `message.reaction.removed` fires when the phone confirms. If your line can't remove it, [`message.reaction.failed`](/webhooks/events) fires with `action: "remove"` and `failed_reason: "retract_rejected"`, and the tapback may still show on the phone. * **A tapback still `pending`** won't be sent once you remove it. * **It's safe to call twice.** Removing a tapback that's already removed, replaced or failed answers `200` with it unchanged. * **Opt-outs don't stop it.** You can remove a tapback after the contact opted out. ```bash cURL theme={null} curl -X DELETE https://api.ibubbl.com/v1/messages/8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b/reactions/7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const messageId = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b"; const reactionId = "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"; const res = await fetch( `https://api.ibubbl.com/v1/messages/${messageId}/reactions/${reactionId}`, { method: "DELETE", 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}`); console.log(data.status, data.removed_at); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] message_id = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b" reaction_id = "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" res = requests.delete( f"https://api.ibubbl.com/v1/messages/{message_id}/reactions/{reaction_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["status"], data["removed_at"]) ``` ```json 200 OK theme={null} { "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", "message_id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "type": "love", "emoji": "❤️", "direction": "outbound", "status": "removed", "failed_reason": null, "created_at": "2026-11-11T16:03:25.403057Z", "removed_at": "2026-11-11T16:04:04.236980Z" } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Reaction not found" } } ``` #### Responses **200 OK** The reaction, now `removed` (the same object as [Add a tapback](/api-reference/messages/react) returns). A tapback that had already failed keeps `status: "failed"`. ```json theme={null} { "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", "message_id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "type": "love", "emoji": "❤️", "direction": "outbound", "status": "removed", "failed_reason": null, "created_at": "2026-11-11T16:03:25.403057Z", "removed_at": "2026-11-11T16:04:04.236980Z" } ``` **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Reaction not found" } } ``` Also returned for a malformed ID, a contact's reaction, a reaction on a different message than the one in the path, and one that belongs to another account. **422 Unprocessable Entity** ```json theme={null} { "detail": { "error": "target_unsent", "message": "The contact unsent this message." } } ``` `target_unsent` or `target_partly_unsent`: the contact unsent the message (or part of it) after you reacted, so the tapback can't be changed any more. **429 Too Many Requests** ```json theme={null} { "detail": { "error": "rate_limited", "message": "Too many conversation actions; wait 12s (limit 300 a minute per account)." } } ``` Wait the `Retry-After` header's seconds, then send the same request again. # Retrieve a message Source: https://docs.ibubbl.com/api-reference/messages/retrieve GET /v1/messages/{message_id} Fetch a single message by its ID. Useful for polling the current delivery state when you can't subscribe to webhooks. Returns `404 not_found` for both missing messages and messages owned by a different customer — existence is not leaked across tenants. ### Retrieve a message **Method:** `GET` | **Endpoint:** `/v1/messages/{message_id}` **Description:** Fetch one message by its ID to check its delivery state. Use it when you can't receive webhooks. To read many messages, or catch up after missed webhooks, use [List messages](/api-reference/messages/list). **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameter `message_id` is the message's ID (a UUID), from the send response or a webhook event. ```bash cURL theme={null} curl https://api.ibubbl.com/v1/messages/8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const messageId = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b"; const res = await fetch(`https://api.ibubbl.com/v1/messages/${messageId}`, { 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}`); console.log(data.status, data.channel); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] message_id = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b" res = requests.get( f"https://api.ibubbl.com/v1/messages/{message_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["status"], data["channel"]) ``` ```json 200 OK theme={null} { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "direction": "outbound", "created_at": "2026-11-05T15:04:10.112730Z", "status": "delivered", "to": "+14155551234", "body": "Hey Sam — quick check-in about your order.", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": "imessage", "sent_at": "2026-11-05T15:04:12.381Z", "held_until": null, "held_reason": null, "effect": null, "reply_to_message_id": null, "attachments": [] } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Message not found" } } ``` #### Responses **200 OK** ```json theme={null} { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "direction": "outbound", "created_at": "2026-11-05T15:04:10.112730Z", "status": "delivered", "to": "+14155551234", "body": "Hey Sam — quick check-in about your order.", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": "imessage", "sent_at": "2026-11-05T15:04:12.381Z", "held_until": null, "held_reason": null, "effect": null, "reply_to_message_id": null, "attachments": [] } ``` `direction` is `outbound` or `inbound`: an inbound message (for example the `message_id` of a `message.inbound` event) can be retrieved too, and then `status` is `delivered`, or `read` once it has been read in the dashboard, and `to` is the contact who sent it. `created_at` is when iBubbl recorded the message (UTC, microseconds, never changes). `status` is one of `queued`, `pending`, `sent`, `delivered`, `read`, `failed`, `expired`, `cancelled`. `channel` is how it went out (`imessage` or `sms`), or `null` before it is sent. For an inbound message, it is how the message arrived (`null` for messages received before iBubbl recorded it). `attachments` lists the message's files in order, each as `kind` (`image`, `gif`, `video`, `audio` or `file`), `mime_type` and `size_bytes`, for files you sent and files the contact sent you; it's `[]` when there are none. File contents and URLs aren't returned. An inbound file can show `size_bytes: 0` for a few seconds, until iBubbl has received it. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Message not found" } } ``` Also returned for a malformed ID and for a message that belongs to another account. # Send a message Source: https://docs.ibubbl.com/api-reference/messages/send POST /v1/messages Send a message to one contact (`contact_id`, or `to` — an E.164 phone number; the contact is created on first use), into an existing group (`chat_guid`), or to a new group (`participant_phones`). Exactly one of the four is required. The response returns immediately with `status: "pending"` or `"queued"`. Delivery progress arrives as [webhook events](/webhooks/events) (`message.sent`, `message.delivered`, `message.read`, `message.failed`), or poll `GET /v1/messages/{id}`. **Idempotency is required.** Provide a unique `idempotency_key` per logical send (UUIDv4 recommended); a retry with the same key and payload replays the original response, while a reused key with a different payload returns `409 idempotency_conflict`. See the [Idempotency](/idempotency) guide. A send to an opted-out contact returns `422 contact_opted_out`; see [Suppression](/concepts/suppression). To send files, list up to 10 `https` URLs in `attachments` (1:1 only); `body`, if set, is their caption. iBubbl downloads them before answering, which can take up to about 30 seconds, so allow 60 seconds before your client times out. See [Attachments](/guides/attachments). On a `5xx` (including a `502`/`503` from the network edge during a deploy), retry with the same `idempotency_key`. ### Send a message **Method:** `POST` | **Endpoint:** `/v1/messages` **Description:** Send a message to one contact (by `contact_id` or phone number), into an existing iMessage group (`chat_guid`), or to a new group (`participant_phones`). One-to-one messages can carry files by URL; see [Send attachments](/guides/attachments). The call returns at once; delivery progress arrives as [webhook events](/webhooks/events), or poll [Retrieve a message](/api-reference/messages/retrieve). **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 | | - | - | - | - | | `contact_id` | string (UUID) | Exactly one of these four | Send one-to-one to an existing contact. A deleted contact's ID returns `404 not_found`. | | `to` | string (E.164, e.g. `+14155551234`) | Exactly one of these four | Send one-to-one by phone number. If no contact has this number, one is created. | | `chat_guid` | string | Exactly one of these four | Send into an existing group. Read it from the response to a send into the group, or from [Retrieve a message](/api-reference/messages/retrieve) for any message in it. It changes when a participant is added; an old value returns `404 group_not_found`. | | `participant_phones` | array of strings (E.164) | Exactly one of these four | Create a new group and send the first message. At least 2 numbers; your iBubbl line is added automatically, so don't include it. | | `group_name` | string, at most 80 characters | No | Display name for the new group. Only allowed with `participant_phones`. | | `body` | string, at least 1 character | Unless `attachments` is set | The message text. Plain text; emoji and Unicode are supported. For a line break, include a newline (`\n` in the JSON string); use `\n`, not `\r\n`. A retry with the same `idempotency_key` must send the identical body, line breaks included, or it returns `409 idempotency_conflict`. With `attachments` it's their caption. The first message to a contact must have one (`422 first_message_needs_text`). | | `attachments` | array of 1–10 strings (`https` URLs) | No | Files to send: photos, GIFs, videos, PDFs or audio. iBubbl downloads each URL when the request arrives. One-to-one only (`to` or `contact_id`), and not with `effect` or `reply_to_message_id`. Types, size limits and the combinations allowed are in [Send attachments](/guides/attachments#supported-files-and-limits). Allow up to 60 seconds for the response. | | `reply_to_message_id` | string (UUID) | No | Send as an inline reply that quotes an earlier message: a message `id`, or the `message_id` of a [`message.inbound`](/webhooks/events) event. It must be in the conversation you're sending to (the same contact, or the same group); otherwise `422 reply_target_not_found`. iMessage only. Not allowed with `participant_phones` or `attachments`. See [Reply and send with effects](/guides/send-messages#reply-to-a-message-and-send-with-effects). | | `effect` | string | No | An iMessage effect: `slam`, `loud`, `gentle`, `invisible`, `echo`, `spotlight`, `balloons`, `confetti`, `love`, `lasers`, `fireworks`, `celebration` or `shooting_star`. iMessage only. Not allowed with `participant_phones` or `attachments`. | | `idempotency_key` | string, 1–128 characters | Yes | Your unique key for this send; a UUIDv4 is recommended. The same key with the same payload replays the original message; the same key with a different payload returns `409`. A key whose message ended `failed` or `expired` is released, and a retry sends a new message. See [Idempotency](/idempotency). | Fields not listed above are ignored without an error: the request still succeeds, and they are not stored. `reply_to_message_id` and `effect` only show on iMessage. If the message goes out as SMS (RCS included), it is sent as plain text without the quote or the effect, and the API doesn't refuse it. `channel` on [Retrieve a message](/api-reference/messages/retrieve) tells you which way it went. Neither can be combined with `attachments`: that request is refused with `422 validation_error`. Both fields count toward the [idempotency](/idempotency) payload when set. `attachments` count toward the idempotency payload too, in order. A replay never downloads them again. A request whose files can't be downloaded or don't meet the limits is refused with a `422` and creates no message; see [Send attachments](/guides/attachments#errors). ```bash cURL theme={null} # One key per message: generate it once, and reuse it if you retry. IDEMPOTENCY_KEY=$(uuidgen) curl -X POST https://api.ibubbl.com/v1/messages \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"to\": \"+14155551234\", \"body\": \"Hey Sam — quick check-in about your order.\", \"reply_to_message_id\": \"5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c\", \"effect\": \"confetti\", \"idempotency_key\": \"$IDEMPOTENCY_KEY\" }" ``` ```typescript TypeScript theme={null} import { randomUUID } from "node:crypto"; // One key per message: generate it once, and reuse it if you retry. const idempotencyKey = randomUUID(); const res = await fetch("https://api.ibubbl.com/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+14155551234", body: "Hey Sam — quick check-in about your order.", // Optional: quote an earlier message, and add an iMessage effect. reply_to_message_id: "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c", effect: "confetti", idempotency_key: idempotencyKey, }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.status); ``` ```python Python theme={null} import os import uuid import requests API_KEY = os.environ["IBUBBL_API_KEY"] # One key per message: generate it once, and reuse it if you retry. idempotency_key = str(uuid.uuid4()) res = requests.post( "https://api.ibubbl.com/v1/messages", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "to": "+14155551234", "body": "Hey Sam — quick check-in about your order.", # Optional: quote an earlier message, and add an iMessage effect. "reply_to_message_id": "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c", "effect": "confetti", "idempotency_key": idempotency_key, }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["status"]) ``` ```json 202 Accepted theme={null} { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "direction": "outbound", "created_at": "2026-11-05T15:04:10.112730Z", "status": "pending", "to": "+14155551234", "body": "Hey Sam — quick check-in about your order.", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": null, "sent_at": "2026-11-05T15:04:10.112Z", "held_until": null, "held_reason": null, "effect": "confetti", "reply_to_message_id": "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c", "attachments": [] } ``` ```json 422 Unprocessable Entity theme={null} { "detail": { "error": "validation_error", "message": "Exactly one of contact_id, to, chat_guid, or participant_phones is required" } } ``` #### Responses **202 Accepted** ```json theme={null} { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "direction": "outbound", "created_at": "2026-11-05T15:04:10.112730Z", "status": "pending", "to": "+14155551234", "body": "Hey Sam — quick check-in about your order.", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": null, "sent_at": "2026-11-05T15:04:10.112Z", "held_until": null, "held_reason": null, "effect": null, "reply_to_message_id": null, "attachments": [] } ``` `status` is `pending` or `queued`. A message held by a capacity limit or an offline line is still accepted with `queued` and retried automatically. A message waiting for the contact's [working hours](/concepts/working-hours) or the line's hourly limit for new contacts is `pending`, with `held_until` and `held_reason` set. For a group send, `to` is `null` and `conversation_kind` is `"group"`; a new group's `chat_guid` is `null` until the group is created a few seconds later. `effect` and `reply_to_message_id` echo what you sent, or `null`. `attachments` lists the files the message carries, in your order, as `kind` (`image`, `gif`, `video`, `audio` or `file`), `mime_type` and `size_bytes`; it's `[]` for a text message. `direction` is always `outbound` here, and `created_at` is when iBubbl recorded the message; it never changes, and it's what [List messages](/api-reference/messages/list) sorts and filters by. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Contact not found" } } ``` Also returned for a `contact_id` that is malformed, belongs to another account, or was [deleted](/api-reference/contacts/delete); send with `to` to reach the number again (that creates a new contact). Other `error` value: `group_not_found` (no group with that `chat_guid` on your account). **409 Conflict** ```json theme={null} { "detail": { "error": "idempotency_conflict", "message": "idempotency_key reused with a different request payload" } } ``` **422 Unprocessable Entity** ```json theme={null} { "detail": { "error": "validation_error", "message": "Exactly one of contact_id, to, chat_guid, or participant_phones is required" } } ``` Other `error` values: `contact_opted_out`, `contact_blocked`, `no_eligible_line`, `unresponsive_contact`, `cold_cap_exhausted`, `daily_cap_exhausted`, `followup_cap_exhausted`, `group_outbound_blocked_suppression` (with a `blocked_phones` list), `insufficient_participants`, `reply_target_not_found`, `reply_target_unsent`, `reply_target_partly_unsent`, and for attachments `first_message_needs_text`, `attachment_fetch_failed`, `attachment_too_large`, `unsupported_attachment` and `attachment_combination_not_supported`. See [Errors](/errors). **429 Too Many Requests** ```json theme={null} { "detail": { "error": "rate_limited", "message": "Too many attachment sends in progress; wait 5s and retry with the same idempotency_key." } } ``` Sends with `attachments` only, and rarely: iBubbl is already downloading as many attachment sends as it can. The `Retry-After` header says how many seconds to wait (5); then send the same request with the same `idempotency_key`. Text-only sends never get a `429`. **500 Internal Server Error** ```json theme={null} { "detail": { "error": "no_owner_rep", "message": "Customer is missing an Owner rep" } } ``` Other `error` value: `internal_error`. Retry with the same `idempotency_key`. # Create a webhook subscription Source: https://docs.ibubbl.com/api-reference/webhooks/create POST /v1/webhooks Register an HTTPS endpoint to receive events. The response includes the signing secret **once** — store it immediately; it cannot be retrieved again. Use the secret to verify incoming webhook signatures (see [Signature verification](/guides/webhook-signatures)). If `event_types` is omitted, the subscription receives all event types. You can register multiple subscriptions per account (e.g., one for production, one for staging). Delivery is at-least-once with exponential backoff. Customer endpoints should be idempotent based on the event `id`. ### Create a webhook subscription **Method:** `POST` | **Endpoint:** `/v1/webhooks` **Description:** Register an HTTPS URL to receive [webhook events](/webhooks/events). The response is the only time the signing secret is shown. **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 | | - | - | - | - | | `url` | string | Yes | Your HTTPS endpoint. Must start with `https://`. | | `event_types` | array of strings | No | Only send these types. Leave it out, or send `null` or `[]`, to receive every type, including ones added later. Each entry must be a type from the [event catalog](/webhooks/events). | ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/webhooks \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/ibubbl-webhooks", "event_types": ["message.sent", "message.failed", "message.inbound"] }' ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.ibubbl.com/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://api.example.com/ibubbl-webhooks", event_types: ["message.sent", "message.failed", "message.inbound"], }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); // Store data.secret now: it is never shown again. console.log(data.id, data.secret_prefix); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] res = requests.post( "https://api.ibubbl.com/v1/webhooks", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "url": "https://api.example.com/ibubbl-webhooks", "event_types": ["message.sent", "message.failed", "message.inbound"], }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") # Store data["secret"] now: it is never shown again. print(data["id"], data["secret_prefix"]) ``` ```json 201 Created theme={null} { "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", "url": "https://api.example.com/ibubbl-webhooks", "secret_prefix": "whsec_a3f2c8d9", "event_types": [ "message.sent", "message.failed", "message.inbound" ], "created_at": "2026-11-05T14:23:45.123Z", "last_delivered_at": null, "revoked_at": null, "secret": "whsec_a3f2c8d9e0f1b4d52c8a1f4e23b9d4f7ca8e51d2c3b4a5e6f7a8b9c0d1e2f3a4", "warning": "Save this secret now — it cannot be retrieved again. Use it to verify the X-iBubbl-Signature header on incoming webhook POSTs (HMAC-SHA256 over `{t}.{raw body}`)." } ``` ```json 422 Unprocessable Entity theme={null} { "detail": { "error": "invalid_url", "message": "Webhook URL must start with https://" } } ``` #### Responses **201 Created** ```json theme={null} { "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", "url": "https://api.example.com/ibubbl-webhooks", "secret_prefix": "whsec_a3f2c8d9", "event_types": [ "message.sent", "message.failed", "message.inbound" ], "created_at": "2026-11-05T14:23:45.123Z", "last_delivered_at": null, "revoked_at": null, "secret": "whsec_a3f2c8d9e0f1b4d52c8a1f4e23b9d4f7ca8e51d2c3b4a5e6f7a8b9c0d1e2f3a4", "warning": "Save this secret now — it cannot be retrieved again. Use it to verify the X-iBubbl-Signature header on incoming webhook POSTs (HMAC-SHA256 over `{t}.{raw body}`)." } ``` Store `secret` now; later responses only show `secret_prefix`. See [Signatures](/guides/webhook-signatures). **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": "invalid_url", "message": "Webhook URL must start with https://" } } ``` Other `error` value: `validation_error`, for example an unknown event type: ```json theme={null} { "detail": { "error": "validation_error", "message": "event_types: unknown event type(s): message.bogus. See https://docs.ibubbl.com/webhooks/events" } } ``` **500 Internal Server Error** ```json theme={null} { "detail": { "error": "internal_error", "message": "Failed to create subscription" } } ``` The subscription was not saved. Retry the request. # List webhook subscriptions Source: https://docs.ibubbl.com/api-reference/webhooks/list GET /v1/webhooks Returns all currently active webhook subscriptions for the authenticated customer. Revoked subscriptions are omitted — query them by ID directly (`GET /v1/webhooks/{id}`) if you need their history. ### List webhook subscriptions **Method:** `GET` | **Endpoint:** `/v1/webhooks` **Description:** List your account's active webhook subscriptions. Secrets are never returned, only their prefix. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. ```bash cURL theme={null} curl https://api.ibubbl.com/v1/webhooks \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.ibubbl.com/v1/webhooks", { 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 sub of data) console.log(sub.id, sub.url, sub.event_types); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] res = requests.get( "https://api.ibubbl.com/v1/webhooks", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") for sub in data: print(sub["id"], sub["url"], sub["event_types"]) ``` ```json 200 OK theme={null} [ { "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", "url": "https://api.example.com/ibubbl-webhooks", "secret_prefix": "whsec_a3f2c8d9", "event_types": [ "message.sent", "message.failed", "message.inbound" ], "created_at": "2026-11-05T14:23:45.123Z", "last_delivered_at": "2026-11-05T15:01:12.456Z", "revoked_at": null } ] ``` ```json 401 Unauthorized theme={null} { "detail": { "error": "unauthorized", "message": "Invalid or revoked API key" } } ``` #### Responses **200 OK** ```json theme={null} [ { "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", "url": "https://api.example.com/ibubbl-webhooks", "secret_prefix": "whsec_a3f2c8d9", "event_types": [ "message.sent", "message.failed", "message.inbound" ], "created_at": "2026-11-05T14:23:45.123Z", "last_delivered_at": "2026-11-05T15:01:12.456Z", "revoked_at": null } ] ``` Newest first. Revoked subscriptions are left out; an account with none gets `[]`. **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. # Retrieve a webhook subscription Source: https://docs.ibubbl.com/api-reference/webhooks/retrieve GET /v1/webhooks/{subscription_id} Fetch a single subscription by ID. Returns the subscription regardless of revoked status (so you can inspect history). `404 not_found` is returned for both missing subscriptions and those owned by a different customer. ### Retrieve a webhook subscription **Method:** `GET` | **Endpoint:** `/v1/webhooks/{subscription_id}` **Description:** Fetch one webhook subscription by its ID. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameter `subscription_id` is the subscription's ID (a UUID), from Create a webhook subscription. ```bash cURL theme={null} curl https://api.ibubbl.com/v1/webhooks/4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const subscriptionId = "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d"; const res = await fetch(`https://api.ibubbl.com/v1/webhooks/${subscriptionId}`, { 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}`); console.log(data.url, data.revoked_at); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] subscription_id = "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d" res = requests.get( f"https://api.ibubbl.com/v1/webhooks/{subscription_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["url"], data["revoked_at"]) ``` ```json 200 OK theme={null} { "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", "url": "https://api.example.com/ibubbl-webhooks", "secret_prefix": "whsec_a3f2c8d9", "event_types": [ "message.sent", "message.failed", "message.inbound" ], "created_at": "2026-11-05T14:23:45.123Z", "last_delivered_at": "2026-11-05T15:01:12.456Z", "revoked_at": null } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Webhook subscription not found" } } ``` #### Responses **200 OK** ```json theme={null} { "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", "url": "https://api.example.com/ibubbl-webhooks", "secret_prefix": "whsec_a3f2c8d9", "event_types": [ "message.sent", "message.failed", "message.inbound" ], "created_at": "2026-11-05T14:23:45.123Z", "last_delivered_at": "2026-11-05T15:01:12.456Z", "revoked_at": null } ``` A revoked subscription has `revoked_at` set. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Webhook subscription not found" } } ``` Also returned for a malformed ID and for a subscription that belongs to another account. # Revoke a webhook subscription Source: https://docs.ibubbl.com/api-reference/webhooks/revoke DELETE /v1/webhooks/{subscription_id} Soft-revoke a subscription. After revocation, no further events are delivered to its URL. The row is retained for audit purposes; you can still fetch it with `GET /v1/webhooks/{id}` and see its `revoked_at` timestamp. Revoking an already-revoked subscription is a no-op that returns `{"status": "already_revoked"}`. ### Revoke a webhook subscription **Method:** `DELETE` | **Endpoint:** `/v1/webhooks/{subscription_id}` **Description:** Stop sending events to a subscription. Revoking twice is safe. **Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**. #### Request Body None. The path parameter `subscription_id` is the subscription's ID (a UUID). ```bash cURL theme={null} curl -X DELETE https://api.ibubbl.com/v1/webhooks/4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const subscriptionId = "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d"; const res = await fetch(`https://api.ibubbl.com/v1/webhooks/${subscriptionId}`, { method: "DELETE", 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}`); console.log(data.status); // "revoked" or "already_revoked" ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] subscription_id = "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d" res = requests.delete( f"https://api.ibubbl.com/v1/webhooks/{subscription_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["status"]) # "revoked" or "already_revoked" ``` ```json 200 OK theme={null} { "status": "revoked" } ``` ```json 404 Not Found theme={null} { "detail": { "error": "not_found", "message": "Webhook subscription not found" } } ``` #### Responses **200 OK** ```json theme={null} { "status": "revoked" } ``` A subscription that was already revoked returns `{"status": "already_revoked"}`. **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. **404 Not Found** ```json theme={null} { "detail": { "error": "not_found", "message": "Webhook subscription not found" } } ``` Also returned for a malformed ID and for a subscription that belongs to another account. # Authentication Source: https://docs.ibubbl.com/authentication Authenticate every request with a Bearer API key. Every iBubbl API request must include a Bearer API key in the `Authorization` header. Requests without a valid key return `401 unauthorized`. ## Header format ```http theme={null} Authorization: Bearer ib_pk_<64 hex chars> ``` Example: ```http theme={null} Authorization: Bearer ib_pk_a3f2c8d9e0f1b4d52c8a1f4e23b9d4f7ca8e51d2c3b4a5e6f7a8b9c0d1e2f3a4 ``` The token always starts with the literal prefix `ib_pk_` followed by 64 lowercase hexadecimal characters. The full token is 70 characters. ## Generating a key 1. Open your dashboard at [ibubbl-dashboard.vercel.app](https://ibubbl-dashboard.vercel.app). 2. Navigate to **Settings → API Access**. 3. Click **Create key** and give it a descriptive name (e.g., "production-backend", "zapier-integration"). 4. Copy the key from the modal that appears. **This is the only time the full secret is shown.** If you lose it, revoke the key and create a new one. The dashboard displays the key's prefix (first 14 characters, e.g. `ib_pk_a3f2c8d9`) on the list view so you can identify which key is which without revealing the full secret. ## Make an authenticated request Keep the key in the `IBUBBL_API_KEY` environment variable; every example in these docs reads it from there. ```bash theme={null} export IBUBBL_API_KEY="ib_pk_..." ``` Listing your webhook subscriptions is a safe first call: it only reads, and sends nothing. A `200` with a list (`[]` if you have none) means the key works. ```bash cURL theme={null} curl https://api.ibubbl.com/v1/webhooks \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.ibubbl.com/v1/webhooks", { 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}`); console.log(`key works: ${data.length} subscription(s)`); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] res = requests.get( "https://api.ibubbl.com/v1/webhooks", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(f"key works: {len(data)} subscription(s)") ``` To explore the API in Postman or Insomnia instead, import the OpenAPI file: see [OpenAPI, Postman and Insomnia](/openapi). ## Revocation and rotation To revoke a key, click **Revoke** next to it in the dashboard. Revocation is immediate — subsequent requests with that key return `401 unauthorized`. **Recommended rotation cadence:** * Rotate any key whose holder has changed (employee offboarding, vendor change). * Rotate at least annually for long-lived integrations. * Rotate immediately if a key may have been exposed (committed to a repo, posted in chat, etc.). To rotate without downtime: 1. Create a new key. 2. Deploy the new key to your integration. 3. Verify traffic is flowing using the new key (check the dashboard's "last used" timestamp on each key). 4. Revoke the old key. ## Security best practices > [!WARNING] > > **Never commit API keys to source control.** Use environment variables, a secrets manager, or your platform's secret-injection system. If a key is ever exposed, revoke it in the dashboard right away and issue a new one. * **Server-side only.** Never embed an iBubbl API key in a mobile app, browser bundle, or any client-controlled environment. The key has full account access. * **One key per integration.** Issue a separate key per service or environment so you can revoke one without breaking the others. * **Least-privilege isn't a thing yet.** API keys carry full account access; scoped keys are on the roadmap. ## Authentication errors A `401 unauthorized` response means one of: * Missing `Authorization` header. * Header doesn't start with `Bearer ` (note the space). * Token doesn't start with `ib_pk_`. * Token has been revoked, or never existed. Response shape: ```json theme={null} { "detail": { "error": "unauthorized", "message": "API key required (use Authorization: Bearer ib_pk_…)" } } ``` ## Suspended accounts A valid key on a suspended or cancelled account returns `403 forbidden`: ```json theme={null} { "detail": { "error": "forbidden", "message": "Account is suspended" } } ``` (`"Account is cancelled"` for a cancelled account.) Retrying won't help; email [support@ibubbl.com](mailto:support@ibubbl.com). See the [Error Reference](/errors) for the full code catalog. # Build with AI Source: https://docs.ibubbl.com/build-with-ai Give your coding agent the iBubbl docs, the OpenAPI spec and a system prompt with the rules that matter. Coding agents write better iBubbl integrations when they read the docs instead of guessing. Everything on this site is available in forms an agent can read, and the system prompt below gives it the rules that are easy to get wrong. ## Docs for agents | Resource | URL | Use it for | | - | - | - | | `llms.txt` | [`https://docs.ibubbl.com/llms.txt`](https://docs.ibubbl.com/llms.txt) | An index of every page, with a one-line summary and a link to its Markdown. | | `llms-full.txt` | [`https://docs.ibubbl.com/llms-full.txt`](https://docs.ibubbl.com/llms-full.txt) | All of these docs in one plain-text file, to paste or attach as context. | | Markdown pages | Add `.md` to any page URL, for example [`https://docs.ibubbl.com/api-reference/messages/send.md`](https://docs.ibubbl.com/api-reference/messages/send.md) | One page as clean Markdown. | | OpenAPI spec | [`https://docs.ibubbl.com/openapi/public.json`](https://docs.ibubbl.com/openapi/public.json) | The machine-readable `/v1` contract: generate a client, or import it into Postman or Insomnia ([how](/openapi)). | | Docs MCP server | `https://docs.ibubbl.com/mcp` | Lets an agent search and read these docs while it works. | The menu at the top of each page can also copy the page as Markdown, open it in ChatGPT or Claude, or connect the MCP server to Cursor or VS Code. ### Connect the docs MCP server The MCP server searches and reads these docs. It can't call the iBubbl API or send messages, and it doesn't need your API key. ```bash Claude Code theme={null} claude mcp add --transport http ibubbl-docs https://docs.ibubbl.com/mcp ``` ```json Cursor theme={null} { "mcpServers": { "ibubbl-docs": { "url": "https://docs.ibubbl.com/mcp" } } } ``` For Cursor, add the entry to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json` for every project. ## System prompt for coding agents Copy this into your agent's instructions (for example `CLAUDE.md`, `AGENTS.md` or `.cursor/rules`) before asking it to build an iBubbl integration. Each rule prevents a mistake that sends duplicate messages, messages people who opted out, or creates a reply loop. ```text theme={null} You are building an integration with the iBubbl API, which sends iMessages (with a fallback to SMS) from a line that iBubbl runs for the customer. Follow these rules exactly. REFERENCE - Base URL: https://api.ibubbl.com. Every endpoint is under /v1. - Docs: https://docs.ibubbl.com. Full text: https://docs.ibubbl.com/llms-full.txt. OpenAPI: https://docs.ibubbl.com/openapi/public.json. Read them before using an endpoint or field; never guess names, fields or values. - Auth: send "Authorization: Bearer ". Read the key from the IBUBBL_API_KEY environment variable. Never hard-code, log or print it, and never put it in browser or mobile code. - Every request is live. There is no sandbox or test mode. While testing, send only to the developer's own phone number. SENDING (POST /v1/messages) - Exactly one of: to (E.164, e.g. +14155551234), contact_id, chat_guid, participant_phones. Plus idempotency_key (required) and body (required unless attachments are sent; see ATTACHMENTS). - Generate ONE idempotency_key (a UUIDv4) per logical message, before the first attempt, and store it with the message. Reuse the same key and the identical payload on every retry. Never generate the key inside a retry loop. A new key with the same body sends a second, real message: iBubbl never deduplicates by content. - After a timeout, resend with the same key and body: you get the original message back if it was accepted, so the contact gets exactly one. Exception: never auto-retry a timed-out participant_phones send (it can create a second group); wait for its message.sent webhook first. - 202 means accepted, not delivered. Track the outcome with webhooks or GET /v1/messages/{id}. A message outside the contact's sending hours stays "pending" with held_reason and held_until set; that is normal, not an error. - The request has no field for the sending line. Unknown fields such as from or line_id are ignored without an error; each contact sticks to one line. - Read the message's channel before assuming iMessage-only behaviour: "imessage" or "sms" (RCS also reports as "sms"); null until it is sent. The message.* webhooks carry the same channel field (from message.sent on, and on message.inbound for received messages); message.queued does not. - Optional reply_to_message_id (an inline quote) and effect (one of 13 iMessage effects, e.g. "confetti") are iMessage only: on SMS the message goes out as plain text without them. reply_to_message_id must be a message in the same conversation (the same contact, or the same group), else 422 reply_target_not_found. Neither is allowed with participant_phones. ATTACHMENTS (POST /v1/messages; https://docs.ibubbl.com/guides/attachments) - attachments: 1-10 https URLs, 1:1 only (to or contact_id; never with chat_guid or participant_phones). iBubbl downloads them during the request: set the client timeout to 60 s for these sends. - body is optional with attachments (it is the caption), but the first message to a contact must have a body, else 422 first_message_needs_text. - Types and limits: JPEG/PNG/WebP/HEIC/HEIF and GIF 25 MiB each, MP4/MOV 50 MiB, PDF 10 MB, MP3/M4A/AAC audio 25 MiB alone; 86 MB in all. With other files, audio must be one MP3 of at most 2 MB, else 422 attachment_combination_not_supported. No .ics, WebM, Ogg, SVG, ZIP. - Errors (422, nothing created, key reusable): attachment_fetch_failed (URL not reachable/public, non-200, too slow), attachment_too_large, unsupported_attachment, attachment_combination_not_supported. The message names the file as attachments[i]. - The URLs, in order, are part of the idempotency payload; a replay never downloads again. effect and reply_to_message_id are not allowed with attachments (422 validation_error): send them as a separate message. - Response: every message has attachments: [{kind, mime_type, size_bytes}] ([] for text). No file contents or URLs. LIMITS (per line; https://docs.ibubbl.com/concepts/limits) - 50 new contacts a day (resets at midnight America/New_York); the 51st gets 422 cold_cap_exhausted. - 30 new contacts per rolling 60 minutes; extra ones stay "pending" with held_reason "hourly_limit" and go out by themselves. Do not resend them. - 1,000 outbound messages a day, every kind counted; then 422 daily_cap_exhausted. - 6 unanswered messages in a row to a contact who never replied; the 7th gets 422 unresponsive_contact. - No HTTP rate limit on POST /v1/messages (a text send never gets a 429; a send with attachments rarely gets 429 rate_limited, Retry-After 5), but a line sends one message at a time (about one a second). Send one request at a time per line; bursts only build a queue. Pace outreach to new contacts at about one every 2 minutes per line. - A "queued" message (line offline) that expires after about an hour fires message.failed with status "expired" and failed_reason "queue_expired". Branch on failed_reason, not status. Its idempotency_key is released. READING HISTORY (GET /v1/messages) - Lists inbound and outbound messages, newest first, as {"data": [...], "has_more": bool}. limit is 1-100 (default 50). - Next page: repeat the same request with starting_after = the id of the LAST item in data. Stop when has_more is false. Never use page numbers or offsets, and never change the filters between pages. - Filters: contact_id, conversation_id, chat_guid, direction (inbound/outbound), status, created_after, created_before (ISO 8601 with Z, e.g. 2026-11-10T09:30:00Z; both exclusive). - An unknown or repeated query parameter is 422 validation_error, and a starting_after that isn't one of your ids is 422 invalid_cursor. Use the exact parameter names above. - Rate limit: 120 list requests per 60 seconds per account, shared by all API keys and all list endpoints. Over it: 429 rate_limited with a Retry-After header (seconds). Sleep that long, then send the SAME request again (same starting_after); the refused request didn't count. Fetch pages one at a time; don't run parallel loops to go faster. - To catch up after missed webhooks: list with created_after = your last processed created_at minus 5 minutes, skip message ids you already have, and store each message's status as returned (it is current). https://docs.ibubbl.com/guides/webhook-retries#recover-from-downtime CONTACTS (/v1/contacts) - POST /v1/contacts upserts by E.164 phone (201 new, 200 existing) and only fills empty fields; PATCH /v1/contacts/{id} overwrites. Unknown body fields are 422 validation_error. - GET /v1/contacts lists contacts newest first, with the same pagination and rate limit as READING HISTORY. Filters: search (every word must match a name, email or phone; phone digits match too), tag (ignores case), opted_out, blocked (true/false). Use the exact parameter names. - DELETE /v1/contacts/{id} answers 204 with no body; afterwards the id is 404 everywhere, including as a send's contact_id. History stays. The same number later becomes a NEW contact with a new id. Deleting never clears an opt-out. - POST /v1/contacts/{id}/opt-out is PERMANENT: nothing can undo it. Call it only when the person asked not to be messaged, never to clean up a list or in tests with a real person's number. A repeat call is 200 with no second contact.opted_out event. - To pause messages, block instead: POST /v1/contacts/{id}/block (sends get 422 contact_blocked) and DELETE /v1/contacts/{id}/block to undo. A block fires no webhook and doesn't affect group conversations. - Neither opt-out nor delete cancels a message already accepted and still waiting; that is done in the dashboard. TAPBACKS, READ RECEIPTS AND TYPING - message.inbound carries message_id and conversation_id; use them directly. - POST /v1/messages/{message_id}/reactions {"type": "love"|"like"|"dislike"| "laugh"|"emphasize"|"question"}: only on a RECEIVED message (else 422 invalid_target). 201 pending; the same type again is 200 and sends nothing; a different type replaces it. Delivery fires message.reaction.added (direction "outbound"); a failure fires message.reaction.failed. Not over SMS (422 sms_not_supported). DELETE /v1/messages/{message_id}/reactions/{reaction_id} removes it. - POST /v1/conversations/{conversation_id}/read marks the whole conversation read and sends a read receipt; read_receipt is "queued" or "skipped" (with a reason). Never in groups or to blocked/opted-out contacts. It also clears the dashboard's unread badge. - POST /v1/conversations/{conversation_id}/typing shows the typing dots for 10 seconds: call it on message.inbound and again every 5 seconds while generating a reply; sending the reply clears them. status "ignored" (group, not_imessage, no_chat) is normal, not an error. DELETE the same path only if you decide not to reply. - Tapback and typing calls to a blocked or opted-out 1:1 contact get 422 contact_blocked / contact_opted_out; 503 opt_out_check_unavailable means retry later. - These five endpoints share 300 requests per 60 seconds per account (429 rate_limited with Retry-After), separate from the list budget. RETRIES AND ERRORS - Branch on detail.error (the stable code), never on detail.message. - Log the X-Request-Id response header with every error; quote it to support@ibubbl.com. - Network error, timeout or 5xx: retry with the SAME idempotency_key and the same payload, with exponential backoff (1 s, 2 s, 4 s) and a small cap on attempts. Exception: 500 no_owner_rep will not succeed on retry. - Never retry a 4xx automatically. Fix the request instead. The one exception: 429 rate_limited (a list, a tapback/read/typing call, or an attachment send); wait Retry-After seconds, then repeat the same request (same idempotency_key). - 422 unresponsive_contact: the contact has not replied to 6 messages in a row. Stop messaging them until they reply. - 422 contact_opted_out: final. Never message this contact again, and never work around it (another number format, a group, deleting and re-creating the contact, another key or account). - 422 contact_blocked: your account blocked this contact. Don't retry; only unblock if the user asks for it. - 422 daily_cap_exhausted or cold_cap_exhausted: not queued. Do not send again before midnight ET. WEBHOOKS - Verify the X-iBubbl-Signature header on every request before trusting the body: HMAC-SHA256 of "{t}.{raw body bytes}" keyed with the whsec_ secret, compared in constant time; reject if t is more than 300 seconds old. Read the header name case-insensitively. Details: https://docs.ibubbl.com/guides/webhook-signatures - Return a 2xx within 10 seconds and do the work asynchronously. - Delivery is at least once and in no guaranteed order. Deduplicate on the event id (evt_...) before doing anything. - Only message.inbound may trigger an automated send. Never send in response to message.queued, message.sent, message.delivered, message.read, message.failed, message.cancelled, message.edited or a reaction event, or to any event about your own outbound messages: that creates a loop. - contact.opted_out: mark the contact do-not-contact in your own system. - Return a 2xx for event types and enum values you don't recognise; iBubbl adds new ones over time. ``` The rules come from these pages, which have the details: [Message lifecycle](/concepts/message-lifecycle), [Limits and queues](/concepts/limits), [Lines and routing](/concepts/lines-and-routing), [Idempotency](/idempotency), [Errors](/errors), [Working hours](/concepts/working-hours), [Suppression and opt-outs](/concepts/suppression), [Webhook events](/webhooks/events) and [Signature verification](/guides/webhook-signatures). # Changelog Source: https://docs.ibubbl.com/changelog Changes to the iBubbl API and these docs, newest first. **Type** says what changed: **New** is a new API capability, **Changed** is a change in how the API behaves, **Correction** means the docs were wrong and now match the API, and **Docs** is new or reorganised documentation with no API change. | Date | Change | Type | | - | - | - | | 2026-11-11 | New endpoints for the things a person does in a conversation: [Add a tapback](/api-reference/messages/react) and [Remove a tapback](/api-reference/messages/remove-reaction) (`POST` / `DELETE /v1/messages/{id}/reactions`, the six iMessage tapbacks, one per message), [Mark a conversation read](/api-reference/conversations/mark-read) (sends a read receipt), and [Start](/api-reference/conversations/start-typing) / [Stop typing](/api-reference/conversations/stop-typing) (the typing dots, 10 seconds per call). New errors `invalid_target`, `sms_not_supported`, `target_unsent`, `target_partly_unsent` and `503 opt_out_check_unavailable`. New guide: [Tapbacks, read receipts and typing](/guides/reactions-read-receipts-typing), including typing while your AI writes a reply. | New | | 2026-11-11 | New webhook event [`message.reaction.failed`](/webhooks/events#message-reaction-failed): a tapback you sent, or its removal, couldn't be sent, with `action` and `failed_reason`. A subscription without an `event_types` filter receives it. | New | | 2026-11-11 | Every `message.*` and `message.reaction.*` webhook now carries `conversation_id`, the ID the new read and typing endpoints take. See [The `conversation_id` field](/webhooks/events#the-conversation_id-field). | New | | 2026-11-11 | The tapback, read and typing endpoints have their own rate limit: 300 requests per 60 seconds per account, separate from the 120 for lists, with `429 rate_limited` and `Retry-After` over it. See [Rate limits](/concepts/limits#rate-limits). | New | | 2026-11-11 | Marking a group conversation read, in the dashboard, no longer sends a read receipt. It used to land in one participant's one-to-one chat instead of the group. | Changed | | 2026-11-11 | New endpoints complete the contacts API: [List contacts](/api-reference/contacts/list) (`GET /v1/contacts`, paginated like [List messages](/api-reference/messages/list), with `search`, `tag`, `opted_out` and `blocked` filters), [Delete a contact](/api-reference/contacts/delete) (`204`), [Opt a contact out](/api-reference/contacts/opt-out) (permanent; fires `contact.opted_out` with `source: "api"`), and [Block](/api-reference/contacts/block) / [Unblock](/api-reference/contacts/unblock) a contact. Every contact now has `blocked` and `blocked_at`. See [Block or opt out](/concepts/suppression#block-or-opt-out). | New | | 2026-11-11 | [Send a message](/api-reference/messages/send) with the `contact_id` of a deleted contact now returns `404 not_found`; before, it sent. A new contact for a number that opted out earlier (for example after deleting the old contact) now starts with `opted_out: true`; before, it showed `false` while its sends were already refused. | Changed | | 2026-11-11 | Send files: [`POST /v1/messages`](/api-reference/messages/send) takes `attachments`, up to 10 `https` URLs of photos, GIFs, videos, PDFs or audio, for one-to-one sends (not with `effect` or `reply_to_message_id`). iBubbl downloads them when the request arrives; `body` becomes optional and is the caption, but the first message to a contact still needs one. New errors: `attachment_fetch_failed`, `attachment_too_large`, `unsupported_attachment`, `attachment_combination_not_supported` and `first_message_needs_text`. The URLs are part of the idempotency payload. See [Send attachments](/guides/attachments). | New | | 2026-11-11 | Every message object ([Send](/api-reference/messages/send), [Retrieve](/api-reference/messages/retrieve), [List](/api-reference/messages/list)) now has `attachments`: the files it carries, sent or received, as `kind`, `mime_type` and `size_bytes`; `[]` when there are none. | New | | 2026-11-11 | A send with `attachments` can get `429 rate_limited` with `Retry-After: 5` when iBubbl is already downloading as many attachment sends as it can. Text sends are still never rate limited. See [Rate limits](/concepts/limits#rate-limits). | Changed | | 2026-11-11 | List endpoints are rate limited: 120 requests per 60 seconds per account, shared by all your API keys and all lists. Over that, [List messages](/api-reference/messages/list) answers `429 rate_limited` with a `Retry-After` header in seconds. Wait that long and send the same request again; the [pagination loops](/concepts/pagination#read-every-page) now do this. Sends are still never rate limited. See [Rate limits](/concepts/limits#rate-limits). | Changed | | 2026-11-11 | New endpoint: [List messages](/api-reference/messages/list) (`GET /v1/messages`) returns your inbound and outbound messages, newest first, with filters for contact, conversation, group, direction, status and creation time. Results are paginated with `starting_after` and `has_more`; see [Pagination](/concepts/pagination). New error `invalid_cursor`. New recipe: [Recover from downtime](/guides/webhook-retries#recover-from-downtime) after missed webhooks. | New | | 2026-11-11 | Every message object ([Send](/api-reference/messages/send), [Retrieve](/api-reference/messages/retrieve), [List](/api-reference/messages/list)) now has `direction` (`inbound` or `outbound`) and `created_at` (when iBubbl recorded it, UTC with microseconds). | New | | 2026-11-11 | Webhooks now carry `channel` (`imessage` or `sms`): `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.cancelled` and `message.inbound`. [Retrieve a message](/api-reference/messages/retrieve) now returns `channel` for inbound messages too, where it was always `null`. See [The `channel` field](/webhooks/events#the-channel-field). | New | | 2026-11-11 | A `queued` message that expires now fires [`message.failed`](/webhooks/events#message-failed), with `status: "expired"` and `failed_reason: "queue_expired"`; before, it fired no webhook. A queued message the queue's retry fails also fires `message.failed` now. | Changed | | 2026-11-11 | Every API response carries an `X-Request-Id` header, and a request can send its own. Quote it to support instead of a timestamp and request body. See [Request IDs](/errors#request-ids). | New | | 2026-11-10 | [Send a message](/api-reference/messages/send) takes two optional fields: `reply_to_message_id`, to send an inline reply that quotes an earlier message in the same conversation, and `effect`, one of 13 iMessage effects. Both are iMessage only, not allowed when creating a group, and echoed in the response. New errors `reply_target_not_found`, `reply_target_unsent` and `reply_target_partly_unsent`. See [Reply and send with effects](/guides/send-messages#reply-to-a-message-and-send-with-effects). | New | | 2026-11-10 | API keys work only on `/v1` endpoints. The dashboard's internal send and retry routes no longer accept an API key; they answer `401 invalid_token`. Nothing documented changed. | Changed | | 2026-11-10 | Every [API reference](/api-reference/messages/send) page now has cURL, TypeScript and Python examples, with a success and an error response beside them, and the guides, concepts and [Quickstart](/quickstart) share one sample style: the key from `IBUBBL_API_KEY`, a 10-second timeout, a new `idempotency_key` per send, and `detail.error` read on failure. New page: [OpenAPI, Postman and Insomnia](/openapi). The send example now shows `sent_at` set on acceptance. | Docs | | 2026-11-10 | New guides: [Send messages](/guides/send-messages), [Receive messages](/guides/receive-messages) (with an auto-reply that can't loop), [Test webhooks locally](/guides/test-webhooks-locally) and [Retries and deduplication](/guides/webhook-retries). [Signature verification](/guides/webhook-signatures) moved to Guides (the old `/webhooks/signatures` URL redirects) and now has a test vector, the header-case rules and raw-body wiring for Express, FastAPI and Next.js. Each retried delivery is signed with a new timestamp. | Docs | | 2026-11-10 | New concept pages: [Message lifecycle](/concepts/message-lifecycle) (every status, which are final, which webhook fires, and how duplicates and retries behave), [Limits and queues](/concepts/limits) (the exact per-line limits, rate limits and pacing) and [Lines and routing](/concepts/lines-and-routing) (which line sends, and iMessage versus SMS). The [Build with AI](/build-with-ai) system prompt now includes the limits. | Docs | | 2026-11-10 | `cold_cap_exhausted` means 50 new contacts per line per day, and `daily_cap_exhausted` means 1,000 messages per line per day; there is no warm-up ramp. `hourly_cap_hit` and `followup_cap_exhausted` are legacy codes no current line returns. A `cancelled` message does not release its `idempotency_key`. Two simultaneous requests with one key can return `500` for one of them. | Correction | | 2026-11-10 | The docs are reorganised into Get started, Concepts, Guides, Webhooks, API reference and Resources. New pages: [Quickstart](/quickstart), [Build with AI](/build-with-ai) (with a system prompt for coding agents), [Support](/support) and this changelog. No page moved, so existing links keep working. | Docs | | 2026-11-10 | The [error catalog](/errors) now lists every code `/v1` returns, including `contact_blocked`, `no_eligible_line`, `unresponsive_contact`, `group_not_found` and `rate_limited`. Removed the `503` entries, which iBubbl never returns. Also corrected: a queued message that expires fires no webhook; `held_reason` can be `hourly_limit`; the unanswered limit is 6 in a row. | Correction | | 2026-11-10 | The API is at `https://api.ibubbl.com` and these docs at `https://docs.ibubbl.com`. The previous hosts keep working. | Changed | | 2026-11-05 | Contacts: create and upsert take `email`, `city`, `state` and `tags`; new endpoints to [update a contact](/api-reference/contacts/update), [add a tag](/api-reference/contacts/add-tag) and [remove a tag](/api-reference/contacts/remove-tag). The contacts endpoints now reject unknown body fields with `422 validation_error`. | New | | 2026-11-05 | A busy calendar no longer holds sends. `held_reason` is `working_hours` or `hourly_limit`. | Changed | | 2026-11-05 | Every API reference page now shows each status code it can return, with a real example. `403 forbidden` means a suspended or cancelled account; `500 no_owner_rep` is documented. | Docs | | 2026-11-05 | New page: [After-hours auto-reply](/concepts/after-hours-reply). | Docs | | 2026-11-04 | Line breaks in a message `body`: send `\n`, not `\r\n`. A retry with the same `idempotency_key` must send the identical body. | Changed | | 2026-10-23 | Daily send caps reset at midnight ET (America/New\_York). | Changed | | 2026-10-20 | The webhook catalog is complete: `message.delivered` and `message.read`, ordering and duplicates, inbound attachments. The reference signature verifiers no longer crash on a missing or malformed header. | Correction | | 2026-10-20 | API keys are `ib_pk_` plus 64 hex characters. Idempotency keys never expire, and a key whose message failed or expired is released for a new send. There is no test mode. | Correction | | 2026-10-20 | Group conversations: get `chat_guid` from [Retrieve a message](/api-reference/messages/retrieve); it changes when someone is added to the group. | Correction | | 2026-10-20 | Corrections to suppression, contact timezones, inbound events and versioning. New enum values are not breaking changes. | Correction | | 2026-10-16 | A message that failed with an `imessage_*` reason can move to `delivered` or `read` if Apple later confirms it. | Changed | | 2026-10-15 | `message.failed` can follow `message.sent`. New `failed_reason` values `imessage_undeliverable` and `imessage_error:`. | New | | 2026-10-13 | New `cancelled` status and `message.cancelled` event, for a waiting message that is cancelled before it is sent. | New | | 2026-10-08 | Waiting messages carry `held_until` and `held_reason`. New default sending hours: Monday to Saturday 9 AM–8 PM, Sunday 12–8 PM. A message waiting for sending hours is `pending`, not `queued`. | New | | 2026-10-06 | `failed_reason: mac_send_failed` also covers a send not confirmed within 10 minutes. | Changed | | 2026-10-05 | A contact's phone number is stored in the form it was first seen. | Correction | | 2026-10-03 | New `failed_reason` value `line_inactive`. | New | | 2026-10-01 | [Inbound events](/inbound-events/overview) from any app through a secret URL, iBubbl-signed sources, and a log of recent events. | New | | 2026-09-22 | [Inbound events](/inbound-events/overview): `POST /v1/events/{token}` lets a connected app, such as Recharge, start an iBubbl workflow. | New | | 2026-09-22 | The OpenAPI spec now includes the group-send fields. | Correction | | 2026-05-31 | [Group conversations](/concepts/group-conversations): send into an existing group with `chat_guid`, or start one with `participant_phones`. | New | | 2026-05-29 | Webhook signatures use `X-iBubbl-Signature: t=,v1=`, with a 5-minute replay window. | Changed | | 2026-05-17 | First public API docs: messages, contacts and webhooks. | New | # After-Hours Auto-Reply Source: https://docs.ibubbl.com/concepts/after-hours-reply Answer texts that arrive while you are closed, automatically. When someone texts you while you're closed, iBubbl can answer for you: "Thanks for your message! We're closed right now and will get back to you tomorrow at 8:00 AM CST." It's a ready-made workflow you can edit. ## Set it up 1. **Set your business hours.** Go to **Settings → Hours → Business hours**. By default they are **the same as your booking hours**. Turn that off to set different ones. Business hours use your own time zone, which is shown next to the schedule. 2. **Create the workflow.** On **Automations**, choose **New workflow → After-hours auto-reply**. It's created as a draft. 3. **Edit the message** if you like, then **Activate** it. ## What it does ``` Someone texts you (direct messages only) → wait 2 minutes → are you closed, and has nobody on your team replied? yes → send the after-hours reply, and tag the contact "after-hours" no → do nothing ``` * **About 2 minutes after the text.** The wait gives your team a chance to answer first. If anyone replies in that time, from the dashboard, the API, an integration or a scheduled send, no auto-reply goes out. * **Checked at the moment it would reply, not when the text arrived.** A text at 8:59 AM whose reply would go out at 9:01 AM, after you've opened, gets no "we're closed" message. * **Once per contact every 12 hours.** Three texts in one evening get one reply. This also stops two businesses' auto-replies from answering each other all night. You can change the number of hours on the Send step (**At most once per contact every N hours**), from 1 to 168. While the step is set to **Send right away even outside sending hours**, the limit can't be turned off: the workflow won't activate without it. * **Direct messages only.** Group chats never get an auto-reply. The reply would go to one person privately, not to the group. * **It answers even outside your [sending hours](/concepts/working-hours)**, because it is a reply to someone who just texted you. That exception applies only for **20 minutes** after their message arrived. If the reply can't go out in that time, for example because your line is offline, it is **cancelled** so that it never arrives later. A cancelled reply fires [`message.cancelled`](/webhooks/events). * **The `after-hours` tag** lets you filter your inbox in the morning and pick up those conversations first. The message can include `{{ live.next_open_label }}`, which reads like "tomorrow at 8:00 AM CST" or "Monday at 8:00 AM CST", in your time zone. If no day is open, it falls back to the text after `or`, "as soon as we can" in the template. ## Testing it Open the workflow's **Test** tab. You can choose whether to treat the business as open or closed and whether a rep has replied, then see which path the workflow takes without sending anything. ## Limits * Holidays and one-off closures aren't supported yet. Business hours are a weekly schedule. * STOP messages and opted-out or blocked contacts never start the workflow. # Group Conversations Source: https://docs.ibubbl.com/concepts/group-conversations How iBubbl represents and routes iMessage group conversations. iBubbl supports iMessage group conversations — threads with 2 or more participants in addition to your iBubbl line. Groups work alongside 1:1 conversations and share the same [`/v1/messages`](/api-reference/messages/send) API surface, with additional fields for routing. ## Conversation kind Messages carry a `conversation_kind` field — on the `message.inbound` webhook and on `GET /v1/messages/{id}` / `POST /v1/messages` responses: * `one_to_one` — a single contact plus your iBubbl line * `group` — your iBubbl line plus 2 or more contacts Branch your integration on `conversation_kind`. The same message lifecycle applies to both kinds — only the addressing differs. (One exception: `message.queued` fires only for 1:1 sends.) ## Group identity Each group conversation has an opaque `chat_guid`, for example `any;+;067ace6caf6c43aaa2f7d643975e15ea`. Treat it as an opaque string: don't parse it, and branch on `conversation_kind` rather than its prefix. **No webhook event carries `chat_guid`.** To learn it: * **A group that messaged you:** take the `message_id` from the `message.inbound` event and call `GET /v1/messages/{id}` — the response includes the group's `chat_guid`. * **A group you created:** poll `GET /v1/messages/{id}` for your first message until `chat_guid` is non-null (see [Lifecycle of a new group](#lifecycle-of-a-new-group)). * **A group you've sent to:** the `POST /v1/messages` response for an existing-group send includes it. **`chat_guid` can change.** When a participant is added to a group (from the iBubbl dashboard), the group gets a new `chat_guid`, and a send to the old value returns `404 group_not_found`. If that happens, look up the current value again with `GET /v1/messages/{id}` on a recent message from the group. Use `chat_guid` to send into an existing group — see [Sending messages](/api-reference/messages/send). ## Participants The `participants` array on the `message.inbound` webhook lists the group's participants other than your iBubbl line: ```json theme={null} { "participants": [ {"phone": "+13477914509", "first_name": "Sarah", "last_name": "Smith"}, {"phone": "+17187699436", "first_name": "Bob", "last_name": "Jones"} ] } ``` For a 1:1 conversation the array holds the one contact. > [!WARNING] > > iBubbl does not currently see participants leaving or being removed from a group, so the list can include people who are no longer in the group. Treat it as "everyone who has been in this group", not the live roster. ## Sender attribution On inbound webhook events for group conversations, `sender_phone` identifies which specific participant sent the message: ```json theme={null} { "type": "message.inbound", "data": { "message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e", "from_number": "+13477914509", "sender_phone": "+13477914509", "conversation_kind": "group", "participants": [ {"phone": "+13477914509", "first_name": "Sarah", "last_name": "Smith"}, {"phone": "+17187699436", "first_name": "Bob", "last_name": "Jones"} ], "body": "What time tomorrow?" } } ``` `sender_phone` always equals `from_number`; both are kept so 1:1 integrations work unchanged. Either can be an Apple ID email address rather than a phone number when the sender uses one for iMessage. ## Suppression in groups If **any** participant of a group is on your suppression list, iBubbl blocks **all** outbound to the group with `422 group_outbound_blocked_suppression`: ```json theme={null} { "detail": { "error": "group_outbound_blocked_suppression", "message": "Cannot send to this group — 1 participant(s) have opted out." } } ``` When you **create** a new group (`participant_phones`) and some of the numbers are opted out, the error also lists them in `blocked_phones` (as stored on your suppression list, so the format can differ from what you sent). A send into an existing group doesn't include `blocked_phones`. This is deliberate: sending into a group where one participant opted out would deliver to that participant, defeating the opt-out. Because participant removals aren't seen (above), and an opt-out can't be undone (see [Suppression & Opt-Outs](/concepts/suppression)), a group with an opted-out participant stays blocked. To keep messaging the others, create a new group without that person. ## Sending to groups Two of the four request shapes on `POST /v1/messages` are for groups: > [card] Existing group > > Send into a group you already know by `chat_guid` (see [Group identity](#group-identity) for how to get it). > [card] New group > > Pass `participant_phones` (2+ E.164 numbers) and an optional `group_name`. iBubbl creates the iMessage group and sends your first message in one operation. See [`POST /v1/messages`](/api-reference/messages/send) for full examples. ## Lifecycle of a new group When you create a new group via `participant_phones`: 1. iBubbl validates the request: at least 2 distinct people (`422 insufficient_participants` if the numbers resolve to fewer — for example the same number twice), and no opted-out numbers. 2. The first message is accepted as `pending`, and the response returns right away with `chat_guid: null`. 3. When the first message goes out, the iMessage group is created and the group's `chat_guid` is recorded. If the message is held for the contacts' [sending hours](/concepts/working-hours), that happens when the hold ends. 4. The message moves to `sent` and the `message.sent` webhook fires. To address the group on later sends, poll `GET /v1/messages/{id}` until `chat_guid` is non-null, store it, and use the existing-group shape from then on. > [!NOTE] > > The `message.sent` event for a new group's first message can occasionally be delivered twice. De-duplicate on `data.message_id` plus `type`. ## Limitations These apply in v1: * **No participant management.** The API can't add or remove participants, and removals made elsewhere aren't seen (see [Participants](#participants)). Adding a participant from the dashboard changes the group's `chat_guid`. * **No renaming.** `group_name` can only be set when the group is created, and later renames aren't picked up. * **No editing or unsending.** The v1 API can't edit or unsend a message you sent. When a contact edits a message they sent you, you receive `message.edited`; when a contact unsends one, no event fires. * **No attachments.** `POST /v1/messages` sends text only, to 1:1 contacts and groups alike. * **At least 2 participants.** Single-participant "groups" aren't a thing in iMessage; use the `to` or `contact_id` shape for 1:1 sends. # Limits and queues Source: https://docs.ibubbl.com/concepts/limits The exact send limits on every iBubbl line, what happens when you reach them, and how fast a line can send. Every iBubbl line is a real phone number on a real Mac. Carriers and Apple flag numbers that message too many strangers too quickly, so iBubbl enforces a few limits per line. They protect your number. This page gives the exact numbers. Two kinds of limit apply: * **Holds.** The send is accepted (`202`) and waits as `pending` until it can go. You don't need to do anything. * **Refusals.** The send is rejected with a `422` and nothing is created. You need to wait for the limit to reset, or stop messaging that contact. ## The four limits These limits apply to every line, as of 2026-11-10. | Limit | Per line | What counts | Over the limit | | - | - | - | - | | **New contacts per day** | **50**, resetting at midnight America/New\_York | Each [new contact](#what-counts-as-a-new-contact) once a day, however many messages you send them | The 51st new contact gets `422 cold_cap_exhausted`. More messages to the 50 already counted today still go out. | | **New contacts per hour** | **30** in any rolling 60 minutes | New contacts, counted when the message goes out | A hold, not a refusal: the message stays `pending` with `held_reason: "hourly_limit"`. A slot frees 60 minutes after each send, and the message goes out by itself. | | **Messages per day** | **1,000**, resetting at midnight America/New\_York | Every outbound message: new contacts, replies, follow-ups, group messages, messages still waiting, and messages that failed. Not `cancelled` or `expired` ones. | The 1,001st message gets `422 daily_cap_exhausted`. | | **Unanswered in a row** | **6** to one contact who has never replied | Messages to that contact, including ones still waiting to go out (`pending` or `queued`) | The 7th gets `422 unresponsive_contact`. The limit lifts for good as soon as the contact replies; waiting doesn't reset it. | A refused send creates no message, so its `idempotency_key` stays unused. Codes and retry advice: [Errors](/errors). > [!NOTE] > > iBubbl can temporarily set a line's limits lower than these, for example while a new line builds its reputation. These numbers are never exceeded. To discuss your limits, email [support@ibubbl.com](mailto:support@ibubbl.com). ### What counts as a new contact A contact is **new** if your account has no history with them: * they have never texted, called or booked with you, on any of your lines, and * no one-to-one message to them has gone out. Once one message to them goes out, they stop being new, and replies and follow-ups to them never count toward the new-contact limits. Only the 1,000-a-day limit counts those. * **A new group** counts as one new contact, whoever is in it. Sending into an existing group counts as none. * **A message iMessage refused** (`failed` with an `imessage_*` reason, such as `imessage_undeliverable`) uses no new-contact slot, and the contact stays new. It still counts toward the 1,000 a day. ## Holds, queues and refusals | What you see | Why | What happens next | | - | - | - | | `202`, `status: "pending"`, `held_reason: "working_hours"` | Outside the contact's [sending hours](/concepts/working-hours) | Goes out when the window opens | | `202`, `status: "pending"`, `held_reason: "hourly_limit"` | The line has messaged 30 new contacts in the last 60 minutes | Goes out when a slot frees | | `202`, `status: "queued"` | Your line's Mac is offline (`line_offline` in the [`message.queued`](/webhooks/events) webhook) | Goes out when the Mac reconnects, or [expires](#offline-lines) after an hour | | `422 cold_cap_exhausted` | 50 new contacts today | Send to new contacts again after midnight ET | | `422 daily_cap_exhausted` | 1,000 messages today | Send again after midnight ET | | `422 unresponsive_contact` | 6 unanswered messages to this contact | Wait for the contact to reply | `held_reason` and `held_until` appear on [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) within about 10 seconds of acceptance. See [Message lifecycle](/concepts/message-lifecycle#held-messages). ## Offline lines Your line sends from a Mac. If that Mac hasn't checked in for 2 minutes, it counts as offline: * **A contact your line has messaged before** is accepted as `202` with `status: "queued"`. It goes out when the Mac is back. A message still `queued` 60–65 minutes after it was accepted becomes `expired`, and [`message.failed`](/webhooks/events#message-failed) fires with `status: "expired"` and `failed_reason: "queue_expired"`. Send it again if it still matters: its `idempotency_key` is released. * **A contact with no line yet** (someone your account has never messaged) gets `422 no_eligible_line`, because iBubbl only gives new contacts to a line whose Mac is online. Nothing is created. Send again once the line is back. A message that is already `pending` when the Mac goes offline waits for it. If the Mac stays offline for more than an hour, the message becomes `failed` with `failed_reason: "agent_offline"`, and `message.failed` fires. ## Rate limits * **`POST /v1/messages` has no HTTP rate limit.** A text send never gets a `429`. The one exception is rare: a send with `attachments` gets `429 rate_limited` with `Retry-After: 5` when iBubbl is already downloading as many attachment sends as it can and no slot frees within 10 seconds. Wait, then send the same request with the same `idempotency_key`. * **Attachments:** up to 10 files and 86 MB per message; images and GIFs 25 MiB each, video 50 MiB, PDF 10 MB, audio 25 MiB. See [Send attachments](/guides/attachments#supported-files-and-limits). * **List endpoints** ([List messages](/api-reference/messages/list), `GET /v1/messages`, and [List contacts](/api-reference/contacts/list), `GET /v1/contacts`) allow **120 requests per 60 seconds per account**. The window slides: each request counts for 60 seconds after it's made. All your API keys and all list endpoints share that one budget. Over it, the answer is `429 rate_limited` with a `Retry-After` header: wait that many seconds, then send the same request again. A refused request doesn't count. At 100 items a page, that's up to 12,000 items a minute, so a loop that reads one page after another rarely reaches it; [Pagination](/concepts/pagination#read-every-page) shows a loop that handles `429`. Other reads, such as retrieving one message, aren't limited. * **Conversation actions** ([Add](/api-reference/messages/react) and [remove](/api-reference/messages/remove-reaction) a tapback, [Mark a conversation read](/api-reference/conversations/mark-read), [Start](/api-reference/conversations/start-typing) and [stop](/api-reference/conversations/stop-typing) typing) allow **300 requests per 60 seconds per account**, shared by all your API keys and all five endpoints. It's a budget of its own: it never uses up the list budget, nor the other way round. The window slides the same way, and over it the answer is `429 rate_limited` with a `Retry-After` header. A typing refresh every 5 seconds is 12 a minute per conversation, so about 20 conversations can show typing at once. See [Tapbacks, read receipts and typing](/guides/reactions-read-receipts-typing#rate-limits). * **`POST /v1/events/{token}`** accepts up to 60 events per 60 seconds for each secret-URL [inbound event source](/inbound-events/overview). Over that, it answers `429 rate_limited` while a workflow listens to that source. If no workflow listens, it answers `200` with `runs_started: 0` and the event is dropped. Signed sources, such as Recharge, have no limit. ## Throughput and pacing No rate limit on text sends doesn't mean unlimited speed. Each line is one Mac, and it sends **one message at a time**: * The Mac checks for new messages every 2 seconds, then sends them in order with a 1-second pause after each one. So one line sends **at most about one message per second**. * Before the first text to a new US or Canadian number, the Mac asks Apple whether the number uses iMessage (see [Lines and routing](/concepts/lines-and-routing)). These checks are spaced 5 to 10 seconds apart. * The hourly and daily limits above apply on top. So a burst gains nothing. If you send 500 messages in one second, iBubbl accepts them all quickly, and then they wait as `pending` in your line's queue and go out one by one. New contacts past 30 wait an hour or more, and past 50 or 1,000 the sends are refused with `422`. **How to pace your sends:** * **Send one message at a time per line**, or keep only a few requests in flight. Faster sending only builds a longer queue. * **For outreach to new contacts, plan for 30 an hour and 50 a day per line.** One new contact every 2 minutes, spread over the contact's sending hours, stays inside both limits. * **Stop on a cap.** After `cold_cap_exhausted` or `daily_cap_exhausted`, stop that kind of send until midnight ET, instead of trying every remaining contact. * **Follow webhooks rather than polling.** If you must poll `GET /v1/messages/{id}`, poll each message at most every 30 seconds. To check many messages at once, page through [`GET /v1/messages`](/api-reference/messages/list) with `created_after` instead of polling each one. ## Duplicates and retries * **Duplicates count against every limit.** iBubbl doesn't detect identical messages. A send repeated with a new `idempotency_key` is a second real message: it uses a slot in the 1,000 a day, and it counts as another unanswered message to that contact. Reuse the key on retries. See [Message lifecycle](/concepts/message-lifecycle#duplicates-and-retries). * **A retry with the same key never counts twice.** It returns the original message, so it uses no extra slot. * **Don't retry a cap `422` with a new key.** The answer is the same until the limit resets, and nothing was sent. * **Don't resend a held or queued message.** It is already waiting and will go out by itself. A second send creates a duplicate. ## Next * [Message lifecycle](/concepts/message-lifecycle): what each status means, and which webhook fires. * [Lines and routing](/concepts/lines-and-routing): how a contact gets a line, and iMessage versus SMS. * [Errors](/errors): every error code and whether to retry it. # Lines and routing Source: https://docs.ibubbl.com/concepts/lines-and-routing Which of your lines sends to a contact, and how iBubbl decides between iMessage and SMS. A **line** is a phone number iBubbl runs for you on a dedicated Mac. Your account can have one line or several. This page explains which line a message goes out on, and whether it goes as an iMessage or a text. ## Each contact sticks to one line The first time your account messages a contact, iBubbl assigns the contact to one of your lines. After that, every message to that contact goes out on the same line, so they always see the same number. The assignment never expires. It changes in only two cases: * **The line is no longer active.** The next message to the contact moves them to another of your lines. * **The contact texts one of your other lines.** Their next message from you comes from the line they texted. Messages in a group don't move anyone. A line whose Mac is temporarily offline keeps its contacts. Their messages wait as `queued` until it is back. See [Limits and queues](/concepts/limits#offline-lines). ### How a new contact gets a line For a contact with no line yet, iBubbl picks among your lines that are active and whose Mac is online. It prefers a line that still has room for new contacts today, then the one that has messaged the fewest new contacts this hour, then today. If none of your lines' Macs is online, the send gets `422 no_eligible_line`. ### You can't choose the line `POST /v1/messages` has no field for the sending line or number. Fields it doesn't know, such as `from` or `line_id`, are **ignored without an error**: the request succeeds and the line is chosen as above. To find out which line sent a message, read `from_number` on its [`message.sent`](/webhooks/events) webhook (and on `message.delivered`, `message.read`, `message.failed` and `message.cancelled`). On [`message.inbound`](/webhooks/events), the line that received the message is `to_number`. The send and retrieve responses don't include the line. ## iMessage or SMS For each one-to-one text message to a US or Canadian number (`+1`), iBubbl decides whether to send it as an iMessage or as a text message (SMS). It goes down this list and stops at the first step that gives an answer: 1. **The number recently failed on iMessage.** An earlier message to this number failed with iMessage error 22 (not reachable on iMessage) in the last 30 days, and no iMessage to the number has been delivered since. **→ SMS.** 2. **An iMessage reached the number recently.** An iMessage from your account to this contact was delivered in the last 30 days. **→ iMessage.** 3. **Apple answered recently.** iBubbl asked Apple whether the number uses iMessage less than 30 days ago. **→ That answer.** 4. **Ask Apple now.** Your line's Mac asks Apple whether the number uses iMessage. Yes **→ iMessage.** No, or no answer within 5 seconds **→ SMS.** The message that failed with error 22 is not resent automatically: it stays `failed` with `failed_reason: "imessage_undeliverable"`. Step 1 makes sure your *next* message to that number goes as SMS. Send it again yourself if it still matters. ### What isn't routed * **Numbers outside `+1`** always go as iMessage. If the recipient isn't on iMessage, the message fails with `imessage_undeliverable`; iBubbl never falls back to SMS for them. * **Group messages** always go as iMessage. * **Attachments** aren't part of the public API yet. Messages with files sent from the dashboard or a workflow can go by SMS or MMS on some lines, and as iMessage otherwise. Routing is on for every line, for one-to-one text messages to `+1` numbers, as of 2026-11-10. ## Which channel a message used [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) returns `channel`, and so do the webhooks from [`message.sent`](/webhooks/events#the-channel-field) onwards (`message.delivered`, `message.read`, `message.failed`, `message.cancelled`), plus `message.inbound` for messages you receive: | Value | Meaning | | - | - | | `imessage` | Sent (or received) as an iMessage. | | `sms` | Sent (or received) as a text message. **RCS counts as `sms` too**: iBubbl doesn't tell SMS and RCS apart in the API. | | `null` | Not sent yet, or, rarely, iBubbl couldn't tell how it went out. For an inbound message: received before iBubbl started recording the channel. | Some behaviour depends on the channel. For example, a plain SMS never reports `read`. Check `channel` before assuming iMessage-only behaviour. > [!NOTE] > > Each webhook carries `channel` as it was when the event fired. `message.queued` doesn't carry it, since nothing has been sent yet. `GET /v1/messages/{id}` always returns the latest value. ## Next * [Limits and queues](/concepts/limits): the per-line limits and how fast a line sends. * [Message lifecycle](/concepts/message-lifecycle): every status, and which webhook fires for each. * [Group conversations](/concepts/group-conversations): how groups are addressed. # Message lifecycle Source: https://docs.ibubbl.com/concepts/message-lifecycle Every status an outbound message can have, which ones are final, which webhook fires for each, and how duplicates and retries behave. A message you send with [`POST /v1/messages`](/api-reference/messages/send) moves through a small set of statuses. The `202 Accepted` response only means iBubbl has the message. It goes out later, from the Mac that runs your line, and its status keeps changing after that. Read the current status at any time with [`GET /v1/messages/{id}`](/api-reference/messages/retrieve), or follow it with [webhooks](/webhooks/events). ## The diagram ```mermaid theme={null} flowchart TD accepted(("POST")) --> queued accepted --> pending queued --> pending pending --> sent sent --> delivered delivered --> read queued --> expired queued --> failed pending --> failed queued --> cancelled pending --> cancelled sent -.-> failed delivered -.-> failed read -.-> failed failed -.-> sent failed -.-> delivered failed -.-> read cancelled -.-> sent ``` Solid arrows are the normal path. Dashed arrows are the [moves that go backwards](#statuses-that-move-backwards), which are rare. ## Statuses | Status | Description | Is final? | Webhook emitted | | - | - | - | - | | `queued` | Accepted, but parked by a capacity hold. Today that means your line's Mac is offline (`line_offline`). One-to-one messages only. iBubbl retries every 30 seconds. | No | `message.queued` with `status: "queued"`, at acceptance | | `pending` | Accepted and waiting for your line's Mac to send it. This includes a message being [held](#held-messages) for the contact's sending hours or the hourly new-contact limit. | No | `message.queued` with `status: "pending"`, at acceptance (one-to-one only) | | `sent` | Your line sent it. | No | `message.sent` | | `delivered` | The recipient's device confirmed delivery. | No | `message.delivered` | | `read` | The recipient read it. A read is reported only when the recipient has read receipts on, and a plain SMS never reports one. | **No.** A late iMessage error can still turn it into `failed`, if no delivery receipt was recorded. | `message.read` | | `failed` | It didn't go out, or iMessage reported it undeliverable. `failed_reason` says why. That includes a `queued` message the queue's retry fails. | **Usually.** A failure with an `imessage_*` reason moves to `delivered` or `read` if Apple later confirms the message. A few connection failures can move to `sent` if the Mac confirms the send late (see below). | `message.failed`, with `failed_reason` | | `expired` | It was still `queued` about an hour after it was accepted, so iBubbl dropped it (`failed_reason: "queue_expired"`). | **Yes** | `message.failed`, with `status: "expired"` and `failed_reason: "queue_expired"` | | `cancelled` | Stopped before it went out. That happens when someone on your account cancels it in the dashboard, or when an [after-hours auto-reply](/concepts/after-hours-reply) misses its window. There is no API endpoint to cancel a message. | **Almost.** It moves to `sent` only if your line had already sent it when the cancel arrived. | `message.cancelled` (it carries no reason) | > [!NOTE] > > `message.queued` fires once, when a one-to-one message is accepted, whether it starts as `queued` or `pending`. It does not fire again when a `queued` message moves to `pending`. Group sends never fire it. ## Held messages A `pending` message can wait before it goes out. `GET /v1/messages/{id}` shows why with two fields: * `held_reason` is `working_hours` (outside the contact's [sending hours](/concepts/working-hours)) or `hourly_limit` (the line has used its [hourly new-contact limit](/concepts/limits)). * `held_until` is when it is expected to go out. It is `null` when there is no estimate: for example, when no sending day is enabled. Both are `null` when the message isn't held. iBubbl marks holds within about 10 seconds of accepting a message, so the `202` response and an immediate `GET` usually show `null`. A held message has no separate status or webhook: you get `message.sent` when it goes out. ## Events that don't fire These changes happen without a webhook. If you need to know about them, poll `GET /v1/messages/{id}`, or list recent messages with [`GET /v1/messages`](/api-reference/messages/list), which returns each one's current status. To catch up on webhooks you missed, see [Recover from downtime](/guides/webhook-retries#recover-from-downtime). * **`queued` → `pending`.** The capacity hold cleared and the message is on its way. The next event is `message.sent`. * **A group send is accepted.** Groups never fire `message.queued`. Their first event is `message.sent`. * **A read receipt marks earlier messages read.** When the recipient reads a message, iBubbl also marks your earlier `sent` and `delivered` messages in that conversation `read`, but `message.read` fires only for the message the receipt was about. * **A failed message heals straight to `delivered` or `read`.** You get `message.delivered` or `message.read`, with no `message.sent` before it. A `queued` message that ends without going out always fires `message.failed`. One still `queued` 60–65 minutes after it was accepted expires: the event carries `status: "expired"` and `failed_reason: "queue_expired"`, and its `idempotency_key` is released, so send it again if it still matters. One the queue's retry fails carries `status: "failed"` and the reason. A `pending` message never becomes `expired`. If its line's Mac has been offline for more than an hour, it moves to `failed` with `failed_reason: "agent_offline"`, and `message.failed` fires. ## Statuses that move backwards Most messages only move forward. The exceptions: * **`sent`, `delivered` or `read` → `failed`.** Messages.app can report an iMessage error after the message left your line: for example, the number isn't on iMessage. The message becomes `failed` with `failed_reason` `imessage_undeliverable` or `imessage_error:`. This happens only when no delivery receipt was recorded. * **`failed` → `delivered` or `read`.** If a message that failed with an `imessage_*` reason is later confirmed by Apple, it moves to `delivered` or `read`. * **`failed` → `sent`.** A message failed as `line_inactive` or `agent_offline`, or as `mac_send_failed` / `bluebubbles_unreachable` within 30 minutes, moves to `sent` if the Mac reports that it did send it after all. `message.sent` fires. * **`cancelled` → `sent`.** The Mac had already sent it when the cancel arrived. `message.sent` fires. Order events by the envelope's `created_at`, and when in doubt treat `GET /v1/messages/{id}` as the truth. See [ordering and duplicates](/webhooks/events#ordering-and-duplicates). ## Duplicates and retries ### What the idempotency key does Every send carries a required `idempotency_key`. Keys are scoped to your iBubbl account, shared by all of its API keys, and never expire. Full rules: [Idempotency](/idempotency). | Earlier message with this key | Same key, same request | Same key, different request | | - | - | - | | None | Sent as a new message | — | | `failed` or `expired` | The key is released: sent as a **new** message, with a new `id` | `409 idempotency_conflict` | | Any other status, **including `cancelled`** | Nothing is sent. `202` with the original message, showing its **current** status | `409 idempotency_conflict` | ### There is no content deduplication iBubbl never compares message bodies. Nothing in the API, the queue or the Mac drops a message because an identical one was sent a moment ago. **A new `idempotency_key` with an identical body sends a second, real message, even milliseconds after the first.** The idempotency key is the only thing that prevents duplicates. ### Avoid retry storms * Generate the key **once per logical message, before the first attempt**, and store it with the message in your system. Never generate it inside the retry loop. * Retry only network errors, timeouts and `5xx` responses, with the same key and the identical request body. * Cap the attempts (3 to 5 is plenty) and back off between them: 1 s, 2 s, 4 s. * Don't retry `4xx` responses. They create no message, and repeating them won't change the answer. See [Errors](/errors). * If several workers can send the same message, make sure they share the stored key, rather than each generating its own. ### After a timed-out request If your request timed out, you don't know whether iBubbl accepted it. **Send it again with the same key and the same body.** If the first request was accepted, you get `202` with that message and nothing is sent twice. If it wasn't, this request sends it once. Either way the contact gets one message. ### Two known edge cases > [!WARNING] > > **Two simultaneous requests with the same key.** If two requests with one key arrive at the same moment (for example, a retry fired while the first request is still running), one of them can return `500 internal_error`. Only one message is created. Retry the failed one with the same key: it returns the `202` replay. > [!WARNING] > > **New groups (`participant_phones`).** For a send that creates a new group, the key is attached just after the message is created. If such a request times out, a retry can create a **second group and a second message**. Don't retry a timed-out `participant_phones` send automatically. First wait for its `message.sent` webhook (its `to_number` is the group's first participant), or check the conversation in the dashboard. Sends into an existing group (`chat_guid`) and one-to-one sends are not affected. ## Next * [Limits and queues](/concepts/limits): what holds or refuses a send. * [Lines and routing](/concepts/lines-and-routing): which line sends, and whether it goes as iMessage or SMS. * [Webhook events](/webhooks/events): the payload of every event above. # Pagination Source: https://docs.ibubbl.com/concepts/pagination How list endpoints page through results: newest first, a starting_after cursor, has_more, and the rules every list follows. List endpoints return results a page at a time. Every paginated list in the iBubbl API works the same way, so one loop covers all of them. [List messages](/api-reference/messages/list) and [List contacts](/api-reference/contacts/list) follow these rules, and lists added later will too. ## How it works 1. Request the first page. Pass `limit` (1 to 100, default 50) and any filters. 2. The response is `{"data": [...], "has_more": true | false}`. 3. If `has_more` is `true`, request the next page with the same filters plus `starting_after` set to the `id` of the **last** item in `data`. 4. Stop when `has_more` is `false`. ```json theme={null} { "data": [ { "id": "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c", "created_at": "2026-11-10T15:06:41.518204Z", "...": "..." }, { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "created_at": "2026-11-10T15:04:10.112730Z", "...": "..." } ], "has_more": true } ``` The next request adds `starting_after=8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b`. ## The rules * **Newest first.** Items are ordered by `created_at` descending, then by `id` descending for items created in the same microsecond. The order is total: two requests never order the same items differently. * **The cursor is an ID.** `starting_after` is the `id` of an item you already have, not a page number or an opaque token. You can store it, log it and read it. It stays valid if you change filters: the next page starts after that item's position, whether or not the item itself matches the new filters. * **Pages don't shift.** Each page starts from the cursor item's position, not from a count of rows. Items created while you page through can't push an item onto two pages or make one disappear. New items appear at the front of the list, so a loop that started before they arrived doesn't see them; run it again with `created_after` to pick them up. * **`has_more` is exact.** When it's `false` there is nothing after the last item. A page can be shorter than `limit` only when it's the last one, and an empty `data` always comes with `has_more: false`. * **`limit` is strict.** Outside 1–100 is `422 validation_error`, not rounded to the nearest valid value. * **Unknown parameters are refused.** A query parameter the endpoint doesn't know, or one given twice, returns `422 validation_error` naming it. This is stricter than request bodies on purpose: a misspelt cursor such as `startingAfter`, quietly ignored, would return the first page forever and your loop would never end. * **An unknown cursor is an error.** A `starting_after` that isn't one of your items' IDs returns `422 invalid_cursor`. Another account's ID is answered the same way, and so is a contact deleted after you received it: start again from the first page. * **Time filters are exclusive and need a time zone** (on lists that have them, such as List messages). `created_after` and `created_before` exclude the exact time given. Write times as ISO 8601 with `Z` (`2026-11-10T09:30:00Z`); `created_at` values come back in that form, so you can pass them back unchanged. In a URL, an unencoded `+` (as in `+00:00`) reads as a space, so encode it as `%2B` or use `Z`. * **Filters are applied before paging.** A rare filter value still returns every match across pages. A filter that matches nothing returns an empty page, not `404`. ## Read every page The loop below fetches page after page until `has_more` is `false`, and yields items one at a time. Pass any filters the endpoint takes. If it goes over the [list rate limit](/concepts/limits#rate-limits), it waits as long as `Retry-After` says and asks for the same page again. ```typescript TypeScript theme={null} type Message = { id: string; direction: "inbound" | "outbound"; created_at: string; status: string; body: string; contact_id: string | null; conversation_id: string | null; }; async function* listMessages( filters: Record = {}, ): AsyncGenerator { let startingAfter: string | undefined; while (true) { const params = new URLSearchParams({ limit: "100", ...filters }); if (startingAfter) params.set("starting_after", startingAfter); const res = await fetch(`https://api.ibubbl.com/v1/messages?${params}`, { headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}` }, signal: AbortSignal.timeout(10_000), }); if (res.status === 429) { // Over the list rate limit: wait, then ask for the same page again. const seconds = Number(res.headers.get("Retry-After") ?? "1"); await res.body?.cancel(); await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); continue; } const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); yield* data.data as Message[]; if (!data.has_more) return; startingAfter = data.data[data.data.length - 1].id; } } for await (const message of listMessages({ direction: "inbound" })) { console.log(message.created_at, message.id, message.body); } ``` ```python Python theme={null} import os import time import requests API_KEY = os.environ["IBUBBL_API_KEY"] def list_messages(**filters): params = {"limit": 100, **filters} while True: res = requests.get( "https://api.ibubbl.com/v1/messages", headers={"Authorization": f"Bearer {API_KEY}"}, params=params, timeout=10, ) if res.status_code == 429: # Over the list rate limit: wait, then ask for the same page again. time.sleep(int(res.headers.get("Retry-After", "1"))) continue data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") yield from data["data"] if not data["has_more"]: return params["starting_after"] = data["data"][-1]["id"] for message in list_messages(direction="inbound"): print(message["created_at"], message["id"], message["body"]) ``` A few notes: * **Keep the filters the same on every page**, and change only `starting_after`. The loops above do this by building each request from the same `filters`. * **Fetch pages one at a time.** Each page needs the last ID of the one before it, so pages can't be fetched in parallel. * **A list request is safe to retry.** On a network error, a timeout or a `5xx`, request the same page again with the same `starting_after`. Listing never changes anything. Don't retry a `4xx`: fix the request. The one exception is `429 rate_limited`: your account made more than 120 list requests in 60 seconds. Wait the number of seconds in the `Retry-After` header, then send the same request again. The refused request didn't count, and the cursor is still valid. See [Rate limits](/concepts/limits#rate-limits). * **One budget per account.** Every API key on your account and every list endpoint share the 120 requests a minute. Two loops running side by side each get about half. * **Big ranges.** Narrow the range with `created_after` / `created_before` rather than reading your whole history on every run. The [reconciliation recipe](/guides/webhook-retries#recover-from-downtime) shows how to keep a checkpoint. ## Next > [card] [List messages](/api-reference/messages/list) > > Every filter and the response shape. > [card] [List contacts](/api-reference/contacts/list) > > Search, tag, opt-out and block filters. > [card] [Recover from downtime](/guides/webhook-retries#recover-from-downtime) > > Catch up on what your webhook endpoint missed. # Suppression & Opt-Outs Source: https://docs.ibubbl.com/concepts/suppression How iBubbl handles opt-outs to keep you TCPA-compliant. iBubbl checks opt-outs when a send is accepted. Once a contact is opted out, any new send attempt — through any API endpoint, dashboard action, or automation — fails immediately with `422 contact_opted_out`. There's no way to bypass this server-side. > [!WARNING] > > **Messages accepted before the opt-out may still be sent.** The check happens when a message is accepted, and waiting messages are not withdrawn automatically today. A message that was already accepted and is still waiting — held for the contact's [sending hours](/concepts/working-hours), or queued for capacity — when the contact opts out can still go out afterwards. To stop it, cancel it in the dashboard (a [`message.cancelled`](/webhooks/events) webhook fires), or email [support@ibubbl.com](mailto:support@ibubbl.com). This isn't just hygiene — it's how you stay TCPA-compliant for US recipients. Opt-out enforcement is non-negotiable. ## What triggers an opt-out ### Inbound STOP-pattern keyword The most common path. When an inbound message body matches one of the recognized keywords (case-insensitive, exact-match after stripping whitespace), iBubbl auto-marks the contact opted out and fires a [`contact.opted_out`](/webhooks/events) webhook with `source: "auto_keyword"`. Recognized keywords: * `stop` * `unsubscribe` * `cancel` * `end` * `quit` * `stopall` The match is intentionally strict: the body must equal one of these keywords after `.strip().lower()`. Common false-positive patterns like `"STOP! that's hilarious"` or `"please stop sending"` do **not** match — they don't equal the keyword exactly. If you need broader pattern handling, post-process inbound webhooks yourself and have your rep mark the contact opted out from the dashboard. ### Manual dashboard opt-out A rep can mark a contact opted out from the dashboard's contact detail view. A [`contact.opted_out`](/webhooks/events) webhook fires with `source: "manual"`. ### GoHighLevel Do Not Disturb If your account is connected to a GoHighLevel sub-account, marking a contact Do Not Disturb there (for SMS or all channels) opts them out in iBubbl too. The webhook fires with `source: "ghl_dnd"`. ### Through the API [`POST /v1/contacts/{id}/opt-out`](/api-reference/contacts/opt-out) records an opt-out from your own system, for example one collected in your CRM, by email or on a call. The webhook fires with `source: "api"`. Calling it for someone already opted out (in any way) answers `200` and fires nothing. > [!WARNING] > > **An API opt-out is as permanent as a STOP reply.** Nothing can undo it, through the API or the dashboard. Call it only for a person who asked not to be messaged. To pause messages to someone, [block](/api-reference/contacts/block) them instead. ## What happens after opt-out * All `POST /v1/messages` to the contact return `422 contact_opted_out`. * Automation (workflow) sends to the contact are refused the same way. * The contact remains visible in your dashboard with an opted-out badge, and `opted_out` is `true` on the [contact](/api-reference/contacts/retrieve). * **The opt-out belongs to the phone number, not the contact.** It matches every form of the number (`+14155551234` and `4155551234` are the same person). If you [delete the contact](/api-reference/contacts/delete) and the number comes back as a new contact, the new one starts with `opted_out: true` and sends are still refused. ## Re-opting in An opt-out can't currently be undone — not through the API and not from the dashboard. This is deliberate: accidental or automated re-opt-ins are a compliance risk. If a contact was opted out by mistake, email [support@ibubbl.com](mailto:support@ibubbl.com). ## Detecting opt-outs in your integration The cleanest path is the [`contact.opted_out`](/webhooks/events) webhook — you'll see it shortly after the opt-out happens, with the source. It fires once per person, the first time they opt out. If you can't run webhooks, list opted-out contacts with [`GET /v1/contacts?opted_out=true`](/api-reference/contacts/list), or check `opted_out` on each contact response: ```bash cURL theme={null} curl https://api.ibubbl.com/v1/contacts/c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const contactId = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"; const res = await fetch(`https://api.ibubbl.com/v1/contacts/${contactId}`, { 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}`); if (data.opted_out) console.log("opted out at", data.opted_out_at); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] contact_id = "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f" res = requests.get( f"https://api.ibubbl.com/v1/contacts/{contact_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") if data["opted_out"]: print("opted out at", data["opted_out_at"]) ``` ```json theme={null} { "id": "c8a1f4e2-...", "phone": "+14155551234", "opted_out": true, "opted_out_at": "2026-05-17T14:28:00.000Z", ... } ``` ## Block or opt out Both stop one-to-one sends, but they are different things: | | [Block](/api-reference/contacts/block) | Opt-out | | - | - | - | | Whose decision | Yours: you don't want to message them for now. | The contact's: they asked not to be messaged. | | Undo | Yes: [unblock](/api-reference/contacts/unblock), or from the dashboard. | Never. | | Webhook | None. | [`contact.opted_out`](/webhooks/events), once per person. | | Sends | One-to-one sends return `422 contact_blocked`. Group conversations are not affected. | Every send returns `422 contact_opted_out`, including to a group they're in (`group_outbound_blocked_suppression`). | | Their inbound messages | Stored and sent to you as `message.inbound`, but not marked unread, not notified, and no workflow starts. | Stored and sent to you as `message.inbound`. No workflow starts. | | Tapbacks and typing | In a one-to-one conversation, [Add a tapback](/api-reference/messages/react) and [Start typing](/api-reference/conversations/start-typing) return `422 contact_blocked`. Removing a tapback and stopping typing still work. | The same, with `422 contact_opted_out`. | | Read receipts | [Mark a conversation read](/api-reference/conversations/mark-read) marks the messages read but sends no receipt (`contact_blocked`). | The same (`contact_opted_out`). | | After the contact is deleted | Gone: a new contact for the number isn't blocked. | Kept: a new contact for the number is opted out. | A contact can be both. Sends then return `contact_opted_out`. ## Compliance posture iBubbl's suppression is built for TCPA compliance for US recipients of iMessage. The defaults — strict STOP matching, mandatory enforcement, no re-opt-in — reflect that posture. If your use case has different jurisdictional requirements, email [support@ibubbl.com](mailto:support@ibubbl.com) before launching. You remain the responsible party for your account's send patterns and content. iBubbl's enforcement reduces risk; it doesn't eliminate it. # Working Hours & Scheduling Source: https://docs.ibubbl.com/concepts/working-hours How iBubbl respects working hours for outbound sends. iBubbl never sends an iMessage outside of the recipient's working hours. This protects your sender reputation and respects contact preferences — late-night blasts are the fastest way to get a phone number flagged. The one exception is an [after-hours auto-reply](/concepts/after-hours-reply): a reply to someone who texted you in the last 20 minutes can go out straight away. ## How it works Every outbound message — sent from the dashboard, `POST /v1/messages`, a workflow, a scheduled send or an integration — waits for the recipient's working hours before it leaves your device: * `POST /v1/messages` accepts the send straight away (`202 Accepted`) with `status: "pending"`, whatever the time. * If the **recipient's local time** is inside your working window, the message goes out within seconds. * If it is outside, the message stays `pending` and goes out automatically when the window next opens. There is no separate status or webhook for this wait; a [`message.sent`](/webhooks/events) webhook fires when it goes out. You don't need to do anything to opt in. The behavior is on by default for every account. ## Configuring working hours Working hours are set in the dashboard at **Settings → Sending Hours**. Defaults for new accounts: | Day | Sends allowed | | - | - | | Monday – Saturday | 09:00 – 20:00 | | Sunday | 12:00 – 20:00 | The end time is exclusive: with the default window, sends pause at 8:00 PM. The window is applied in each **contact's** timezone (see below), so there is no timezone setting on the window itself. These are only your **sending** hours. When people can book meetings with you is a separate setting, **Settings → Booking Hours**, in your own time zone. You can configure different windows per day of the week, or close specific days entirely. The dashboard is the only way to manage these settings — there's no API surface for them today. ## Per-contact timezone The local time check uses the **contact's timezone**, not your account's. iBubbl determines a contact's timezone in this order: 1. **Explicit override** — if you set the `timezone` field on the contact (via [`POST /v1/contacts`](/api-reference/contacts/upsert) or the dashboard). 2. **Area-code inference** — for North American numbers, iBubbl infers a default timezone from the area code (e.g., `+1415...` → `America/Los_Angeles`). 3. **Account timezone** — the timezone in your dashboard's **Settings → Notifications**, which starts as your browser's timezone. 4. **`America/New_York`** — if none of the above is set. To set a contact's timezone yourself, pass it on contact upsert **before you send to them**: ```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", "timezone": "America/New_York" }' ``` ```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", timezone: "America/New_York", }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.timezone); ``` ```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", "timezone": "America/New_York", }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["timezone"]) ``` Once a contact has a timezone, an upsert never changes it. That includes the inferred one: the area-code guess is saved on the contact when their first message is processed, so a `timezone` sent on a later upsert is ignored. Upsert first, then send. The `timezone` must be one of the supported zones (US, Canada, and a few international ones); anything else returns `422 invalid_timezone`. ## Detecting a held send A message waiting for working hours is `pending`, like one that is about to go out. [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) tells them apart with two fields: ```json theme={null} { "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", "status": "pending", "to": "+14155551234", "held_reason": "working_hours", "held_until": "2026-10-08T16:00:00+00:00" } ``` * `held_reason` — why it is waiting right now: `working_hours` (outside the contact's working hours) or `hourly_limit` (your line has messaged 30 new contacts in the last 60 minutes; the message stays `pending` and goes out by itself when a slot frees — see [Limits and queues](/concepts/limits)). * `held_until` — when it is **expected** to go out. For `working_hours`, the contact's next opening: it is recomputed within about 10 seconds when your hours change, and it is `null` if no sending day is enabled. For `hourly_limit`, an estimate of when a slot frees, or `null` if there is none. Both are `null` when the message is not being held. A held message is first marked within about 10 seconds of being accepted, so a `GET` immediately after `POST` may not show the hold yet. A [`message.sent`](/webhooks/events) webhook fires when it goes out. ## Capacity holds (`status: "queued"`) Separately from working hours, a one-to-one send can be accepted but parked. The response is still `202 Accepted`, with `status: "queued"`. Today the only reason is `line_offline`: your line's Mac is offline. The reason is in the `failed_reason` field of the [`message.queued`](/webhooks/events) webhook, not in the API response. (`hourly_cap_hit` is a legacy reason that no current line uses; the hourly new-contact limit holds messages as `pending` instead.) iBubbl re-checks queued messages every 30 seconds and sends them once the Mac is back. A message still queued 60–65 minutes after it was accepted is dropped and moves to `status: "expired"`, and a [`message.failed`](/webhooks/events#message-failed) webhook fires with `status: "expired"` and `failed_reason: "queue_expired"`. Send it again (a new `idempotency_key`, or the same one: an expired message's key is released) if it still matters. A `pending` message works differently: if its line has been offline for over an hour, it is not sent late. It moves to `status: "failed"` with `failed_reason: "agent_offline"`, and a [`message.failed`](/webhooks/events) webhook fires. The daily limits do **not** queue: a send over 50 new contacts or 1,000 messages a day fails immediately with a `422`. See [Limits and queues](/concepts/limits) for every limit and [Errors](/errors) for the codes. # Error Reference Source: https://docs.ibubbl.com/errors Stable error codes and what they mean. Every iBubbl API error response uses the same envelope shape: ```json theme={null} { "detail": { "error": "validation_error", "message": "Exactly one of contact_id, to, chat_guid, or participant_phones is required" } } ``` The `error` field is a **stable machine-readable code** — branch your integration's logic on this, not on the HTTP status or `message`. The `message` field is human-readable and may change without notice. ## Request IDs Every response from `api.ibubbl.com`, successful or not, carries an `X-Request-Id` header. It names that one request in iBubbl's logs. When something goes wrong, log it next to the error and quote it to [support@ibubbl.com](mailto:support@ibubbl.com): it finds your request faster than a timestamp and a request body can. ```bash cURL theme={null} # -D - prints the response headers, including X-Request-Id. curl -sS -D - https://api.ibubbl.com/v1/messages/8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const messageId = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b"; const res = await fetch(`https://api.ibubbl.com/v1/messages/${messageId}`, { headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}` }, signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) { const requestId = res.headers.get("x-request-id"); throw new Error(`${res.status} ${data.detail?.error} (request ${requestId})`); } ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] message_id = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b" res = requests.get( f"https://api.ibubbl.com/v1/messages/{message_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: request_id = res.headers.get("X-Request-Id") raise RuntimeError(f"{res.status_code} {data['detail']['error']} (request {request_id})") ``` **Send your own ID to trace a request end to end.** If your request carries an `X-Request-Id` header, iBubbl uses it as the request's ID and returns it unchanged, so the same ID appears in your logs and ours. It must be 1–128 characters of letters, digits, `.`, `_`, `:` and `-`. Anything else is ignored, and iBubbl generates an ID instead. Reusing one ID for several requests is allowed, but a unique ID per request is easier to find. ## Error catalog **unauthorized** `401` Missing, malformed, or revoked API key. Verify the `Authorization: Bearer ib_pk_` header and that the key is active in your dashboard. **forbidden** `403` Your API key is valid, but your iBubbl account is suspended or cancelled (`message` is `Account is suspended` or `Account is cancelled`). Every API-key endpoint returns this. A resource that belongs to another account is never a 403: it returns `not_found`. **not\_found** `404` The requested resource doesn't exist, OR it exists but belongs to a different customer (existence is hidden across tenants). Verify the resource ID is correct and was created on the same account whose key you're using. A [deleted contact](/api-reference/contacts/delete) answers `404` too, including as the `contact_id` of a send. **idempotency\_conflict** `409` You reused an `idempotency_key` with a request payload that doesn't match the one originally cached. Either generate a new key for this distinct send, or fix your retry logic to reuse the original payload byte-for-byte. See [Idempotency](/idempotency). **validation\_error** `422` Generic request-shape failure. The `message` will identify the field. Common causes: missing required body field, malformed E.164 phone, body length zero, `to` and `contact_id` both omitted, neither `body` nor `attachments`, an `attachments` entry that isn't an `https` URL (or more than 10 of them), `attachments` with `chat_guid` or `participant_phones`, or `attachments` with `effect` or `reply_to_message_id`. On a list endpoint: an unknown or repeated query parameter, `limit` outside 1–100, or a time without a time zone. **invalid\_cursor** `422` List endpoints only: `starting_after` is a UUID but not the `id` of one of your items (another account's ID, and a contact deleted since, are answered the same way). Pass the `id` of the last item from the previous page, with the same endpoint. See [Pagination](/concepts/pagination). **contact\_opted\_out** `422` The destination contact has opted out and cannot receive new sends. iBubbl enforces this server-side; there is no programmatic way to override. [Add a tapback](/api-reference/messages/react) and [Start typing](/api-reference/conversations/start-typing) return it too, in a one-to-one conversation. See [Suppression & Opt-Outs](/concepts/suppression). **contact\_blocked** `422` A one-to-one send to a contact your account has blocked, in the dashboard or with [Block a contact](/api-reference/contacts/block). Nothing was sent. The send works again once the contact is [unblocked](/api-reference/contacts/unblock). If a contact is both opted out and blocked, you get `contact_opted_out`. [Add a tapback](/api-reference/messages/react) and [Start typing](/api-reference/conversations/start-typing) return it too, in a one-to-one conversation. **unresponsive\_contact** `422` Refused on the 7th consecutive message to a contact who has never replied: iBubbl allows 6 unanswered messages in a row, then stops. Messages still waiting to go out (`pending` or `queued`) count toward the 6. Nothing was sent. The limit lifts as soon as the contact replies; waiting does not reset it. **invalid\_timezone** `422` The `timezone` field on a contact upsert is not in the supported IANA allowlist. The supported set covers US/Canada timezones plus common international zones; if you need a zone that isn't supported, email [support@ibubbl.com](mailto:support@ibubbl.com). **invalid\_email** `422` The `email` on a contact create or update doesn't look like an email address (`name@domain.tld`, no spaces, at most 254 characters). Nothing was saved. **invalid\_tag** `422` A tag name is empty, longer than 64 characters, or holds an invisible character. On a contact create, nothing was saved. **duplicate\_tag** `422` A tag name collides with an existing tag in a way iBubbl can't resolve. Rare: choose a different name. **line\_offline** `queued` Your line's Mac is offline. This is not returned as an error: a one-to-one send to a contact your line has messaged before is accepted (`202`, `status: "queued"`) and goes out automatically when the Mac reconnects. The reason appears only as `failed_reason` on the [`message.queued`](/webhooks/events) webhook, not in the API response. If it is still queued 60–65 minutes after it was accepted it expires (`status: "expired"`), and [`message.failed`](/webhooks/events#message-failed) fires with `failed_reason: "queue_expired"`. A contact with no line yet gets `no_eligible_line` instead. See [Limits and queues](/concepts/limits#offline-lines). **daily\_cap\_exhausted** `422` The line has sent 1,000 messages today, its daily ceiling. Every outbound message counts: new contacts, replies, follow-ups, groups, messages still waiting and failed ones. The send fails and is not queued; the count resets at midnight ET (America/New\_York), so send again after that. See [Limits and queues](/concepts/limits). **cold\_cap\_exhausted** `422` The line has messaged 50 new contacts today (contacts your account has no history with). The send fails and is not queued. Messages to contacts already counted today, and to anyone you've messaged before, still go out. The count resets at midnight ET (America/New\_York). See [Limits and queues](/concepts/limits). **hourly\_cap\_hit** `queued` Legacy: not used on any current line. The hourly limit on new contacts is a hold instead: the message stays `pending` with `held_reason: "hourly_limit"` and goes out by itself. See [Limits and queues](/concepts/limits). **followup\_cap\_exhausted** `422` Legacy: not used on any current line. It was a daily cap on follow-ups under the older per-line caps. **no\_eligible\_line** `422` No line on your account can take this send right now: for example, a contact you haven't messaged before while your line is offline, or a group whose line is no longer active. Nothing was sent. Send again once your line is back online. **group\_not\_found** `404` The `chat_guid` doesn't match a group conversation on your account. A group's `chat_guid` can change when someone is added to it, so look up the current one. Retrying the same request won't help. **group\_outbound\_blocked\_suppression** `422` One or more participants in the group have opted out, so iBubbl won't send to the group. When the send would create a new group, `detail` also carries a `blocked_phones` list naming them. Opt-outs are permanent, so retrying won't help; send to a group without them. **insufficient\_participants** `422` The `participant_phones` resolve to fewer than 2 different people: for example, the same number written two ways. A group needs at least 2 different participants; for one person, send one-to-one. **reply\_target\_not\_found** `422` `POST /v1/messages` only: `reply_to_message_id` doesn't name a message in the conversation you're sending to. For a `to` or `contact_id` send it must be a message with that contact, one-to-one; for a `chat_guid` send, a message in that group. A message from another account is answered the same way. Check the ID and the addressing field. See [Reply and send with effects](/guides/send-messages#reply-to-a-message-and-send-with-effects). **reply\_target\_unsent** `422` `POST /v1/messages` only: the contact unsent the message `reply_to_message_id` points to, so it can't be quoted. Send without `reply_to_message_id`. **reply\_target\_partly\_unsent** `422` `POST /v1/messages` only: the contact unsent part of the message `reply_to_message_id` points to, and your line can't quote what's left. Send without `reply_to_message_id`. **invalid\_target** `422` [Add a tapback](/api-reference/messages/react) only: the message is one you sent. Tapbacks go only on messages you received. **sms\_not\_supported** `422` [Add a tapback](/api-reference/messages/react) only: the message came in by SMS, or the conversation's latest messages are going out by SMS, and tapbacks can't be sent over SMS. They work over iMessage and RCS. Nothing was sent. See [Tapbacks, read receipts and typing](/guides/reactions-read-receipts-typing#tapbacks). **target\_unsent** `422` [Add a tapback](/api-reference/messages/react) and [Remove a tapback](/api-reference/messages/remove-reaction): the contact unsent the message, so it can't get (or lose) a tapback. Also a `failed_reason` on [`message.reaction.failed`](/webhooks/events) when they unsent it before the tapback went out. **target\_partly\_unsent** `422` [Add a tapback](/api-reference/messages/react) and [Remove a tapback](/api-reference/messages/remove-reaction): the contact unsent part of the message, and your line can't place a tapback on what's left. **opt\_out\_check\_unavailable** `503` [Add a tapback](/api-reference/messages/react) and [Start typing](/api-reference/conversations/start-typing): iBubbl couldn't confirm that the contact hasn't opted out, so it sent nothing. Retry after a short backoff. (Mark read doesn't fail this way: it answers `200` with `read_receipt_skipped_reason: "opt_out_check_unavailable"` and sends no receipt.) **first\_message\_needs\_text** `422` `POST /v1/messages` only: you sent `attachments` without a `body` to a contact you've never exchanged a message with. Messages can't open a new conversation with files alone. Add a `body`. See [Captions and the first message](/guides/attachments#captions-and-the-first-message). **attachment\_fetch\_failed** `422` `POST /v1/messages` only: iBubbl couldn't download one of the `attachments` URLs. The `message` names it by position, for example `attachments[1]: attachment fetch answered 404`. Causes: the host doesn't resolve or is a private or internal address, the server answered something other than `200`, more than 3 redirects (or one to a non-`https` URL), an empty file, or the download took too long (about 30 seconds for all the files, or 10 seconds without data). Check the URL works from the public internet, then send again. Nothing was created. See [Send attachments](/guides/attachments#how-the-urls-are-read). **attachment\_too\_large** `422` `POST /v1/messages` only: a file is over its kind's limit (images and GIFs 25 MiB, video 50 MiB, PDF 10 MB, audio 25 MiB), or the files are over 86 MB in all. The `message` names the file. Send a smaller file, or split the files across messages. See [Supported files and limits](/guides/attachments#supported-files-and-limits). **unsupported\_attachment** `422` `POST /v1/messages` only: a file's type can't be sent through the API, for example a calendar invite (`.ics`), WebM or Ogg audio, SVG or a ZIP file. The type comes from the response's `Content-Type` (or the URL's extension when that is `application/octet-stream`). Convert the file to a supported type. **attachment\_combination\_not\_supported** `422` `POST /v1/messages` only: the files can't go out together with their caption. With other files, audio must be a single MP3 of 2 MB or less; any other audio, and a second audio file, needs a message of its own. Send it separately. **imessage\_undeliverable** `message.failed` Not an API error: it appears only as `failed_reason` on a failed message and its [`message.failed`](/webhooks/events) webhook. iMessage couldn't deliver the message to this number because the number isn't reachable on iMessage. Resending fails the same way, so reach the contact another way. Other iMessage failures appear as `imessage_error:`. **internal\_error** `500` An unexpected error on iBubbl's side. These are paired with our error-tracking system; we get paged when they happen. If you see a sustained pattern, email [support@ibubbl.com](mailto:support@ibubbl.com) with the response's [`X-Request-Id`](#request-ids) header. One known cause: two `POST /v1/messages` requests with the same `idempotency_key` arriving at the same moment. Only one message is created, and the other request gets this `500`. Retrying it with the same key returns the `202` replay. See [Duplicates and retries](/concepts/message-lifecycle#duplicates-and-retries). **no\_owner\_rep** `500` Your account has no active owner rep to send as, so `POST /v1/messages` can't send. Retrying won't help until the account has one; email [support@ibubbl.com](mailto:support@ibubbl.com). **invalid\_url** `422` A [`POST /v1/webhooks`](/api-reference/webhooks/create) `url` that doesn't start with `https://`. Webhook endpoints must use HTTPS. **invalid\_signature** `403` An [inbound event](/inbound-events/overview) arrived with a missing or incorrect signature. For Recharge, the usual cause is that the source was created with your API token pasted where the **API Client Secret** belongs — they're different values. **payload\_too\_large** `413` An [inbound event](/inbound-events/overview) body exceeded 256 KB. **rate\_limited** `429` Returned in four places, and nowhere else: * **Conversation actions** ([Add](/api-reference/messages/react) and [remove](/api-reference/messages/remove-reaction) a tapback, [Mark read](/api-reference/conversations/mark-read), [Start](/api-reference/conversations/start-typing) and [stop](/api-reference/conversations/stop-typing) typing): your account made more than 300 of these requests in 60 seconds (all API keys and all five endpoints together; a budget separate from lists). The response has a `Retry-After` header; wait that long and call again. See [Rate limits](/concepts/limits#rate-limits). * **List endpoints** ([List messages](/api-reference/messages/list), [List contacts](/api-reference/contacts/list)): your account made more than 120 list requests in 60 seconds (all API keys and all lists together). The response has a `Retry-After` header with the seconds to wait; then send the same request again. See [Rate limits](/concepts/limits#rate-limits). * **Sends with `attachments`**, rarely: iBubbl was already downloading as many attachment sends as it can, and no slot freed within 10 seconds. `Retry-After` is `5`; then send the same request with the same `idempotency_key`. Nothing was created. * **`/v1/events`**: a secret-URL [inbound event](/inbound-events/overview) source received more than 60 events in 60 seconds. There is no `Retry-After` header here; send again after about a minute. Text-only sends (`POST /v1/messages` without `attachments`) never return it. ## When to retry | Code | HTTP | Retryable? | | - | - | - | | `unauthorized` | 401 | ❌ Fix the key. | | `forbidden` | 403 | ❌ Account suspended or cancelled; email [support@ibubbl.com](mailto:support@ibubbl.com). | | `not_found` | 404 | ❌ Verify the ID. | | `group_not_found` | 404 | ❌ Use the group's current `chat_guid`. | | `idempotency_conflict` | 409 | ❌ Fix the retry logic. | | `validation_error` | 422 | ❌ Fix the payload or query parameters. | | `invalid_cursor` | 422 | ❌ Use the last `id` from the previous page. | | `contact_opted_out` | 422 | ❌ Cannot bypass. | | `contact_blocked` | 422 | ❌ Not until the contact is [unblocked](/api-reference/contacts/unblock). | | `unresponsive_contact` | 422 | ❌ Not until the contact replies. | | `group_outbound_blocked_suppression` | 422 | ❌ Cannot bypass; leave out the opted-out participants. | | `insufficient_participants` | 422 | ❌ Fix the participant list, or send one-to-one. | | `reply_target_not_found` | 422 | ❌ Fix `reply_to_message_id`, or the addressing field. | | `reply_target_unsent` / `reply_target_partly_unsent` | 422 | ❌ Send without `reply_to_message_id`. | | `invalid_target` | 422 | ❌ Tapbacks go only on messages you received. | | `sms_not_supported` | 422 | ❌ Not over SMS. | | `target_unsent` / `target_partly_unsent` | 422 | ❌ The contact unsent the message. | | `first_message_needs_text` | 422 | ❌ Add a `body`. | | `attachment_fetch_failed` | 422 | ⚠️ Fix or check the URL, then send again; the key is still unused. | | `attachment_too_large` / `unsupported_attachment` / `attachment_combination_not_supported` | 422 | ❌ Change the files. | | `invalid_url` | 422 | ❌ Use an `https://` URL. | | `invalid_timezone` | 422 | ❌ Fix the timezone. | | `invalid_email` / `invalid_tag` / `duplicate_tag` | 422 | ❌ Fix the value. | | `line_offline` | — (`202`, `queued`) | ⚠️ Not an error: iBubbl queues and retries for up to an hour. Don't resend. | | `daily_cap_exhausted` / `cold_cap_exhausted` | 422 | ⚠️ Not queued. Send again after the limit resets (midnight ET). | | `hourly_cap_hit` / `followup_cap_exhausted` | — | Legacy: not used on any current line. | | `no_eligible_line` | 422 | ⚠️ Send again once your line is back online. | | `rate_limited` | 429 | ✅ Lists, conversation actions and attachment sends: send the same request again (same `idempotency_key`) after `Retry-After` seconds. `/v1/events`: after about a minute. | | `invalid_signature` | 403 | ❌ Fix the signing secret. | | `payload_too_large` | 413 | ❌ Fix the payload size. | | `no_owner_rep` | 500 | ❌ Not until your account has an active owner rep; email [support@ibubbl.com](mailto:support@ibubbl.com). | | `internal_error` | 500 | ✅ Retry with the same idempotency key after a short backoff. | | `opt_out_check_unavailable` | 503 | ✅ Retry after a short backoff. | The general rule: **don't retry 4xx**. Most 4xxes are payload-level problems that retrying won't fix. The exception is `429 rate_limited`: wait, then send the same request again (on a list, a conversation action or an attachment send, `Retry-After` says how long). The limit 422s (`daily_cap_exhausted`, `cold_cap_exhausted`) are retryable only after the limit resets at midnight ET; iBubbl does not queue them for you. The hourly new-contact limit and an offline line are different: those sends are accepted and wait (`pending` with `held_reason: "hourly_limit"`, or `queued`), so resending just creates a duplicate. The numbers are on [Limits and queues](/concepts/limits). A send refused with a `404` or `422` creates no message, so its `idempotency_key` stays unused and you can reuse it when you send again. ## HTTP status mapping summary | Status | When | | - | - | | `200` / `201` / `202` / `204` | Success. `202` for accepted-but-queued sends; `201` for created resources; `204` (no body) for [Delete a contact](/api-reference/contacts/delete); `200` everywhere else. | | `401` | Auth failure. | | `403` | Account suspended or cancelled (`forbidden`), or an inbound event's signature failed (`invalid_signature`). | | `404` | Resource not found (or hidden across tenants), or `group_not_found`. | | `409` | Idempotency conflict. | | `413` | Inbound event body over 256 KB. | | `422` | Validation, routing or send-limit failure. | | `429` | `rate_limited`: too many list requests or conversation actions from your account, or attachment downloads busy (all with `Retry-After`), or too many inbound events for one source on `/v1/events`. | | `500` | No owner rep (`no_owner_rep`) or an unexpected iBubbl error (`internal_error`). | | `503` | `opt_out_check_unavailable` on a tapback or typing call. Retry after a short backoff. | | `502` / `503` without a `detail.error` | The network edge during a deployment blip, not iBubbl itself. Retry with the same idempotency key after a short backoff. | # Send attachments Source: https://docs.ibubbl.com/guides/attachments Send photos, videos, PDFs and audio by URL, with an optional caption. Send files with a message by listing their URLs in `attachments` on [`POST /v1/messages`](/api-reference/messages/send). iBubbl downloads each file when the request arrives, checks it, and sends it from your line with your `body` as the caption. ```json theme={null} { "to": "+14155551234", "body": "Here's your receipt.", "attachments": ["https://files.example.com/receipts/order-1042.pdf"], "idempotency_key": "…" } ``` ## Send files with a caption ```bash cURL theme={null} # One key per message: generate it once, and reuse it if you retry. IDEMPOTENCY_KEY=$(uuidgen) # iBubbl downloads the files before answering: allow up to 60 seconds. curl -X POST https://api.ibubbl.com/v1/messages \ --max-time 60 \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"to\": \"+14155551234\", \"body\": \"Here are the photos from today's visit.\", \"attachments\": [ \"https://files.example.com/visits/1042/front.jpg\", \"https://files.example.com/visits/1042/back.jpg\" ], \"idempotency_key\": \"$IDEMPOTENCY_KEY\" }" ``` ```typescript TypeScript theme={null} import { randomUUID } from "node:crypto"; // One key per message: generate it once, and reuse it if you retry. const idempotencyKey = randomUUID(); const res = await fetch("https://api.ibubbl.com/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+14155551234", body: "Here are the photos from today's visit.", attachments: [ "https://files.example.com/visits/1042/front.jpg", "https://files.example.com/visits/1042/back.jpg", ], idempotency_key: idempotencyKey, }), // iBubbl downloads the files before answering: allow up to 60 seconds. signal: AbortSignal.timeout(60_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.status, data.attachments); ``` ```python Python theme={null} import os import uuid import requests API_KEY = os.environ["IBUBBL_API_KEY"] # One key per message: generate it once, and reuse it if you retry. idempotency_key = str(uuid.uuid4()) res = requests.post( "https://api.ibubbl.com/v1/messages", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "to": "+14155551234", "body": "Here are the photos from today's visit.", "attachments": [ "https://files.example.com/visits/1042/front.jpg", "https://files.example.com/visits/1042/back.jpg", ], "idempotency_key": idempotency_key, }, # iBubbl downloads the files before answering: allow up to 60 seconds. timeout=60, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["status"], data["attachments"]) ``` The `202` response is the usual [message object](/api-reference/messages/send#responses), with an `attachments` array describing what will be sent, in your order: ```json theme={null} { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "status": "pending", "body": "Here are the photos from today's visit.", "attachments": [ { "kind": "image", "mime_type": "image/jpeg", "size_bytes": 2481133 }, { "kind": "image", "mime_type": "image/jpeg", "size_bytes": 1975020 } ] } ``` (Other fields are omitted here.) [Retrieve a message](/api-reference/messages/retrieve) and [List messages](/api-reference/messages/list) return the same `attachments` array, including the files your contacts send you. They describe the files; they don't return their contents or URLs. > [!NOTE] > > **Set a 60-second client timeout on sends with attachments.** iBubbl answers only once every file is downloaded and stored, which can take up to about 30 seconds for large files. A text-only send still answers at once. ## How the URLs are read * **Each URL must be `https`,** and publicly reachable. iBubbl downloads it once, when the request arrives, and keeps its own copy. A signed or expiring URL only needs to work for a few minutes; once you have the `202`, it can expire. * **Redirects** are followed, up to 3, as long as every hop is `https`. * **The type** comes from the response's `Content-Type` header. When that is `application/octet-stream` (or missing), the URL's file extension decides instead, so `…/report.pdf` works. * **Private and internal addresses are refused,** including `localhost`, private networks and cloud metadata addresses, at every hop. * **The whole request fails if one file does.** Nothing is sent and no message is created, so you can fix the URL and send again with the same `idempotency_key`. * **Downloads have a time limit:** all the files together get about 30 seconds, and a server that stops sending for 10 seconds fails the download. * **The file name your contact sees** is generated by iBubbl; it isn't taken from the URL. ## Supported files and limits | Kind | Types | Largest file | | - | - | - | | Images | JPEG, PNG, WebP, HEIC, HEIF | 25 MiB each | | GIFs | GIF | 25 MiB each | | Video | MP4, MOV (QuickTime) | 50 MiB (52,428,800 bytes) | | PDF | PDF | 10 MB (10,000,000 bytes) | | Audio | MP3, M4A (`audio/mp4`, `audio/x-m4a`), AAC | 25 MiB, sent on its own | **For the whole message:** * **Up to 10 files,** and **86 MB (86,000,000 bytes)** in all. * **Any number of images and GIFs** can go together, within those totals. * **Mixing kinds:** a message with several files that aren't all images can hold images, PDFs and videos (each within its own limit), plus **at most one audio file, and only an MP3 of 2 MB or less**. Other audio (M4A, AAC, or a larger MP3) must be sent on its own, in a message of its own. * **Not supported:** other types, such as calendar invites (`.ics`), WebM or Ogg audio, SVG, Word documents and ZIP files. Every file in a message that passes these rules goes out together with its caption. A combination that wouldn't is refused with `422 attachment_combination_not_supported` rather than sent without its text. Attachments work only for one-to-one sends (`to` or `contact_id`). With `chat_guid` or `participant_phones` the request is refused with `422 validation_error`. **Effects and replies can't be combined with attachments.** A request with `attachments` and `effect` or `reply_to_message_id` is refused with `422 validation_error` ("Effects and replies cannot be combined with attachments"). Send the files and the reply or effect as two messages. ## Captions and the first message `body` is optional when you send `attachments`. If you include it, it's the caption. **The first message to a contact must include text.** Messages can't open a new conversation with files alone, so a send with `attachments` and no `body` to a contact you've never exchanged a message with is refused with `422 first_message_needs_text`. Add a `body`. Once the contact has a message from you (sent, or still waiting to go out) or has sent you one, files without text are fine. ## What your contact sees How the files arrive depends on how the message travels. `channel` on [Retrieve a message](/api-reference/messages/retrieve), and on the `message.*` [webhooks](/webhooks/events#the-channel-field), tells you which way it went once it's sent. * **iMessage:** the caption and the files go out together, as one send: the caption appears first, then the files directly below it, as separate bubbles. A message without a `body` shows just the files. * **SMS/MMS** (the contact isn't on iMessage): * **Up to 5 images, 10 MB in all,** go as one MMS with the caption. One video, or one audio file of 2 MB or less, also goes as one MMS. * **Anything larger or mixed** (more images, a PDF, other audio, or images with a video) goes as the caption first, then each file as a separate message, a couple of seconds apart. Carriers can deliver those messages out of order. * **PDFs can't be sent over plain SMS.** If the contact's phone only takes SMS/MMS, a message with a PDF fails, and you get [`message.failed`](/webhooks/events). * **RCS** (an Android phone with RCS): the caption first, then **one file per message**, a second or two apart and in your order. Your `attachments` array and the `202` are the same whichever way the message goes. ## Idempotency The URLs are part of the [idempotency](/idempotency) payload, **in order**. A retry with the same `idempotency_key` must send the same URLs in the same order; a different URL, a missing one or a new order returns `409 idempotency_conflict`. A retry that replays an accepted message returns the original response **without downloading anything again**, even if the file at the URL has changed since. To send a changed file, use a new key. ## Errors A refused request creates no message, so its `idempotency_key` stays unused. Each attachment error names the file it's about by its position, starting at 0: `attachments[1]: attachment fetch answered 404`. | `detail.error` | HTTP | Cause | What to do | | - | - | - | - | | `attachment_fetch_failed` | 422 | iBubbl couldn't download a URL: not `https`, the host doesn't resolve or is private, a non-`200` answer, more than 3 redirects, an empty file, or the download took too long. | Check the URL works from the public internet, then send again. | | `attachment_too_large` | 422 | A file is over its kind's limit, or the files are over 86 MB in all. | Send a smaller file, or split the files across messages. | | `unsupported_attachment` | 422 | A file's type isn't in the table above. | Convert it to a supported type. | | `attachment_combination_not_supported` | 422 | The files can't go together, for example an M4A with a photo, or two audio files. | Send the audio in a message of its own. | | `first_message_needs_text` | 422 | Files without `body` to a contact you've never exchanged a message with. | Add a `body`. | | `validation_error` | 422 | The request's shape: an empty `attachments` list, more than 10 URLs, something that isn't an `https` URL, `attachments` with `chat_guid` or `participant_phones`, `attachments` with `effect` or `reply_to_message_id`, or neither `body` nor `attachments`. | Fix the request. | | `rate_limited` | 429 | Rare: iBubbl is downloading too many attachment sends at once. Comes with a `Retry-After` header (5 seconds). | Wait `Retry-After` seconds, then send the same request again with the same `idempotency_key`. | The other send errors (opt-outs, limits and so on) work as for any send; see [Errors when sending](/guides/send-messages#errors-when-sending) and the [error catalog](/errors). ## Next > [card] [Send messages](/guides/send-messages) > > Addressing, groups, replies and effects. > [card] [Send a message (reference)](/api-reference/messages/send) > > Every field of `POST /v1/messages`. # Tapbacks, Read Receipts & Typing Indicators Source: https://docs.ibubbl.com/guides/reactions-read-receipts-typing React to messages, send read receipts and show the typing bubble, the way a person on an iPhone would. Three small things make a conversation feel like it's with a person: a ❤️ on what they said, **Read** under their message, and the typing dots while a reply is on its way. iBubbl sends all three from your line through `/v1`: | To | Call | | - | - | | React to a message | [`POST /v1/messages/{message_id}/reactions`](/api-reference/messages/react), and [`DELETE …/reactions/{reaction_id}`](/api-reference/messages/remove-reaction) to take it back | | Send a read receipt | [`POST /v1/conversations/{conversation_id}/read`](/api-reference/conversations/mark-read) | | Show the typing dots | [`POST /v1/conversations/{conversation_id}/typing`](/api-reference/conversations/start-typing), and [`DELETE`](/api-reference/conversations/stop-typing) to stop | `message_id` and `conversation_id` are both on every [`message.inbound`](/webhooks/events) event, so you can act on a message the moment it arrives. ## Tapbacks A tapback is one of the six iMessage reactions: ❤️ `love`, 👍 `like`, 👎 `dislike`, 😂 `laugh`, ‼️ `emphasize` and ❓ `question`. ```bash cURL theme={null} # message_id from the message.inbound event curl -X POST https://api.ibubbl.com/v1/messages/8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b/reactions \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "love"}' ``` ```typescript TypeScript theme={null} const messageId = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b"; // from the message.inbound event const res = await fetch(`https://api.ibubbl.com/v1/messages/${messageId}/reactions`, { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ type: "love" }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.status); // keep data.id to remove it later ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] message_id = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b" # from the message.inbound event res = requests.post( f"https://api.ibubbl.com/v1/messages/{message_id}/reactions", headers={"Authorization": f"Bearer {API_KEY}"}, json={"type": "love"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["status"]) # keep data["id"] to remove it later ``` What to know: * **Only on messages you received.** A tapback on a message you sent is `422 invalid_target`. Custom emoji tapbacks can't be sent. * **One per message.** A different type replaces your previous tapback on that message, as on an iPhone. The same type again answers `200` and sends nothing, so a retry is safe. Tapbacks a teammate added in the dashboard count as yours. * **Accepted, then sent.** The answer is `201` with `status: "pending"`. Your line sends it within seconds, and then: * [`message.reaction.added`](/webhooks/events) fires with `direction: "outbound"` once it's on the contact's phone; * or [`message.reaction.failed`](/webhooks/events) fires with a `failed_reason` if it can't be sent. A tapback not sent within 10 minutes fails as `expired`. * **Not over SMS.** Tapbacks go out over iMessage, and over RCS to Android contacts. If the message came in by SMS, or the conversation is going out by SMS, the answer is `422 sms_not_supported`. When iBubbl can't tell up front, the line refuses it later with `failed_reason: "sms_target"`. * **Same rules as a send.** In a one-to-one conversation, a [blocked](/api-reference/contacts/block) contact gets `422 contact_blocked`, an opted-out one `422 contact_opted_out`. Group conversations aren't checked for blocks or opt-outs. To take a tapback back, [remove it](/api-reference/messages/remove-reaction) with its `id`. `message.reaction.removed` fires when it's gone from the phone. ## Read receipts [Mark a conversation read](/api-reference/conversations/mark-read) marks every message the contact sent you as read, and sends them a read receipt. ```bash cURL theme={null} # conversation_id from the message.inbound event curl -X POST https://api.ibubbl.com/v1/conversations/3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d/read \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const conversationId = "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d"; // from the message.inbound event const res = await fetch(`https://api.ibubbl.com/v1/conversations/${conversationId}/read`, { method: "POST", 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}`); console.log(data.read_receipt, data.read_receipt_skipped_reason); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] conversation_id = "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d" # from the message.inbound event res = requests.post( f"https://api.ibubbl.com/v1/conversations/{conversation_id}/read", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["read_receipt"], data["read_receipt_skipped_reason"]) ``` * **It's for the whole conversation.** Read receipts on iMessage and RCS are per conversation: the contact sees **Read** under their latest message, which covers everything before it. That's why the endpoint takes a `conversation_id`, not a message. * **It also clears the unread badge in the iBubbl dashboard**, for your whole team. Mark a conversation read when it really has been handled, by your code or a person. * **Not in groups.** In a group conversation the messages are marked read in iBubbl, but no receipt is sent (`read_receipt_skipped_reason: "group"`): a receipt there would reach only one participant, in the wrong chat. * **Not to blocked or opted-out contacts**, and not when nothing is new: the answer says `skipped` and why. * **Read receipts are on by default.** To turn them off for your account, email [support@ibubbl.com](mailto:support@ibubbl.com). Don't confuse this with [`message.read`](/webhooks/events): that event is the **contact** reading **your** message. ## Typing indicators [Start typing](/api-reference/conversations/start-typing) shows the contact the three animated dots in a one-to-one iMessage conversation. They last 10 seconds, so call again every 5 seconds to keep them going. Calls closer than 3 seconds apart are answered without doing anything new. The dots go away when you send a message. If you stop calling, or call [Stop typing](/api-reference/conversations/stop-typing), iBubbl stops typing straight away, but the contact's iPhone can keep showing the dots for up to about a minute. So start typing when you're going to reply, not "just in case". Where typing can't be shown, the call answers `200` with `status: "ignored"` and an `ignored_reason`: `group` (group conversations), `not_imessage` (the contact uses SMS or RCS), or `no_chat` (the contact hasn't messaged this line yet). Treat `ignored` as normal, not as an error. A [blocked](/api-reference/contacts/block) or opted-out contact gets `422`. ### Typing while your AI thinks The most common use: a contact texts you, your AI takes a few seconds to write the reply, and meanwhile they see you typing. 1. **On [`message.inbound`](/webhooks/events)**, mark the conversation read and start typing right away, with the event's `conversation_id`. 2. **While your model generates**, call Start typing again every 5 seconds. 3. **Send the reply.** It replaces the dots. Stop the refresh loop. 4. **If you decide not to reply** (or the model fails), stop refreshing and call Stop typing. This builds on the receiver in [Receive messages](/guides/receive-messages#an-auto-reply-that-cannot-loop): verify the signature, de-duplicate on the event `id`, act only on `message.inbound`, and use a deterministic `idempotency_key`. The code below is the work that receiver hands off once those checks pass. ```bash cURL theme={null} # The calls, in order. The ids come from the message.inbound event. API=https://api.ibubbl.com CONVERSATION_ID=3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d INBOUND_ID=5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e # 1. Read receipt, then the typing dots. curl -X POST "$API/v1/conversations/$CONVERSATION_ID/read" -H "Authorization: Bearer $IBUBBL_API_KEY" curl -X POST "$API/v1/conversations/$CONVERSATION_ID/typing" -H "Authorization: Bearer $IBUBBL_API_KEY" # 2. Every 5 seconds while the reply is being written: curl -X POST "$API/v1/conversations/$CONVERSATION_ID/typing" -H "Authorization: Bearer $IBUBBL_API_KEY" # 3. Send the reply (it clears the dots). One key per inbound message, so a # repeated event can never send a second reply. curl -X POST "$API/v1/messages" \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"contact_id\": \"c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f\", \"body\": \"Tuesday at 3 works. See you then!\", \"idempotency_key\": \"ai-reply-$INBOUND_ID\"}" # 4. Only if you decided not to reply: curl -X DELETE "$API/v1/conversations/$CONVERSATION_ID/typing" -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const API = "https://api.ibubbl.com"; const HEADERS = { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }; async function call(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${API}${path}`, { method, headers: HEADERS, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); return data; } // Run for a message.inbound event, after the signature check and // de-duplication. generateReply is your model call. export async function replyWithAI( event: { data: { message_id: string; conversation_id: string; body: string } }, generateReply: (text: string) => Promise, ): Promise { const { message_id: inboundId, conversation_id: conversationId, body } = event.data; const typingPath = `/v1/conversations/${conversationId}/typing`; // Where to reply, and whether it's one-to-one (typing is 1:1 only). const inbound = await call("GET", `/v1/messages/${inboundId}`); if (inbound.conversation_kind === "group") return; // 1. Read receipt, then the dots. Neither may stop the reply. await call("POST", `/v1/conversations/${conversationId}/read`).catch(console.warn); const typing = await call("POST", typingPath).catch(() => null); // 2. Keep the dots going while the model works ("ignored" needs no refresh). const refresh = typing?.status === "started" ? setInterval(() => call("POST", typingPath).catch(() => {}), 5_000) : undefined; let sent = false; try { const reply = await generateReply(body); if (reply) { // 3. Send. The deterministic key makes a repeated event harmless. await call("POST", "/v1/messages", { contact_id: inbound.contact_id, body: reply, idempotency_key: `ai-reply-${inboundId}`, }); sent = true; } } finally { clearInterval(refresh); // 4. No reply after all: stop the dots. if (!sent && typing?.status === "started") { await call("DELETE", typingPath).catch(() => {}); } } } ``` ```python Python theme={null} import os import threading from typing import Callable, Optional import requests API = "https://api.ibubbl.com" API_KEY = os.environ["IBUBBL_API_KEY"] HEADERS = {"Authorization": f"Bearer {API_KEY}"} def call(method: str, path: str, body: Optional[dict] = None) -> dict: res = requests.request(method, f"{API}{path}", headers=HEADERS, json=body, timeout=10) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") return data def reply_with_ai(event: dict, generate_reply: Callable[[str], Optional[str]]) -> None: """Run for a message.inbound event, after the signature check and de-duplication. generate_reply is your model call.""" inbound_id = event["data"]["message_id"] conversation_id = event["data"]["conversation_id"] typing_path = f"/v1/conversations/{conversation_id}/typing" # Where to reply, and whether it's one-to-one (typing is 1:1 only). inbound = call("GET", f"/v1/messages/{inbound_id}") if inbound["conversation_kind"] == "group": return # 1. Read receipt, then the dots. Neither may stop the reply. try: call("POST", f"/v1/conversations/{conversation_id}/read") except Exception as exc: print("read receipt:", exc) try: typing = call("POST", typing_path)["status"] == "started" except Exception: typing = False # 2. Keep the dots going while the model works. done = threading.Event() def refresh() -> None: while not done.wait(5): try: call("POST", typing_path) except Exception: pass if typing: threading.Thread(target=refresh, daemon=True).start() sent = False try: reply = generate_reply(event["data"]["body"]) if reply: # 3. Send. The deterministic key makes a repeated event harmless. call("POST", "/v1/messages", { "contact_id": inbound["contact_id"], "body": reply, "idempotency_key": f"ai-reply-{inbound_id}", }) sent = True finally: done.set() # 4. No reply after all: stop the dots. if typing and not sent: try: call("DELETE", typing_path) except Exception: pass ``` * **Refresh every 5 seconds**, not faster: the dots last 10 seconds, and calls closer than 3 seconds apart do nothing new. 12 calls a minute per conversation keeps you far inside the [rate limit](#rate-limits). * **Your reply still follows the usual rules.** It can wait for the contact's [sending hours](/concepts/working-hours), or be refused (`contact_opted_out`, `unresponsive_contact`, …). The code stops typing whenever the send fails. * **A slow model is fine.** The contact sees typing for as long as you refresh. If generating takes minutes, consider a short holding reply instead. ## What the contact sees | | iMessage (iPhone, iPad, Mac) | RCS (Android) | SMS | | - | - | - | - | | Tapback | The tapback on their message bubble. | Sent as an RCS reaction; how it looks depends on their messaging app. | Refused: `422 sms_not_supported`, or `message.reaction.failed` with `sms_target`. | | Read receipt | **Read** and a time under their latest message. | **Read**, in apps that show RCS read receipts. | Nothing: SMS has no read receipts. | | Typing | The three animated dots. | Nothing: answered `ignored` (`not_imessage`) once iBubbl knows the contact isn't on iMessage. | Nothing, as for RCS. | On iMessage, your read receipt shows even if the contact turned off read receipts for their own messages: that setting only controls what they send. ## Webhooks | Event | Fires when | | - | - | | [`message.inbound`](/webhooks/events) | A contact messages you. Carries the `message_id` and `conversation_id` the three actions take. | | [`message.reaction.added`](/webhooks/events) | Your tapback reached the contact's phone (`direction: "outbound"`), or a contact reacted to one of your messages (`"inbound"`). | | [`message.reaction.removed`](/webhooks/events) | A tapback was removed: you took it back, a newer one of yours replaced it, or the contact took theirs back. | | [`message.reaction.failed`](/webhooks/events) | Your tapback, or its removal, couldn't be sent. `action` says which, `failed_reason` why. | Marking a conversation read and typing fire no events. Every event above carries `conversation_id`. Your own tapbacks are events about your own actions: never send a message because of one (see [An auto-reply that cannot loop](/guides/receive-messages#an-auto-reply-that-cannot-loop)). ## Rate limits The five endpoints share one budget: **300 requests per 60 seconds per account**, across all your API keys. It's separate from the 120 a minute for [list endpoints](/concepts/limits#rate-limits), so typing never slows your history reads, and the other way round. Over the limit, the answer is `429 rate_limited` with a `Retry-After` header in seconds; wait that long, then call again. A refused call doesn't count. At one typing refresh every 5 seconds, that's room for about 20 conversations typing at the same moment, plus the read receipts and tapbacks around them. ## Next * [Add a tapback](/api-reference/messages/react), [Remove a tapback](/api-reference/messages/remove-reaction) * [Mark a conversation read](/api-reference/conversations/mark-read) * [Start typing](/api-reference/conversations/start-typing), [Stop typing](/api-reference/conversations/stop-typing) * [Webhook events](/webhooks/events) # Receive messages Source: https://docs.ibubbl.com/guides/receive-messages Handle replies, group messages, attachments and opt-outs, and build an auto-reply that cannot loop. When a contact texts your line, iBubbl POSTs a [`message.inbound`](/webhooks/events) event to your webhook endpoint. This guide shows what that event contains, how to handle attachments and STOP replies, and a complete auto-reply receiver that's safe to run in production. You need a webhook subscription that includes `message.inbound`. The [Quickstart](/quickstart) creates one, and [Test webhooks locally](/guides/test-webhooks-locally) shows how to point one at your laptop. ## The `message.inbound` event ```json theme={null} { "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "type": "message.inbound", "created_at": "2026-11-10T15:06:12.456Z", "data": { "message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e", "from_number": "+14155551234", "to_number": "+14155550100", "body": "yeah sounds good, what time?", "received_at": "2026-11-10T15:06:12.456Z", "attachments": [], "reply_to_message_id": null, "conversation_kind": "one_to_one", "sender_phone": "+14155551234", "participants": [ {"phone": "+14155551234", "first_name": "Sam", "last_name": "Lee"} ] } } ``` | Field | Meaning | | - | - | | `message_id` | The inbound message's ID. [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) works with it, and returns the `contact_id` and, for a group, the `chat_guid`. | | `from_number` | Who sent it, exactly as iMessage reported it: usually E.164, but it can be an Apple ID email address, or a number without the leading `+` for some SMS senders. Don't pass it straight into `to`. | | `to_number` | Your iBubbl line that received it. | | `conversation_kind` | `one_to_one` or `group`. | | `sender_phone` | The sender. Always equal to `from_number`; in a group it tells you which participant posted. | | `participants` | Everyone in the conversation except your line. `first_name` and `last_name` can be `null`. | | `reply_to_message_id` | Set when the contact quoted one of your messages. | Only messages **from your contacts** fire `message.inbound`. Messages your line sends, whether through the API or the dashboard, fire `message.queued`, `message.sent` and the other status events instead, never `message.inbound`. ### Group messages In a group, `conversation_kind` is `"group"`, `sender_phone` is the participant who posted, and `participants` lists the group. The event doesn't carry the group's `chat_guid`: to reply into the group, call [`GET /v1/messages/{message_id}`](/api-reference/messages/retrieve) with the inbound `message_id` and send with the `chat_guid` it returns. See [Group conversations](/concepts/group-conversations). ### Attachments When the contact sends images, GIFs, videos, audio or files, the event fires **once**, after every attachment has been fetched, and `attachments` lists them: ```json theme={null} { "id": "6c7d8e9f-0a1b-2c3d-4e5f-6a7b8c9d0e1f", "kind": "image", "mime_type": "image/jpeg", "url": "https://…signed download URL…", "expires_at": "2026-11-11T15:06:12.456Z", "size_bytes": 204811, "width": 1170, "height": 2532 } ``` * `url` is a signed link that **expires after 24 hours** (`expires_at`). If you need the file, download it and store it yourself as soon as the event arrives; a retried delivery carries the same URL, so a late retry may already be expired. * An attachment iBubbl couldn't fetch has `"unavailable": true` and no `url`. Don't treat it as an error; the text (if any) is still in `body`. * `kind` is `image`, `gif`, `video`, `audio` or `file`. Unsupported types are left out of the array. ### STOP and opt-outs If the whole message is a STOP keyword (`stop`, `unsubscribe` and the others on [Suppression and opt-outs](/concepts/suppression)), iBubbl records the opt-out **before** it fires `message.inbound`, and fires a separate [`contact.opted_out`](/webhooks/events) event with `source: "auto_keyword"`. You receive **both** events, in either order. * Mark the contact do-not-contact in your system when `contact.opted_out` arrives. * Any send to them after that returns `422 contact_opted_out` (in a group, `422 group_outbound_blocked_suppression`). Treat it as final. ## An auto-reply that cannot loop An auto-reply is where most messaging integrations go wrong: a reply triggers an event, which triggers a reply, and the line sends hundreds of messages. The receiver below avoids that with five rules: 1. **Verify the signature on the raw body**, before parsing it. See [Signature verification](/guides/webhook-signatures). 2. **De-duplicate on the event `id`, and acknowledge at once.** Deliveries are at-least-once; return `200` before doing slow work. See [Retries and deduplication](/guides/webhook-retries). 3. **Act only on `message.inbound`.** Never send because of `message.sent`, `message.delivered`, `message.read`, `message.failed` or any other event about your own messages: that is the classic loop. 4. **Use a deterministic `idempotency_key`**: `"auto-reply-" + message_id`. However many times the same inbound event reaches you, iBubbl sends that reply at most once. 5. **Reply at most once per conversation per cooldown.** If the other side is also a bot, it answers your auto-reply and you answer back. A contact's reply resets iBubbl's [unanswered limit](/concepts/limits), so that limit won't stop a bot-to-bot exchange; your cooldown will. It also resolves where to reply with `GET /v1/messages/{id}`, so an Apple ID email in `from_number` or a group conversation never breaks the send, and it treats every `4xx` as final. ```typescript TypeScript (Express) theme={null} import express from "express"; import { Pool } from "pg"; import { verifySignature } from "./verify-signature"; // the verifier from the Signature verification guide import { isFirstDelivery } from "./webhook-dedup"; // from the Retries and deduplication guide const API = "https://api.ibubbl.com"; const HEADERS = { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }; const AUTO_REPLY = "Thanks for your message! We'll get back to you shortly."; const COOLDOWN_MS = 60 * 60 * 1000; // one auto-reply per conversation per hour const db = new Pool({ connectionString: process.env.DATABASE_URL }); // Per instance, for the example. With several instances, keep it in the database too. const lastAutoReplyAt = new Map(); // conversation_id -> ms const app = express(); // express.raw keeps the exact bytes iBubbl signed; express.json would not. app.post("/ibubbl-webhooks", express.raw({ type: "application/json" }), async (req, res) => { // Node lower-cases header names: req.headers["X-iBubbl-Signature"] is undefined. const signature = req.headers["x-ibubbl-signature"]; if (!verifySignature(req.body, signature, process.env.IBUBBL_WEBHOOK_SECRET!)) { return res.status(401).send("invalid signature"); } const event = JSON.parse(req.body.toString("utf-8")); // At-least-once delivery: a unique constraint in your database lets each // event id through once, across every instance of your app. if (!(await isFirstDelivery(db, event))) return res.sendStatus(200); // Acknowledge now; do the work after the response. res.sendStatus(200); // Only a contact's message may trigger a send. Never react to // message.sent / delivered / read / failed: that loops. if (event.type !== "message.inbound") return; autoReply(event.data.message_id).catch((err) => console.error("auto-reply failed", err)); }); async function autoReply(inboundMessageId: string): Promise { // Where to reply: contact_id for one-to-one, chat_guid for a group. const lookup = await fetch(`${API}/v1/messages/${inboundMessageId}`, { headers: HEADERS, signal: AbortSignal.timeout(10_000), }); const inbound = await lookup.json(); if (!lookup.ok) throw new Error(`lookup ${lookup.status} ${inbound.detail?.error}`); // Cooldown per conversation, claimed before sending. const last = lastAutoReplyAt.get(inbound.conversation_id) ?? 0; if (Date.now() - last < COOLDOWN_MS) return; lastAutoReplyAt.set(inbound.conversation_id, Date.now()); const target = inbound.conversation_kind === "group" ? { chat_guid: inbound.chat_guid } : { contact_id: inbound.contact_id }; // Deterministic key: a repeated event can never send a second reply. const payload = JSON.stringify({ ...target, body: AUTO_REPLY, idempotency_key: `auto-reply-${inboundMessageId}`, }); for (let attempt = 0; attempt < 3; attempt++) { let res: Response; try { res = await fetch(`${API}/v1/messages`, { method: "POST", headers: HEADERS, body: payload, // the same payload and key on every attempt signal: AbortSignal.timeout(10_000), }); } catch { await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); // network error or timeout continue; } if (res.ok) return; // 202: accepted const error = (await res.json().catch(() => ({}))).detail?.error; if (res.status < 500) { // 4xx is final: contact_opted_out, unresponsive_contact, contact_blocked, ... console.warn(`auto-reply not sent: ${res.status} ${error}`); return; } await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); // 5xx: back off, retry } } app.listen(8000); ``` ```python Python (FastAPI) theme={null} import json import os import time import psycopg import requests from fastapi import BackgroundTasks, FastAPI, HTTPException, Request from verify_signature import verify_signature # the verifier from the Signature verification guide from webhook_dedup import is_first_delivery # from the Retries and deduplication guide API = "https://api.ibubbl.com" API_KEY = os.environ["IBUBBL_API_KEY"] HEADERS = {"Authorization": f"Bearer {API_KEY}"} AUTO_REPLY = "Thanks for your message! We'll get back to you shortly." COOLDOWN_SECONDS = 60 * 60 # one auto-reply per conversation per hour db = psycopg.connect(os.environ["DATABASE_URL"]) # Per instance, for the example. With several instances, keep it in the database too. last_auto_reply_at: dict[str, float] = {} # conversation_id -> unix time app = FastAPI() @app.post("/ibubbl-webhooks") async def receive(request: Request, background: BackgroundTasks): # Read the raw bytes iBubbl signed, before any parsing. body = await request.body() # Starlette headers are case-insensitive; any spelling works. signature = request.headers.get("x-ibubbl-signature") if not verify_signature(body, signature, os.environ["IBUBBL_WEBHOOK_SECRET"]): raise HTTPException(status_code=401, detail="invalid signature") event = json.loads(body) # At-least-once delivery: a unique constraint in your database lets each # event id through once, across every instance of your app. if not is_first_delivery(db, event): return {"ok": True} # Only a contact's message may trigger a send. Never react to # message.sent / delivered / read / failed: that loops. if event["type"] == "message.inbound": # Runs after the response is sent, so iBubbl gets its 200 at once. background.add_task(auto_reply, event["data"]["message_id"]) return {"ok": True} def auto_reply(inbound_message_id: str) -> None: # Where to reply: contact_id for one-to-one, chat_guid for a group. lookup = requests.get( f"{API}/v1/messages/{inbound_message_id}", headers=HEADERS, timeout=10, ) inbound = lookup.json() if not lookup.ok: raise RuntimeError(f"lookup {lookup.status_code} {inbound['detail']['error']}") # Cooldown per conversation, claimed before sending. conversation_id = inbound["conversation_id"] if time.time() - last_auto_reply_at.get(conversation_id, 0) < COOLDOWN_SECONDS: return last_auto_reply_at[conversation_id] = time.time() if inbound["conversation_kind"] == "group": target = {"chat_guid": inbound["chat_guid"]} else: target = {"contact_id": inbound["contact_id"]} # Deterministic key: a repeated event can never send a second reply. payload = { **target, "body": AUTO_REPLY, "idempotency_key": f"auto-reply-{inbound_message_id}", } for attempt in range(3): try: # The same payload and key on every attempt. res = requests.post(f"{API}/v1/messages", headers=HEADERS, json=payload, timeout=10) except requests.RequestException: time.sleep(2 ** attempt) # network error or timeout continue if res.ok: return # 202: accepted if res.status_code < 500: # 4xx is final: contact_opted_out, unresponsive_contact, contact_blocked, ... error = res.json().get("detail", {}).get("error") if res.content else None print(f"auto-reply not sent: {res.status_code} {error}") return time.sleep(2 ** attempt) # 5xx: back off, retry ``` A few notes on the design: * **Why both de-duplication and a deterministic key?** The event-`id` check skips work you've already done. The key is the guarantee: even if two instances of your app process the same event at the same moment, iBubbl sends that reply once. The [Idempotency](/idempotency) page explains replays. * **STOP needs no special case.** The opt-out is recorded before `message.inbound` fires, so a reply to a STOP comes back `422 contact_opted_out` and the code stops. * **Working hours still apply.** An auto-reply sent outside the contact's [sending hours](/concepts/working-hours) waits as `pending` until they open. The one thing allowed to answer straight away outside sending hours is the built-in [after-hours auto-reply](/concepts/after-hours-reply) workflow; use it for "we're closed" replies instead of building your own. * **Missed webhooks.** If your endpoint is down for longer than the \~37-minute retry window, those events are gone. [Recover from downtime](/guides/webhook-retries#recover-from-downtime) lists the messages you missed with `GET /v1/messages` and runs them through the same de-duplication. * **Groups.** The sample replies into groups too. Many integrations prefer to auto-reply only in `one_to_one` conversations: return early when `conversation_kind` is `"group"`. ## Next > [card] [Signature verification](/guides/webhook-signatures) > > The `verifySignature` used above, with a test vector. > [card] [Test webhooks locally](/guides/test-webhooks-locally) > > Run this receiver on your laptop with a tunnel. > [card] [Retries and deduplication](/guides/webhook-retries) > > The retry schedule and why events arrive twice or out of order. > [card] [Webhook events](/webhooks/events) > > Every event type and its payload. # Send messages Source: https://docs.ibubbl.com/guides/send-messages Send to a phone number, a saved contact, an existing group or a new group, then track what happened. Every send is one call to [`POST /v1/messages`](/api-reference/messages/send). It returns `202 Accepted` as soon as iBubbl has the message; your line sends it a moment later. This guide covers the four ways to address a message, what the `202` tells you, how to follow the message after that, and the errors that are specific to sending. > [!WARNING] > > **Every request is live.** There is no sandbox: each call below sends a real message from your line. Test with your own phone number. ## Choose who to send to Address each message in **exactly one** of four ways. Sending two of them (or none) returns `422 validation_error`. | Field | Sends | Use it when | | - | - | - | | `to` | One-to-one, by E.164 phone number | You have a number. If no contact has it yet, one is created. | | `contact_id` | One-to-one, to a saved contact | You stored the `contact_id` from an earlier send or from [`POST /v1/contacts`](/api-reference/contacts/upsert). | | `chat_guid` | Into an existing group | You're replying in a group. Webhooks don't carry it: read it from [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) for any message in the group. | | `participant_phones` (+ `group_name`) | A new group, created by this message | You're starting a group with 2 or more people. | Every request also needs `body` and an `idempotency_key`. Generate the key **once per message**, before the first attempt, and send the same key if you retry; see [Idempotency](/idempotency). > [tab] To phone > > `to` must be E.164: `+`, the country code, then the number, with no spaces or punctuation. > > ```bash cURL theme={null} > # One key per message: generate it once, and reuse it if you retry. > IDEMPOTENCY_KEY=$(uuidgen) > > curl -X POST https://api.ibubbl.com/v1/messages \ > -H "Authorization: Bearer $IBUBBL_API_KEY" \ > -H "Content-Type: application/json" \ > -d "{ > \"to\": \"+14155551234\", > \"body\": \"Hi Sam, your order shipped today.\", > \"idempotency_key\": \"$IDEMPOTENCY_KEY\" > }" > ``` > > ```typescript TypeScript theme={null} > import { randomUUID } from "node:crypto"; > > // One key per message: generate it once, and reuse it if you retry. > const idempotencyKey = randomUUID(); > > const res = await fetch("https://api.ibubbl.com/v1/messages", { > method: "POST", > headers: { > Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, > "Content-Type": "application/json", > }, > body: JSON.stringify({ > to: "+14155551234", > body: "Hi Sam, your order shipped today.", > idempotency_key: idempotencyKey, > }), > signal: AbortSignal.timeout(10_000), > }); > > const data = await res.json(); > if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); > console.log(data.id, data.status); > ``` > > ```python Python theme={null} > import os > import uuid > > import requests > > API_KEY = os.environ["IBUBBL_API_KEY"] > > # One key per message: generate it once, and reuse it if you retry. > idempotency_key = str(uuid.uuid4()) > > res = requests.post( > "https://api.ibubbl.com/v1/messages", > headers={"Authorization": f"Bearer {API_KEY}"}, > json={ > "to": "+14155551234", > "body": "Hi Sam, your order shipped today.", > "idempotency_key": idempotency_key, > }, > timeout=10, > ) > data = res.json() > if not res.ok: > raise RuntimeError(f"{res.status_code} {data['detail']['error']}") > print(data["id"], data["status"]) > ``` > [tab] Contact ID > > Use the `contact_id` from an earlier `202` or from [`POST /v1/contacts`](/api-reference/contacts/upsert). An unknown ID returns `404 not_found`. > > ```bash cURL theme={null} > # One key per message: generate it once, and reuse it if you retry. > IDEMPOTENCY_KEY=$(uuidgen) > > curl -X POST https://api.ibubbl.com/v1/messages \ > -H "Authorization: Bearer $IBUBBL_API_KEY" \ > -H "Content-Type: application/json" \ > -d "{ > \"contact_id\": \"c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f\", > \"body\": \"Hi Sam, your order shipped today.\", > \"idempotency_key\": \"$IDEMPOTENCY_KEY\" > }" > ``` > > ```typescript TypeScript theme={null} > import { randomUUID } from "node:crypto"; > > // One key per message: generate it once, and reuse it if you retry. > const idempotencyKey = randomUUID(); > > const res = await fetch("https://api.ibubbl.com/v1/messages", { > method: "POST", > headers: { > Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, > "Content-Type": "application/json", > }, > body: JSON.stringify({ > contact_id: "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", > body: "Hi Sam, your order shipped today.", > idempotency_key: idempotencyKey, > }), > signal: AbortSignal.timeout(10_000), > }); > > const data = await res.json(); > if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); > console.log(data.id, data.status); > ``` > > ```python Python theme={null} > import os > import uuid > > import requests > > API_KEY = os.environ["IBUBBL_API_KEY"] > > # One key per message: generate it once, and reuse it if you retry. > idempotency_key = str(uuid.uuid4()) > > res = requests.post( > "https://api.ibubbl.com/v1/messages", > headers={"Authorization": f"Bearer {API_KEY}"}, > json={ > "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", > "body": "Hi Sam, your order shipped today.", > "idempotency_key": idempotency_key, > }, > timeout=10, > ) > data = res.json() > if not res.ok: > raise RuntimeError(f"{res.status_code} {data['detail']['error']}") > print(data["id"], data["status"]) > ``` > [tab] Chat GUID > > Reply into an existing group. Get `chat_guid` from [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) for any message in the group (an inbound `message_id` from a webhook works). It changes when someone is added to the group, and an old value returns `404 group_not_found`, so read it fresh rather than storing it forever. See [Group conversations](/concepts/group-conversations). > > ```bash cURL theme={null} > # One key per message: generate it once, and reuse it if you retry. > IDEMPOTENCY_KEY=$(uuidgen) > > curl -X POST https://api.ibubbl.com/v1/messages \ > -H "Authorization: Bearer $IBUBBL_API_KEY" \ > -H "Content-Type: application/json" \ > -d "{ > \"chat_guid\": \"any;+;067ace6caf6c43aaa2f7d643975e15ea\", > \"body\": \"Works for everyone? See you at 3.\", > \"idempotency_key\": \"$IDEMPOTENCY_KEY\" > }" > ``` > > ```typescript TypeScript theme={null} > import { randomUUID } from "node:crypto"; > > // One key per message: generate it once, and reuse it if you retry. > const idempotencyKey = randomUUID(); > > const res = await fetch("https://api.ibubbl.com/v1/messages", { > method: "POST", > headers: { > Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, > "Content-Type": "application/json", > }, > body: JSON.stringify({ > chat_guid: "any;+;067ace6caf6c43aaa2f7d643975e15ea", > body: "Works for everyone? See you at 3.", > idempotency_key: idempotencyKey, > }), > signal: AbortSignal.timeout(10_000), > }); > > const data = await res.json(); > if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); > console.log(data.id, data.status); > ``` > > ```python Python theme={null} > import os > import uuid > > import requests > > API_KEY = os.environ["IBUBBL_API_KEY"] > > # One key per message: generate it once, and reuse it if you retry. > idempotency_key = str(uuid.uuid4()) > > res = requests.post( > "https://api.ibubbl.com/v1/messages", > headers={"Authorization": f"Bearer {API_KEY}"}, > json={ > "chat_guid": "any;+;067ace6caf6c43aaa2f7d643975e15ea", > "body": "Works for everyone? See you at 3.", > "idempotency_key": idempotency_key, > }, > timeout=10, > ) > data = res.json() > if not res.ok: > raise RuntimeError(f"{res.status_code} {data['detail']['error']}") > print(data["id"], data["status"]) > ``` > [tab] New group > > List at least 2 people in E.164. Don't include your own line; iBubbl adds it. `group_name` is optional (at most 80 characters) and only allowed here. Fewer than 2 numbers returns `422 validation_error`; two forms of one person's number return `422 insufficient_participants`. > > ```bash cURL theme={null} > # One key per message: generate it once, and reuse it if you retry. > IDEMPOTENCY_KEY=$(uuidgen) > > curl -X POST https://api.ibubbl.com/v1/messages \ > -H "Authorization: Bearer $IBUBBL_API_KEY" \ > -H "Content-Type: application/json" \ > -d "{ > \"participant_phones\": [\"+14155551234\", \"+14155557654\"], > \"group_name\": \"Kitchen remodel\", > \"body\": \"Hi both, starting a thread for the remodel.\", > \"idempotency_key\": \"$IDEMPOTENCY_KEY\" > }" > ``` > > ```typescript TypeScript theme={null} > import { randomUUID } from "node:crypto"; > > // One key per message: generate it once, and reuse it if you retry. > const idempotencyKey = randomUUID(); > > const res = await fetch("https://api.ibubbl.com/v1/messages", { > method: "POST", > headers: { > Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, > "Content-Type": "application/json", > }, > body: JSON.stringify({ > participant_phones: ["+14155551234", "+14155557654"], > group_name: "Kitchen remodel", > body: "Hi both, starting a thread for the remodel.", > idempotency_key: idempotencyKey, > }), > signal: AbortSignal.timeout(10_000), > }); > > const data = await res.json(); > if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); > console.log(data.id, data.status, data.chat_guid); // chat_guid is null at first > ``` > > ```python Python theme={null} > import os > import uuid > > import requests > > API_KEY = os.environ["IBUBBL_API_KEY"] > > # One key per message: generate it once, and reuse it if you retry. > idempotency_key = str(uuid.uuid4()) > > res = requests.post( > "https://api.ibubbl.com/v1/messages", > headers={"Authorization": f"Bearer {API_KEY}"}, > json={ > "participant_phones": ["+14155551234", "+14155557654"], > "group_name": "Kitchen remodel", > "body": "Hi both, starting a thread for the remodel.", > "idempotency_key": idempotency_key, > }, > timeout=10, > ) > data = res.json() > if not res.ok: > raise RuntimeError(f"{res.status_code} {data['detail']['error']}") > print(data["id"], data["status"], data["chat_guid"]) # chat_guid is None at first > ``` > > The new group's `chat_guid` is `null` in the `202`. It fills in once the group exists in iMessage, which happens when the first message goes out; poll [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) to read it. > > > [!WARNING] > > > > **Don't auto-retry a timed-out new-group send.** If the request times out, a retry with the same key can create a second group. Check with [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) or the dashboard first. See [Duplicates and retries](/concepts/message-lifecycle#duplicates-and-retries). ## Reply to a message and send with effects Two optional fields make a send look like one typed in Messages: * `reply_to_message_id` sends the message as an **inline reply** that quotes an earlier message. Pass a message `id`, or the `message_id` from a [`message.inbound`](/webhooks/events) webhook to reply to something the contact sent. * `effect` adds an **iMessage effect**, such as confetti or a slam. The values are in the table below. Use either or both, with `to`, `contact_id` or `chat_guid`. Neither can be combined with `attachments` (`422 validation_error`): send the files and the reply or effect as two messages. This example replies to an inbound message with confetti: ```bash cURL theme={null} # One key per message: generate it once, and reuse it if you retry. IDEMPOTENCY_KEY=$(uuidgen) # The message_id from the message.inbound webhook you're replying to. REPLY_TO="5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c" curl -X POST https://api.ibubbl.com/v1/messages \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"to\": \"+14155551234\", \"body\": \"Congrats on the new place, Sam!\", \"reply_to_message_id\": \"$REPLY_TO\", \"effect\": \"confetti\", \"idempotency_key\": \"$IDEMPOTENCY_KEY\" }" ``` ```typescript TypeScript theme={null} import { randomUUID } from "node:crypto"; // One key per message: generate it once, and reuse it if you retry. const idempotencyKey = randomUUID(); // The message_id from the message.inbound webhook you're replying to. const replyTo = "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c"; const res = await fetch("https://api.ibubbl.com/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+14155551234", body: "Congrats on the new place, Sam!", reply_to_message_id: replyTo, effect: "confetti", idempotency_key: idempotencyKey, }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.status, data.effect, data.reply_to_message_id); ``` ```python Python theme={null} import os import uuid import requests API_KEY = os.environ["IBUBBL_API_KEY"] # One key per message: generate it once, and reuse it if you retry. idempotency_key = str(uuid.uuid4()) # The message_id from the message.inbound webhook you're replying to. reply_to = "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c" res = requests.post( "https://api.ibubbl.com/v1/messages", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "to": "+14155551234", "body": "Congrats on the new place, Sam!", "reply_to_message_id": reply_to, "effect": "confetti", "idempotency_key": idempotency_key, }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["status"], data["effect"], data["reply_to_message_id"]) ``` The `202` response echoes both fields; they are `null` on a send without them. ### Effects Send the value in the `effect` column. Values are lowercase and exact: anything else returns `422 validation_error`. | `effect` | In Messages | Kind | | - | - | - | | `slam` | Slam | Bubble | | `loud` | Loud | Bubble | | `gentle` | Gentle | Bubble | | `invisible` | Invisible Ink | Bubble | | `echo` | Echo | Screen | | `spotlight` | Spotlight | Screen | | `balloons` | Balloons | Screen | | `confetti` | Confetti | Screen | | `love` | Love | Screen | | `lasers` | Lasers | Screen | | `fireworks` | Fireworks | Screen | | `shooting_star` | Shooting Star | Screen | | `celebration` | Celebration | Screen | A bubble effect animates the message bubble; a screen effect fills the recipient's screen. > [!WARNING] > > **iMessage only.** If the message goes out as SMS (RCS included), it is sent as plain text: the quote and the effect are dropped, and the API doesn't refuse the send. The `message.sent` webhook carries `channel` (`imessage` or `sms`), so you can see which way it went; [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) returns it too. See [Lines and routing](/concepts/lines-and-routing). > [!NOTE] > > **The reply must be in the same conversation.** For a `to` or `contact_id` send, `reply_to_message_id` must be a message in your one-to-one conversation with that contact; for a `chat_guid` send, a message in that group. Anything else, including an ID from another account, returns `422 reply_target_not_found`. A message the contact unsent returns `422 reply_target_unsent`, and one they unsent part of returns `422 reply_target_partly_unsent`. > [!NOTE] > > **Not when creating a group.** Neither field is allowed with `participant_phones` (`422 validation_error`): a new group has nothing to reply to. Send them on a later message to the group's `chat_guid`. > [!NOTE] > > **Both fields are part of the idempotency payload when you set them.** A retry with the same `idempotency_key` must send the same `effect` and `reply_to_message_id`, or it returns `409 idempotency_conflict`. See [Idempotency](/idempotency). ## Send files Add `attachments`, a list of up to 10 `https` URLs, to send photos, GIFs, videos, PDFs or audio to one contact. `body` becomes optional and, when set, is the caption; the first message to a contact still needs a `body`. iBubbl downloads the files before it answers, so allow 60 seconds. [Send attachments](/guides/attachments) has the examples, the types and limits, and how each route delivers them. ## Read the `202` response ```json theme={null} { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "status": "pending", "to": "+14155551234", "body": "Hi Sam, your order shipped today.", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": null, "sent_at": "2026-11-10T15:04:10.112Z", "held_until": null, "held_reason": null, "effect": null, "reply_to_message_id": null, "attachments": [] } ``` `202` means **accepted, not sent**. Save the `id`. `status` is one of: | `status` | `held_reason` | What it means | | - | - | - | | `pending` | `null` | On its way: your line sends it shortly. | | `pending` | `working_hours` | Waiting for the contact's [sending hours](/concepts/working-hours). `held_until` says when it goes out. | | `pending` | `hourly_limit` | Waiting for the line's hourly limit on new contacts. `held_until` is an estimate. See [Limits and queues](/concepts/limits). | | `queued` | `null` | One-to-one only: the line's Mac is offline. It goes out when the Mac reconnects, or expires after about an hour, which fires `message.failed` with `status: "expired"`. See [Offline lines](/concepts/limits#offline-lines). | A waiting message is not an error, so don't resend it: a new key would send a second message once the first goes out. For a group send, `to` is `null` and `conversation_kind` is `"group"`. `channel` stays `null` until the message is sent. ## Track the message You have two ways to learn what happened next: * **Webhooks (recommended).** Subscribe to `message.sent`, `message.delivered`, `message.read`, `message.failed` and `message.cancelled`; each carries the `message_id` from the `202`, and `channel` once the message has gone out. See [Webhook events](/webhooks/events) and [Receive messages](/guides/receive-messages). * **Polling.** [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) returns the current `status`, `channel` (`imessage` or `sms`) and `held_*` fields. Use both where it matters. A few changes fire no webhook, such as a `queued` message moving on to `pending`. [Message lifecycle](/concepts/message-lifecycle) has every status, which ones are final, and which webhook each fires. ## Errors when sending A refused send creates no message, so its `idempotency_key` stays unused and you can reuse it once you've fixed the request. Branch on `detail.error`, never on `detail.message`. The [Errors](/errors) page has the full catalog and the retry rules. | `detail.error` | HTTP | What to do | | - | - | - | | `validation_error` | 422 | Fix the request: zero or two addressing fields, a number not in E.164, an empty `body`, `group_name` without `participant_phones`, fewer than 2 `participant_phones`, an unknown `effect`, a `reply_to_message_id` that isn't a UUID, either of those with `participant_phones` or `attachments`, or `attachments` that aren't 1–10 `https` URLs or are sent to a group. | | `not_found` | 404 | The `contact_id` doesn't exist on this account. | | `group_not_found` | 404 | No group with that `chat_guid`. It may have changed when someone joined: read it again from `GET /v1/messages/{id}`. | | `idempotency_conflict` | 409 | You reused a key with a different payload. Send the original payload, or use a new key for a new message. | | `contact_opted_out` | 422 | Final. Never message this contact again. See [Suppression](/concepts/suppression). | | `contact_blocked` | 422 | The contact is blocked in your dashboard. | | `unresponsive_contact` | 422 | 6 messages in a row without a reply. Wait until the contact replies. | | `cold_cap_exhausted` | 422 | The line reached 50 new contacts today. Try again after midnight ET. | | `daily_cap_exhausted` | 422 | The line reached 1,000 messages today. Try again after midnight ET. | | `no_eligible_line` | 422 | No line can take a new contact right now (for example, its Mac is offline). | | `group_outbound_blocked_suppression` | 422 | Someone in the group has opted out; a new-group send also lists them in `blocked_phones`. | | `insufficient_participants` | 422 | The new group names fewer than 2 different people. | | `reply_target_not_found` | 422 | `reply_to_message_id` isn't a message in this conversation. Check the ID, and that you're sending to the same contact or group. | | `reply_target_unsent` / `reply_target_partly_unsent` | 422 | The contact unsent the message (or part of it). Send without `reply_to_message_id`. | | `first_message_needs_text`, `attachment_fetch_failed`, `attachment_too_large`, `unsupported_attachment`, `attachment_combination_not_supported` | 422 | A problem with `attachments`. See [Send attachments](/guides/attachments#errors). | | `internal_error` | 500 | Retry with the **same** `idempotency_key` and payload, with backoff. | `401`, `403` and `500 no_owner_rep` are account problems, not send problems; see [Errors](/errors). ## Next > [card] [Receive messages](/guides/receive-messages) > > Handle replies, attachments and opt-outs, and auto-reply without loops. > [card] [Message lifecycle](/concepts/message-lifecycle) > > Every status, which ones are final and which webhook fires. > [card] [Limits and queues](/concepts/limits) > > The per-line limits, and how to pace sends. > [card] [Idempotency](/idempotency) > > Retry safely without sending twice. # Test webhooks locally Source: https://docs.ibubbl.com/guides/test-webhooks-locally Receive real iBubbl events on your laptop through a tunnel, and watch every delivery in the dashboard. iBubbl only delivers webhooks to public `https://` URLs, so to receive them on your laptop you expose a local server through a tunnel. You then trigger real events from your own phone and watch each delivery attempt in the dashboard. > [!NOTE] > > **There is no "send test event" button or endpoint.** The only events are real ones: a message to or from your line. This guide uses your own phone to create them. To test your signature code without any events, use the [test vector](/guides/webhook-signatures#test-vector). ## 1. Run a receiver on port 8000 Any server that verifies the signature, logs the body and returns `200` will do. The receiver in [Receive messages](/guides/receive-messages) listens on port 8000. Start it with an empty `IBUBBL_WEBHOOK_SECRET` for now; you'll get the real secret in step 3. ## 2. Open a tunnel Pick one. Each prints a public HTTPS URL that forwards to `http://localhost:8000`. ```bash cloudflared theme={null} # No account needed. Prints https://.trycloudflare.com cloudflared tunnel --url http://localhost:8000 ``` ```bash ngrok theme={null} # Needs a free ngrok account and authtoken. Prints a Forwarding https://… URL. ngrok http 8000 ``` Leave the tunnel running. A quick tunnel gets a **new URL every time it starts**, and webhook subscriptions can't be edited, so after a restart you create a new subscription (step 3) and revoke the old one (step 6). ## 3. Register the tunnel URL Create a subscription for the tunnel's URL plus your route's path. Leave out `event_types` to receive every event while you test. ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/webhooks \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/ibubbl-webhooks"}' ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.ibubbl.com/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://your-tunnel.trycloudflare.com/ibubbl-webhooks" }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.secret); // the secret is shown only now ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] res = requests.post( "https://api.ibubbl.com/v1/webhooks", headers={"Authorization": f"Bearer {API_KEY}"}, json={"url": "https://your-tunnel.trycloudflare.com/ibubbl-webhooks"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["secret"]) # the secret is shown only now ``` The response includes the subscription `id` and its signing `secret` (`whsec_…`), shown only this once. Restart your receiver with `IBUBBL_WEBHOOK_SECRET` set to that secret. A URL that doesn't start with `https://` returns `422 invalid_url`. You can also add the endpoint in the dashboard under **Settings → Webhooks → Add endpoint**; it's the same subscription either way. ## 4. Trigger events from your phone * **Inbound:** text your iBubbl line from your own phone. Your receiver gets `message.inbound`. * **Outbound:** send a message to your own phone with [`POST /v1/messages`](/guides/send-messages). You get `message.queued`, then `message.sent`, then `message.delivered` and `message.read` if your phone reports them. Reply to it to get another `message.inbound`. * **Reactions and edits:** tap back on, or edit, a message in that thread for `message.reaction.added` and `message.edited`. The first delivery usually arrives within about 30 seconds of the event. > [!WARNING] > > **Don't text STOP (or `unsubscribe`, `cancel`, `end`, `quit`, `stopall`) to test opt-outs.** It opts your own number out for good: an opt-out can't be undone from the API or the dashboard. See [Suppression and opt-outs](/concepts/suppression). > [!WARNING] > > **Test events are real events.** Every active subscription on your account whose filter matches receives them, including production ones. If production has an auto-reply, your test texts will get one too. ## 5. Watch the deliveries In the [dashboard](https://ibubbl-dashboard.vercel.app), open **Settings → Webhooks**. **Recent deliveries** lists every attempt to reach your endpoints, newest first, across all your subscriptions: | Column | What it shows | | - | - | | Status | **Delivered** (your endpoint returned `2xx`), **Pending** (waiting for its next retry) or **Dead** (5 attempts failed; no more retries). | | Event | The event type, such as `message.inbound`. | | Code | The HTTP status your endpoint returned on the last attempt. `0` means no response: a timeout after 10 seconds, or the connection failed. Hover over the code to see the error. | | Attempt | How many attempts so far, out of 5. | | Endpoint | The subscription's URL. | | Time | When it was delivered, or when it was last tried. | What common codes mean while testing: | Code | Usual cause | | - | - | | `0` | The tunnel isn't running, or your handler took longer than 10 seconds. | | `401` | Your signature check failed: check the raw body, the secret from step 3 and the header name's case. See [Signature verification](/guides/webhook-signatures). | | `404` | The path in the subscription URL doesn't match your route. | | `3xx` | Redirects aren't followed and count as failures; register the final URL. | | `502` or `530` | The tunnel is up but nothing is listening on port 8000. | A failed delivery is retried for about 37 minutes; see [Retries and deduplication](/guides/webhook-retries). Fix your receiver and the next retry gets through. A delivery that went dead isn't resent. ## 6. Clean up When you're done, revoke the test subscription. Revoking also stops any retries still pending for it. If you don't have its `id`, [`GET /v1/webhooks`](/api-reference/webhooks/list) lists your subscriptions. ```bash cURL theme={null} curl -X DELETE https://api.ibubbl.com/v1/webhooks/4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const subscriptionId = "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d"; const res = await fetch(`https://api.ibubbl.com/v1/webhooks/${subscriptionId}`, { method: "DELETE", 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}`); console.log(data.status); // "revoked" or "already_revoked" ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] subscription_id = "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d" res = requests.delete( f"https://api.ibubbl.com/v1/webhooks/{subscription_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["status"]) # "revoked" or "already_revoked" ``` Then stop the tunnel. Its URL stops working, so a forgotten subscription would only fail; revoking keeps your delivery log clean. ## Next > [card] [Retries and deduplication](/guides/webhook-retries) > > What happens when your endpoint fails. > [card] [Webhook events](/webhooks/events) > > Every event type and its payload. # Retries and deduplication Source: https://docs.ibubbl.com/guides/webhook-retries When iBubbl retries a webhook, why the same event can arrive twice or out of order, and how to de-duplicate. iBubbl delivers every webhook **at least once**. If your endpoint fails, the event is retried on a fixed schedule, so your handler has to cope with the same event arriving more than once, and with events arriving out of order. ## What counts as a failure A delivery succeeds only when your endpoint returns a **`2xx` within 10 seconds**. Everything else is a failure and gets retried: * any `4xx` or `5xx`, including a `401` from your own signature check; * any `3xx`: redirects are **not** followed, so register the final URL; * no response within 10 seconds, or a connection that can't be made (shown as code `0` in the dashboard). ## Retry schedule | Attempt | When | | - | - | | 1 | Within about 30 seconds of the event | | 2 | 30 seconds after attempt 1 fails | | 3 | 1 minute after attempt 2 fails | | 4 | 5 minutes after attempt 3 fails | | 5 | 30 minutes after attempt 4 fails | After the 5th failed attempt the delivery is marked **dead** and never retried. From the first attempt to the last is about **37 minutes**. iBubbl sends deliveries in batches every 30 seconds, so each attempt can arrive up to 30 seconds after the time above, and a burst of events can add more delay. Don't build logic on exact timings. Every attempt carries the **same body** and the same event `id`, but a **new signature timestamp**: each attempt is signed when it is sent, so a retry always passes the 5-minute [replay check](/guides/webhook-signatures#replay-protection). ### What iBubbl does not do * **No auto-disable.** However many deliveries fail, your subscription stays active, and new events keep trying. Only you can turn it off, by [revoking it](/api-reference/webhooks/revoke). * **No replay.** A dead delivery isn't resent, and there's no endpoint to resend it. To catch up after an outage, list what you missed with [`GET /v1/messages`](/api-reference/messages/list): see [Recover from downtime](#recover-from-downtime). * **No delivery to revoked or later subscriptions.** Revoking a subscription stops its pending retries. A subscription you create after an event doesn't receive that event. ### Watching deliveries The dashboard's **Settings → Webhooks → Recent deliveries** shows every attempt: status (Delivered, Pending or Dead), event type, your response code, the attempt number out of 5, and the time. Delivery records are kept for **30 days**. [Test webhooks locally](/guides/test-webhooks-locally) explains each column. ## Return `2xx` fast The 10-second timeout is firm. A slow handler fails even when it eventually succeeds, and then gets the same event again. Verify the signature, record the event, return `2xx`, and do the real work afterwards in a queue or background task. ## At least once: de-duplicate The same event can reach you more than once: * your endpoint processed it but the response was lost, timed out or wasn't `2xx`; * you have two subscriptions that both match the event type: each gets its own delivery, with the **same** event `id`; * in rare cases iBubbl records the same change twice as two events (for example, the `message.sent` for a new group's first message). **De-duplicate on the envelope `id`** (`evt_…`), which is identical on every retry and across subscriptions. For the rare double-recorded change, also de-duplicate on **`data.message_id` plus `type`**. Don't de-duplicate on body content: different events can have identical payloads. ```typescript TypeScript theme={null} // A unique constraint does the de-duplication atomically, across every // instance of your app. Example table: // CREATE TABLE processed_webhook_events ( // event_id text PRIMARY KEY, // message_key text UNIQUE -- data.message_id + ':' + type, when present // ); import type { Pool } from "pg"; export async function isFirstDelivery(db: Pool, event: { id: string; type: string; data: { message_id?: string } }) { const messageKey = event.data.message_id ? `${event.data.message_id}:${event.type}` : null; const result = await db.query( "INSERT INTO processed_webhook_events (event_id, message_key) VALUES ($1, $2) ON CONFLICT DO NOTHING", [event.id, messageKey], ); return result.rowCount === 1; // false: already seen, return 2xx and stop } ``` ```python Python theme={null} # A unique constraint does the de-duplication atomically, across every # instance of your app. Example table: # CREATE TABLE processed_webhook_events ( # event_id text PRIMARY KEY, # message_key text UNIQUE -- data.message_id + ':' + type, when present # ); def is_first_delivery(conn, event: dict) -> bool: message_id = event["data"].get("message_id") message_key = f"{message_id}:{event['type']}" if message_id else None with conn.cursor() as cur: cur.execute( "INSERT INTO processed_webhook_events (event_id, message_key) " "VALUES (%s, %s) ON CONFLICT DO NOTHING", (event["id"], message_key), ) first = cur.rowcount == 1 # False: already seen, return 2xx and stop conn.commit() return first ``` Return `2xx` for a duplicate too: an error would only make iBubbl retry it again. > [!NOTE] > > The `message_key` check drops a second event of the same `type` for the same message. That's right for every `message.*` status event. For `message.edited`, a contact can edit one message up to five times, each a real, separate event, so leave `message_key` empty for edits. Reaction events carry `reaction_id` rather than `message_id`, so the event `id` alone covers them. ## Out of order: don't trust arrival order Events can arrive in a different order than they happened: a retry of an older event can land after a newer one, and events in the same batch aren't sent in a fixed order. For example, `message.read` can arrive before `message.delivered`, and `contact.opted_out` before or after the `message.inbound` that triggered it. * Order events by the envelope's `created_at`, not by when they arrived. * Don't let a late event move a message backwards in your store: once you've recorded `read`, ignore a later-arriving `delivered`. A message can genuinely move from `sent` to `failed`, though; see [Statuses that move backwards](/concepts/message-lifecycle#statuses-that-move-backwards). * When in doubt, [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) returns the current state. ## Recover from downtime After about 37 minutes of failures a delivery is dead, and iBubbl never resends it. If your endpoint was down longer than that, or you shipped a handler with a bug, webhooks alone won't tell you what you missed. [List messages](/api-reference/messages/list) will: it returns every message, inbound and outbound, with its current status. The recipe: keep a **checkpoint**, the newest `created_at` you've processed. To catch up, list everything created since a little before the checkpoint, handle what you haven't seen, update what changed, then move the checkpoint forward. 1. **Start a few minutes early.** Messages arriving at the same moment can become visible in either order, so one with a slightly older `created_at` can show up after you've read a newer one. Starting 5 minutes before the checkpoint covers that; the overlap is harmless because of step 2. 2. **De-duplicate by message.** A message can reach you here and also as a late webhook retry. The samples pass each inbound message through the same [`isFirstDelivery`](#at-least-once-de-duplicate) check as the webhook handler, with the same `message_key` (`:message.inbound`), so whichever arrives second is skipped. 3. **Overwrite the status.** The list returns each message's *current* status, so unlike a webhook it can't be out of date: store it as it is. 4. **Save the checkpoint only after the last page.** If a run fails part-way, the next one starts from the old checkpoint, and step 2 makes the repeat safe. ```typescript TypeScript theme={null} import { listMessages } from "./list-messages"; // the loop from the Pagination page import { isFirstDelivery } from "./webhook-dedup"; // from the section above import { handleInbound } from "./handlers"; // what your message.inbound handler does // Your own storage; replace these with your database calls. import { db, getCheckpoint, saveCheckpoint, findMessage, saveMessage } from "./store"; const OVERLAP_MS = 5 * 60 * 1000; export async function reconcile(): Promise { // e.g. "2026-11-10T15:04:10.112730Z"; on the first run, look back 24 hours const checkpoint = await getCheckpoint(); const from = checkpoint ? Date.parse(checkpoint) : Date.now() - 24 * 60 * 60 * 1000; const since = new Date(from - OVERLAP_MS).toISOString(); let newest = checkpoint; for await (const message of listMessages({ created_after: since })) { const known = await findMessage(message.id); if (!known || known.status !== message.status) { await saveMessage(message); // new, or its status changed while you were away } if (message.direction === "inbound") { const first = await isFirstDelivery(db, { id: `reconcile:${message.id}`, type: "message.inbound", data: { message_id: message.id }, }); if (first) await handleInbound(message); } if (!newest || message.created_at > newest) newest = message.created_at; } if (newest) await saveCheckpoint(newest); } ``` ```python Python theme={null} from datetime import datetime, timedelta, timezone from handlers import handle_inbound # what your message.inbound handler does from list_messages import list_messages # the loop from the Pagination page from webhook_dedup import is_first_delivery # from the section above # Your own storage; replace these with your database calls. from store import conn, find_message, get_checkpoint, save_checkpoint, save_message OVERLAP = timedelta(minutes=5) def reconcile(): # e.g. "2026-11-10T15:04:10.112730Z"; on the first run, look back 24 hours checkpoint = get_checkpoint() if checkpoint: start = datetime.fromisoformat(checkpoint.replace("Z", "+00:00")) else: start = datetime.now(timezone.utc) - timedelta(hours=24) since = (start - OVERLAP).strftime("%Y-%m-%dT%H:%M:%S.%fZ") newest = checkpoint for message in list_messages(created_after=since): known = find_message(message["id"]) if known is None or known["status"] != message["status"]: save_message(message) # new, or its status changed while you were away if message["direction"] == "inbound": first = is_first_delivery(conn, { "id": f"reconcile:{message['id']}", "type": "message.inbound", "data": {"message_id": message["id"]}, }) if first: handle_inbound(message) if newest is None or message["created_at"] > newest: newest = message["created_at"] if newest: save_checkpoint(newest) ``` The `reconcile:` prefix keeps the made-up event `id` apart from real `evt_…` IDs; the `message_key` is what matches a webhook for the same message. Run it when your endpoint comes back from an outage, or on a schedule (every 15 minutes, say) as a safety net. `created_at` values are fixed-width UTC strings ending in `Z`, so comparing them as text, as the samples do, gives the right order. Two limits to know: * **It finds messages *created* since the checkpoint.** A message created earlier whose status changed during the outage (yesterday's `sent` that became `delivered`) isn't in that window. If you need those, look back further with `created_after`, or check the messages you still hold as `queued`, `pending` or `sent` with [`GET /v1/messages/{id}`](/api-reference/messages/retrieve). * **Missed inbound messages can be hours old.** Before an automated reply to one, check its `created_at`: an answer hours after the contact wrote can be worse than none. ## Next > [card] [Receive messages](/guides/receive-messages) > > A complete receiver, with de-duplication and a safe auto-reply. > [card] [Webhook events](/webhooks/events) > > Every event type and its payload. > [card] [Pagination](/concepts/pagination) > > The `listMessages` loop the recovery recipe uses. # Signature verification Source: https://docs.ibubbl.com/guides/webhook-signatures Verify that a webhook came from iBubbl: HMAC-SHA256 over the timestamp and raw body, with reference verifiers, framework wiring and a test vector. Every webhook iBubbl POSTs to your URL carries a signature in the `X-iBubbl-Signature` header. Verify it before you trust the body: it proves the request came from iBubbl, and its timestamp limits how long a captured request can be replayed against your endpoint. ## Header format ```http theme={null} X-iBubbl-Signature: t=,v1=<64 hex chars> ``` Example (from the [test vector](#test-vector) below): ```http theme={null} X-iBubbl-Signature: t=1762800000,v1=86a36838f7b5f62cda7ef2388b3f3c92de119dd521d95db76006504065bc0cff ``` `t` is the Unix time, in seconds, when iBubbl signed the request. `v1` is the HMAC-SHA256 described below. The `v1` label leaves room for a future scheme (`v2`) without a new header name. > [!WARNING] > > **Header names are case-insensitive, and you'll usually see this one in lower case.** HTTP/2, proxies and most frameworks deliver it as `x-ibubbl-signature`. Read it case-insensitively: > > * **Node / Express:** `req.headers['x-ibubbl-signature']`. Node lower-cases every header name, so `req.headers['X-iBubbl-Signature']` is **`undefined`**, and a verifier given `undefined` rejects every request. (`req.get()` and `req.header()` in Express match any case.) > * **Next.js / Fetch API:** `request.headers.get('x-ibubbl-signature')` (`Headers.get` matches any case). > * **FastAPI / Starlette:** `request.headers.get('x-ibubbl-signature')` (matches any case). ## Algorithm * Function: **HMAC-SHA256**, output as lowercase hex. * Key: the **literal UTF-8 bytes of your whole subscription secret**, the `whsec_…` string, prefix included. Do **not** hex-decode it. * Message: the `t` value, a literal `.`, then the **raw request body bytes**: `{t}.{raw body}`. ### What iBubbl signs The body is the exact bytes iBubbl sent: compact JSON (no spaces), keys sorted, and every non-ASCII character escaped as `\uXXXX`, so an emoji 👍 arrives as `\ud83d\udc4d`. Don't rely on that format: verify the bytes you received, and never re-serialize them. `JSON.stringify(JSON.parse(body))` turns `\ud83d\udc4d` back into 👍 and the signature no longer matches. If a delivery is retried, the retry carries the same body and the same event `id`, but a **new `t` and a new signature**: each attempt is signed when it is sent. See [Retries and deduplication](/guides/webhook-retries). ## Replay protection Reject the request if `|now − t| > 300` seconds (5 minutes). Each attempt is signed fresh, so a retry is never rejected just because the original event is old. Five minutes covers normal clock drift on NTP-synced servers. If your clock can drift further, fix the clock rather than widening the window. ## Reference verifiers Copy one of these. Both take the raw body bytes, the header value (or `None`/`undefined` if it's missing) and your secret, and return `false` for a missing, malformed, stale or wrong signature instead of throwing, so a bad request can't crash your handler. The optional last argument is the current time in Unix seconds; leave it out in production, and pass it only to run the [test vector](#test-vector). ```python Python theme={null} # verify_signature.py import hashlib import hmac import time REPLAY_TOLERANCE_SECONDS = 300 def verify_signature(body_bytes: bytes, signature_header, secret: str, now=None) -> bool: """ body_bytes: the raw HTTP request body as received. signature_header: the X-iBubbl-Signature header value (None if missing). secret: your subscription's whsec_ secret. now: current Unix time in seconds; leave as None except in tests. Returns False for any missing, malformed, stale or wrong signature. """ try: parts = dict(p.split("=", 1) for p in (signature_header or "").split(",")) ts = int(parts["t"]) received = parts["v1"].encode("ascii") except (KeyError, ValueError): return False # Reject replays. now = time.time() if now is None else now if abs(now - ts) > REPLAY_TOLERANCE_SECONDS: return False signed = f"{ts}.".encode("utf-8") + body_bytes expected = hmac.new( secret.encode("utf-8"), signed, hashlib.sha256, ).hexdigest().encode("ascii") return hmac.compare_digest(expected, received) ``` ```javascript Node theme={null} // verify-signature.js const crypto = require('crypto'); const REPLAY_TOLERANCE_SECONDS = 300; function verifySignature(bodyBytes, signatureHeader, secret, nowSeconds = Date.now() / 1000) { // bodyBytes: Buffer (the raw request body as received) // signatureHeader: the X-iBubbl-Signature header value (undefined/null if missing) // secret: your subscription's whsec_ secret // nowSeconds: current Unix time in seconds; leave it out except in tests // Returns false for any missing, malformed, stale or wrong signature. if (typeof signatureHeader !== 'string') return false; const parts = {}; for (const part of signatureHeader.split(',')) { const i = part.indexOf('='); if (i > 0) parts[part.slice(0, i)] = part.slice(i + 1); } const ts = Number.parseInt(parts.t, 10); const received = parts.v1; if (!Number.isFinite(ts) || !/^[0-9a-f]{64}$/i.test(received || '')) return false; // Reject replays. if (Math.abs(nowSeconds - ts) > REPLAY_TOLERANCE_SECONDS) return false; const signed = Buffer.concat([Buffer.from(`${ts}.`, 'utf-8'), bodyBytes]); const expected = crypto.createHmac('sha256', secret).update(signed).digest(); // Both are 32 bytes (checked above), so timingSafeEqual can't throw. return crypto.timingSafeEqual(expected, Buffer.from(received, 'hex')); } module.exports = { verifySignature }; ``` ## Test vector Use this to check your verifier without a webhook, a tunnel or a live secret. It was produced by iBubbl's own signer. The envelope is cut down (real events carry more fields, and `created_at` is an ISO 8601 string), but the bytes follow the same rules as every real delivery: compact, sorted keys, `\u` escapes. | Input | Value | | - | - | | Secret | `whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` | | `t` | `1762800000` | | Raw body (159 bytes, one line) | `{"created_at":1762800000,"data":{"body":"Hello! \ud83d\udc4d","message_id":"00000000-0000-0000-0000-000000000001"},"id":"evt_test123","type":"message.inbound"}` | | Expected `v1` | `86a36838f7b5f62cda7ef2388b3f3c92de119dd521d95db76006504065bc0cff` | | Header | `t=1762800000,v1=86a36838f7b5f62cda7ef2388b3f3c92de119dd521d95db76006504065bc0cff` | The `\ud83d\udc4d` in the body is twelve literal characters (backslash, `u`, four hex digits, twice), not an emoji. Pass the time `t` as "now", because the vector's timestamp is long past the 5-minute window: ```python Python theme={null} from verify_signature import verify_signature SECRET = "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" T = 1762800000 BODY = rb'{"created_at":1762800000,"data":{"body":"Hello! \ud83d\udc4d","message_id":"00000000-0000-0000-0000-000000000001"},"id":"evt_test123","type":"message.inbound"}' HEADER = "t=1762800000,v1=86a36838f7b5f62cda7ef2388b3f3c92de119dd521d95db76006504065bc0cff" assert len(BODY) == 159 assert verify_signature(BODY, HEADER, SECRET, now=T) is True assert verify_signature(BODY.replace(b"Hello", b"Jello"), HEADER, SECRET, now=T) is False # one byte changed assert verify_signature(BODY, HEADER, SECRET, now=T + 301) is False # outside the 5-minute window print("test vector OK") ``` ```javascript Node theme={null} const assert = require('assert'); const { verifySignature } = require('./verify-signature'); const SECRET = 'whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; const T = 1762800000; // In a JavaScript string, \\u is a literal backslash followed by u. const BODY = Buffer.from('{"created_at":1762800000,"data":{"body":"Hello! \\ud83d\\udc4d","message_id":"00000000-0000-0000-0000-000000000001"},"id":"evt_test123","type":"message.inbound"}', 'utf-8'); const HEADER = 't=1762800000,v1=86a36838f7b5f62cda7ef2388b3f3c92de119dd521d95db76006504065bc0cff'; assert.strictEqual(BODY.length, 159); assert.strictEqual(verifySignature(BODY, HEADER, SECRET, T), true); const changed = Buffer.from(BODY); changed[BODY.indexOf('Hello')] = 0x4a; // 'H' -> 'J': one byte changed assert.strictEqual(verifySignature(changed, HEADER, SECRET, T), false); assert.strictEqual(verifySignature(BODY, HEADER, SECRET, T + 301), false); // outside the window console.log('test vector OK'); ``` If the first check fails, compare the length first: a body that isn't exactly 159 bytes means the `\u` escapes were turned into an emoji, or whitespace was added along the way. ## Framework integration The signature only matches the **exact bytes** iBubbl sent, so read the raw body before any JSON parsing. > [accordion] Express — keep the raw body > > `express.json()` parses the body and throws the bytes away. Either parse the webhook route with `express.raw()`: > > ```javascript theme={null} > const express = require('express'); > const { verifySignature } = require('./verify-signature'); > > const app = express(); > > app.post( > '/ibubbl-webhooks', > express.raw({ type: 'application/json' }), > (req, res) => { > // Lower case: req.headers['X-iBubbl-Signature'] would be undefined. > const signature = req.headers['x-ibubbl-signature']; > if (!verifySignature(req.body, signature, process.env.IBUBBL_WEBHOOK_SECRET)) { > return res.status(401).send('invalid signature'); > } > > const event = JSON.parse(req.body.toString('utf-8')); > // de-duplicate on event.id, queue the work... > res.sendStatus(200); > }, > ); > ``` > > Or, if `express.json()` is already mounted for the whole app, keep a copy of the bytes with its `verify` hook and verify `req.rawBody`: > > ```javascript theme={null} > app.use(express.json({ > verify: (req, res, buf) => { req.rawBody = buf; }, // the exact bytes received > })); > > app.post('/ibubbl-webhooks', (req, res) => { > const signature = req.headers['x-ibubbl-signature']; > if (!verifySignature(req.rawBody, signature, process.env.IBUBBL_WEBHOOK_SECRET)) { > return res.status(401).send('invalid signature'); > } > const event = req.body; // already parsed; safe to use once verified > // de-duplicate on event.id, queue the work... > res.sendStatus(200); > }); > ``` > [accordion] FastAPI / Starlette — read the body first > > `await request.body()` returns the raw bytes. Read them before any parsing, and don't declare a Pydantic body parameter on the route. > > ```python theme={null} > import json > import os > > from fastapi import FastAPI, HTTPException, Request > > from verify_signature import verify_signature > > app = FastAPI() > > > @app.post("/ibubbl-webhooks") > async def receive_webhook(request: Request): > body_bytes = await request.body() > signature = request.headers.get("x-ibubbl-signature") # any case matches > > if not verify_signature(body_bytes, signature, os.environ["IBUBBL_WEBHOOK_SECRET"]): > raise HTTPException(status_code=401, detail="invalid signature") > > event = json.loads(body_bytes) > # de-duplicate on event["id"], queue the work... > return {"ok": True} > ``` > [accordion] Next.js (App Router) — read the bytes in the route handler > > Read the body as bytes with `request.arrayBuffer()` and verify them before parsing. `await request.text()` followed by `Buffer.from(text, 'utf-8')` gives the same bytes, but reading bytes directly is the safer habit. > > ```javascript theme={null} > // app/api/ibubbl-webhooks/route.js > import { NextResponse } from 'next/server'; > import { verifySignature } from '@/lib/verify-signature'; > > export const runtime = 'nodejs'; // verifySignature uses node:crypto and Buffer > > export async function POST(request) { > const bodyBytes = Buffer.from(await request.arrayBuffer()); > const signature = request.headers.get('x-ibubbl-signature'); // any case matches > > if (!verifySignature(bodyBytes, signature, process.env.IBUBBL_WEBHOOK_SECRET)) { > return new NextResponse('invalid signature', { status: 401 }); > } > > const event = JSON.parse(bodyBytes.toString('utf-8')); > // de-duplicate on event.id, queue the work... > return NextResponse.json({ ok: true }); > } > ``` > > On the Pages Router, turn the built-in parser off for the route (`export const config = { api: { bodyParser: false } }`) and read the request stream into a `Buffer` yourself. ## Common pitfalls > [!WARNING] > > **Don't re-serialize the body before verifying.** Any middleware that parses and re-stringifies JSON produces different bytes: spacing, key order and `\u` escapes all change. Only the raw bytes as received verify. > [!WARNING] > > **Don't hex-decode the secret.** The HMAC key is the whole string `whsec_a3f2c8d9…` encoded as UTF-8, not the decoded bytes of the hex part. > [!WARNING] > > **Don't skip the timestamp check.** The HMAC proves iBubbl sent the request; the timestamp stops a captured request from being replayed hours later. You need both. * **Use a constant-time comparison:** `hmac.compare_digest` (Python) or `crypto.timingSafeEqual` (Node), never `==`. * **Don't skip verification "for testing."** Test shortcuts reach production. Use the [test vector](#test-vector) instead. * **Reject a bad signature with `401` and don't process the body.** Like any non-`2xx`, a `401` is a failed delivery: iBubbl retries it on the [normal schedule](/guides/webhook-retries) and then marks it dead. If valid deliveries are being rejected, check the raw body, the full `whsec_…` secret and the header name's case. ## Next > [card] [Receive messages](/guides/receive-messages) > > A complete receiver, including an auto-reply that can't loop. > [card] [Test webhooks locally](/guides/test-webhooks-locally) > > Receive real events on your laptop through a tunnel. # Idempotency Source: https://docs.ibubbl.com/idempotency Make sends retry-safe with idempotency keys. Sending the same message twice — because of a network retry, a request timeout, or a deploy mid-request — is a real risk in any production integration. iBubbl handles this with **required** idempotency keys on every send. ## How it works `POST /v1/messages` requires an `idempotency_key` field on every request. iBubbl stores the key with the message it created, alongside a hash of the request payload. **Keys never expire.** When you send with a key that your account has used before: * **Same key + same payload** → iBubbl returns the original message (HTTP `202`, the same message ID). The response shows the message's **current** state, for example `delivered`, not the state it had when you first sent it. Nothing is sent again — however long ago the original was sent. * **Same key + different payload** → `409 idempotency_conflict`, whatever the original message's status. This usually means your code generated one key for two genuinely different messages. * **Same key + same payload, and the original message ended `failed` or `expired`** → the key is released and the request is sent as a new message, with a new ID. This is the one case where reusing a key sends again, so retrying a failed send with its original key is safe. A `cancelled` message does **not** release its key: the request returns the cancelled message. * **New key** → treated as a fresh request. > [!WARNING] > > **The key is the only duplicate protection.** iBubbl never compares message bodies. A new key with an identical body sends a second, real message, even milliseconds after the first. See [Duplicates and retries](/concepts/message-lifecycle#duplicates-and-retries), including two edge cases: simultaneous requests with one key, and timed-out sends that create a group. ## Generating keys Use a new UUIDv4 for every logical send. Generate it **once per message**, before the first attempt, and reuse that same value across retries of that one message. ```bash cURL theme={null} # One key per message: generate it once, and reuse it if you retry. IDEMPOTENCY_KEY=$(uuidgen) # Run this same command again to retry: the key and the payload stay identical. curl -X POST https://api.ibubbl.com/v1/messages \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"to\": \"+14155551234\", \"body\": \"Hey Sam — quick check-in.\", \"idempotency_key\": \"$IDEMPOTENCY_KEY\" }" ``` ```typescript TypeScript theme={null} import { randomUUID } from "node:crypto"; async function sendWithRetry(to: string, body: string, maxAttempts = 3) { // One key per message: generate it once, and reuse it if you retry. const idempotencyKey = randomUUID(); for (let attempt = 0; attempt < maxAttempts; attempt++) { let res: Response; try { res = await fetch("https://api.ibubbl.com/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to, body, idempotency_key: idempotencyKey, // the SAME key on every retry }), signal: AbortSignal.timeout(10_000), }); } catch (err) { if (attempt === maxAttempts - 1) throw err; // a timeout or network error: retry await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); continue; } if (res.status < 500) { const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); // 4xx: final, never retry return data; } await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); // 5xx: back off, retry } throw new Error("all retries failed"); } ``` ```python Python theme={null} import os import time import uuid import requests API_KEY = os.environ["IBUBBL_API_KEY"] def send_with_retry(to: str, body: str, max_attempts: int = 3) -> dict: # One key per message: generate it once, and reuse it if you retry. idempotency_key = str(uuid.uuid4()) for attempt in range(max_attempts): try: res = requests.post( "https://api.ibubbl.com/v1/messages", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "to": to, "body": body, "idempotency_key": idempotency_key, # the SAME key on every retry }, timeout=10, ) except requests.RequestException: # a timeout or network error: retry if attempt == max_attempts - 1: raise else: if res.status_code < 500: data = res.json() if not res.ok: # 4xx: final, never retry raise RuntimeError(f"{res.status_code} {data['detail']['error']}") return data time.sleep(2 ** attempt) # back off before retrying raise RuntimeError("all retries failed") ``` ## Scoping Idempotency keys are scoped **per customer account**. Two different iBubbl accounts can use the same key without collision — but within a single account, the namespace is shared across all API keys for that account. (If you have multiple API keys for the same account, they share the idempotency-key space.) ## What "same payload" means iBubbl hashes the routing field **exactly as you sent it**, plus the `body`: | Request shape | Hashed | | - | - | | `to` | the `to` string + `body` | | `contact_id` | the `contact_id` string + `body` | | `chat_guid` | the `chat_guid` string + `body` | | `participant_phones` | the phones, sorted, + `group_name` + `body` | The routing field is compared as text, not by the contact it resolves to. So a retry that switches from `to` to the same contact's `contact_id` (or reformats the phone number) is a different payload and gets `409 idempotency_conflict`. Retry with the exact request you sent the first time. ## Common pitfalls > [accordion] Regenerating the key on retry > > If you generate the key inside your retry loop, each retry gets a fresh key and the deduplication never fires. Generate it **once per message**, before the first attempt, and reuse it. > [accordion] Reusing a key across different sends > > The key represents *one logical send*. If your code sends to multiple contacts in a campaign, each one needs its own key. A key generated once at module level and shared by every send gets `409 idempotency_conflict` on the second message. > [accordion] Reusing old keys > > Keys never expire, so a key reused months later still replays (or conflicts with) the original message. Never derive keys from something that repeats, such as a date or a contact ID alone. > [accordion] Getting 409 when you think the payload matches > > Any difference in the hashed fields above — including whitespace in `body`, or a different spelling of the same phone number — triggers a conflict. Compare your retry's payload character by character against the original. # Inbound events Source: https://docs.ibubbl.com/inbound-events/overview Let a connected app start an iBubbl workflow. Most of this documentation describes iBubbl talking to you. This page is the other direction: another app posts an event to iBubbl, and a workflow you built runs. It works with any app that can send a webhook — a form builder, a scheduling tool, your store, Zapier or Make. The case it was first built for: a customer's card is declined on a subscription renewal, and you want to text them about it while they can still fix it. > [!NOTE] > > Not to be confused with [Webhooks](/webhooks/overview), which is iBubbl > sending events **to** your server. This page is about events arriving **at** > iBubbl. ## How it fits together 1. Create a **source** in your dashboard under **Settings → Inbound events**. You get a URL with a credential built into it, shown once. 2. Paste that URL into the sending app, and send a test event from it. 3. Open the source's **Events & fields**: the event appears there, and you pick the field holding the customer's phone number by clicking it. 4. Build a workflow whose trigger is **Webhook received**, pointed at that source. Its event name, Event ID and message fields can all be picked from the same event. When an event arrives, iBubbl checks it (see [Source types](#source-types)), finds the customer's phone number in the payload, and starts your workflow. ## The endpoint ``` POST https://api.ibubbl.com/v1/events/ ``` This is the one endpoint in the API that takes no `Authorization` header — the credential is the token in the URL. That is not a shortcut: many apps, Recharge among them, let you configure only a destination URL and give you no way to set a header. Treat the URL like a password. If it leaks, use **New URL** on the source (the old URL stops working at once) or revoke the source. Bodies may be JSON or `application/x-www-form-urlencoded` form data, up to 256 KB. `multipart/form-data` isn't supported: such a request is accepted but recorded as **Event wasn't JSON or form data**, and starts nothing. ### What you get back ```json theme={null} { "received": true, "runs_started": 1 } ``` `runs_started` is `0` whenever the event was accepted but nothing ran — no workflow was listening, or the event was a repeat of one already handled. That is normal. When it is unexpectedly `0`, the source's row in **Settings → Inbound events** says which of those it was. Errors are limited to `403` (signature did not match), `404` (no such source, or it was revoked), `413` (body over 256 KB) and, for secret-URL sources only, `429` (over 60 events a minute, while a workflow is listening on the source — see [Source types](#source-types)). Everything else answers `200`, deliberately — see [Delivery](#delivery-and-retries). (Only `POST` is accepted; a `GET` to the URL returns `405`.) ## Source types | Type | Who it's for | How a request is checked | | - | - | - | | **Any app (secret URL)** | Any app that can send a webhook | The URL is the key; no signature. At most 60 events a minute per source (see below). | | **Recharge** | Recharge | Recharge's `X-Recharge-Hmac-Sha256` signature, using your API Client Secret. | | **iBubbl-signed** | Your own systems | An `X-iBubbl-Signature` header you compute with a secret iBubbl gives you. | ### Any app (secret URL) Most apps can't sign a request in a way iBubbl could check, so for these the long, random URL is the credential — the same approach as Zapier's and Make's catch hooks. Create the source, paste the URL into the app, and send a test event. A secret-URL source accepts at most 60 events a minute. Over that, the answer depends on whether any active workflow is listening on the source: if one is, iBubbl answers `429` so the app retries later; if none is, the event is accepted (`200`) and dropped, since it couldn't have started anything. Dropped events don't appear in the source's recent events. Recharge sources are never rate limited. For example: ```bash cURL theme={null} # No API key: the source's token in the URL is the credential. curl -X POST "https://api.ibubbl.com/v1/events/$IBUBBL_EVENT_SOURCE_TOKEN" \ -H "Content-Type: application/json" \ -d '{"type": "lead.created", "lead": {"id": "evt_1", "phone": "3103843698", "first_name": "Ana"}}' ``` ```typescript TypeScript theme={null} // No API key: the source's token in the URL is the credential. const res = await fetch(`https://api.ibubbl.com/v1/events/${process.env.IBUBBL_EVENT_SOURCE_TOKEN}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "lead.created", lead: { id: "evt_1", phone: "3103843698", first_name: "Ana" }, }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.runs_started); ``` ```python Python theme={null} import os import requests # No API key: the source's token in the URL is the credential. TOKEN = os.environ["IBUBBL_EVENT_SOURCE_TOKEN"] res = requests.post( f"https://api.ibubbl.com/v1/events/{TOKEN}", json={ "type": "lead.created", "lead": {"id": "evt_1", "phone": "3103843698", "first_name": "Ana"}, }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["runs_started"]) ``` **Event name.** iBubbl reads it from an `X-Event-Topic` or `X-iBubbl-Topic` header, or a top-level `topic`, `type`, `event` or `event_type` field. If the app puts it somewhere else, choose **The event name is somewhere inside the payload** on the trigger and pick the field. **Phone number.** By default iBubbl looks for `phone`, `phone_number` or `contact.phone`. If the app puts it elsewhere — `lead.phone` above — pick it in the source's **Events & fields**. First and last name work the same way. ### iBubbl-signed For senders you control. iBubbl creates a signing secret (`whsec_…`, shown once). Sign each request with: ``` X-iBubbl-Signature: t=,v1= ``` where `` is the HMAC-SHA256, keyed with the secret, of `.` followed by the raw request body. Requests more than 5 minutes off from `t` are rejected. This is the same scheme iBubbl uses to sign the [webhooks](/webhooks/overview) it sends you. ## Connecting Recharge In Recharge, create a webhook with your iBubbl URL as the address and `charge/failed` as the topic. You can do that from their API: ```bash theme={null} curl -X POST https://api.rechargeapps.com/webhooks \ -H "X-Recharge-Access-Token: " \ -H "Content-Type: application/json" \ -d '{ "address": "https://api.ibubbl.com/v1/events/ibev_...", "topic": "charge/failed" }' ``` > [!WARNING] > > When you create the source in iBubbl, paste your **API Client Secret** — not > your API token. They are different values. Find it in Recharge under > **Tools and apps → API tokens →** your token **→ Edit admin API token**. > > Pasting the token instead is the most common setup mistake, and it shows up > as every event failing with "Signature did not match". Recharge signs each request with an `X-Recharge-Hmac-Sha256` header. Despite the name, the value is **not** an HMAC — it is a plain SHA-256 of your client secret followed by the raw request body. iBubbl handles that for you; it is worth knowing only if you are comparing against your own verifier. ### Writing the message The whole event is available to your message template under `trigger.event`, rooted at the app's own payload. **Insert field** on the Send message step lists the fields of the latest event and writes the right syntax for you. Recharge wraps a charge, so: ``` Hi {{contact.first_name}} — the card on your subscription was declined. You can update it here: https://yourstore.rechargeapps.com/portal/{{trigger.event.charge.customer_hash}}/ ``` `customer_hash` comes in the payload, so the link costs no extra API call. (It is `charge.customer_hash` in Recharge's 2021-01 API format. In the 2021-11 format the charge carries it as `charge.customer.hash` instead — use **Insert field** on a real event to see which one your store sends.) Link to the portal root rather than a deeper page: the exact path differs depending on which Recharge portal your store runs, and Recharge is migrating merchants between them. Once the customer updates their card, Recharge reschedules the retry itself — so "update your card and we'll take care of the rest" is literally true. ## Repeat events Apps resend events. Recharge in particular sends `charge/failed` **every time it retries a charge**, and how many times that is depends on the retry settings in your own Recharge account. Without protection, a single declined card would text the customer on every one of those retries. The **Event ID path** on your trigger is what prevents it, and every **Webhook received** trigger needs one. It points at the field identifying the underlying object — for Recharge, `charge.id`. An event resolving to an ID that already started a run starts nothing new; the source records it as **Already handled**. > [!WARNING] > > Paths start inside the sending app's payload. Recharge wraps everything in a > `charge` object, so the path is `charge.id` — not `id`. > > A path is field names separated by dots; a number selects an array element > (`line_items.0.sku`). Don't write a `$.` prefix: `$.charge.id` matches > nothing. > > A path that matches nothing is treated as a configuration error: no workflow > runs, and the source reports **Event ID path not found**. Sending nothing is > the safer failure here than sending one message per retry. Because the ID identifies the object rather than the delivery, this is permanent for that charge. To send a second, different message later in the dunning cycle, build a separate workflow on the `charge/max_retries_reached` topic. ## Finding the customer iBubbl looks for a phone number in the payload — for Recharge, in the billing address, or the shipping address when the billing phone is blank; for other sources, where you pointed it (see [Any app](#any-app-secret-url)) — and converts it to international format. Numbers arrive as the shopper typed them, so `(310) 384-3698` and `3103843698` both work. A number without a leading `+` is read in the country named by a two-letter `country_code` or `country` field in the payload when there is one, otherwise in the source's default country (the US unless it was set otherwise). A billing phone that can't be read is not replaced by the shipping one. A number that cannot be read as a real phone number is **rejected, not guessed at**, and the event starts nothing. That matters more than it looks: the message you are sending carries a link to that customer's account, so a number resolved onto the wrong person would hand a stranger access to someone else's subscription. Events for customers with no phone number on file therefore do nothing, and the source reports **No phone number in event**. If your store collects phone numbers from only some customers, this feature reaches only those customers. A contact is created (or an existing one's missing name filled in) **only when a workflow starts** for the event. Events nothing is listening for add no one to your contacts. ## Delivery and retries iBubbl answers `200` to anything it has verified, even when the event goes nowhere. That is on purpose. Recharge treats `408`, `429` and `5xx` as failures, retries 19 times over 48 hours, and then **deletes the webhook registration** and emails you. An endpoint that returned `500` on an unrecognised payload would eventually unsubscribe you without either side noticing. It also means the reason an event did nothing cannot live in the HTTP response. It lives on the source instead. **Settings → Inbound events** shows each source's recent events — the newest 20, kept for 30 days — with what happened to each: | What it says | What happened | | - | - | | Started a workflow | An event arrived and a run began. | | Already handled | A repeat of an event already processed. Expected on charge retries. | | No workflow listening | Verified, but no active workflow matched this source and topic. | | No phone number in event | No usable phone number in the payload. | | Event ID path not found | A workflow matched, but its Event ID path matched nothing in the payload. | | Signature did not match | Wrong signing secret — for Recharge, usually the API token pasted instead of the Client Secret. | | Event wasn't JSON or form data | The body couldn't be read. | | Something went wrong | An error on iBubbl's side; the event was not processed. | Events dropped over a secret-URL source's rate limit (with no workflow listening) are not recorded here. ## New URL and revoking **New URL** gives a source a new URL; the old one starts returning `404` at once, and the source keeps its settings and history. Update the sending app with the new URL. Do this if a URL leaks. Revoking a source is also immediate: its URL starts returning `404` and the source stops listening for good. # Overview Source: https://docs.ibubbl.com/introduction Send iMessages from your own iBubbl line and get the replies on your server. With the iBubbl API, your software sends real iMessages, the blue bubbles, from a dedicated line that iBubbl runs for you, and gets every reply pushed to your server, usually within a minute. There's no Mac, Apple ID or phone to manage on your side. * **Send messages** to one contact or to a group, from your iBubbl line. When the recipient can't be reached on iMessage, the message goes out as a text instead, and its `channel` tells you which. * **Receive replies and status changes** as [webhooks](/webhooks/overview): inbound messages, sent / delivered / read, reactions, edits and opt-outs. * **Keep contacts in sync**: create, update and tag them. * **Stay compliant by default**: opt-outs, each contact's sending hours and your line's send limits are enforced for you. The API is HTTPS and JSON, authenticated with one Bearer API key. There's no SDK to install: any HTTP client works. ```bash theme={null} curl -X POST https://api.ibubbl.com/v1/messages \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551234", "body": "Hey Sam, quick check-in about your order.", "idempotency_key": "a3f2c8d9-e0f1-4b4d-9c5a-7b8e2d1f0c4a" }' ``` The API answers `202 Accepted` right away; the message then goes out from your line, and its progress arrives as webhook events. The [Quickstart](/quickstart) walks through it end to end, sending to your own phone. > [!NOTE] > > Every request is live: there is no sandbox or test mode, and a send delivers a real message. You need an active iBubbl line before you can send; iBubbl sets it up for you. ## Base URL ``` https://api.ibubbl.com ``` All endpoints are under `/v1`. See [Versioning](/versioning) for what can and can't change within it. ## Where to go next > [card] [Quickstart](/quickstart) > > Create a key, send to your own phone, check the status, receive a reply. > [card] [Authentication](/authentication) > > API key format, rotation and security practices. > [card] [Webhooks](/webhooks/overview) > > Get replies and status changes pushed to your server. > [card] [API reference](/api-reference/messages/send) > > Every endpoint, with request and response schemas. > [card] [Build with AI](/build-with-ai) > > llms.txt, Markdown pages, the docs MCP server and a system prompt for coding agents. > [card] [Support](/support) > > Email us, and what to include so we can answer quickly. # OpenAPI, Postman and Insomnia Source: https://docs.ibubbl.com/openapi Import the iBubbl OpenAPI spec into Postman or Insomnia and authenticate with your API key. The whole `/v1` API is described in one OpenAPI file: ```text theme={null} https://docs.ibubbl.com/openapi/public.json ``` It is OpenAPI 3.1. It sets the server to `https://api.ibubbl.com` and declares a `BearerAuth` scheme (`Authorization: Bearer ib_pk_…`) on every endpoint except [`POST /v1/events/{token}`](/api-reference/inbound-events/receive), which is authenticated by the token in its URL. It is the same file the API reference pages are built from, so it always matches them. You can also hand it to a code generator, or to a coding agent (see [Build with AI](/build-with-ai)). > [!WARNING] > > **Every request is live.** There is no sandbox or test mode: sending a request from Postman or Insomnia delivers a real message from your line. Send only to your own phone while you explore. ## Postman 1. In Postman, select **Import**, and paste the link above. 2. When Postman asks how to import the OpenAPI file, choose **Postman Collection**, then select **Import**. You get a collection of folders and requests, one request per endpoint. 3. Create an environment for iBubbl (**Environments**, then the add icon) and add a variable named `IBUBBL_API_KEY` whose value is your key. Mark it secure (secret), so the value is masked. Select the environment in the environment selector at the top right. 4. Open the collection's **Authorization** tab, choose **Bearer Token** as the **Auth Type**, and enter `{{IBUBBL_API_KEY}}` in **Token**. Requests that inherit auth from the collection now send `Authorization: Bearer `. If a request has its own auth set, switch it to inherit from the collection. 5. Send **Retrieve a message** or **List webhook subscriptions** first: they only read. Before sending **Send a message**, replace the example body: put your own number in `to`, and a new `idempotency_key` for every message (a UUID; Postman's `{{$guid}}` generates one). The **Receive an event** request needs no key: replace `{token}` with an event source's token (`ibev_…`). ## Insomnia 1. In your workspace, select **Import**, choose **URL** as the source, and enter the link above. 2. Select **Scan**, review the resources, and select **Import**. Insomnia creates an API collection with the spec in its **Spec** tab. To get requests you can send, select **Generate** on the Spec tab and choose requests. 3. Open **Environments** and add your key as a variable in JSON, for example `{ "IBUBBL_API_KEY": "ib_pk_..." }`. 4. Insomnia has no collection-level auth, so set it on a folder (or on each request): on the **Auth** tab choose **Bearer Token**, and in the token field press `Control+Space` and pick `IBUBBL_API_KEY` from the list. 5. As in Postman, start with a read-only request, and give every send your own number and a new `idempotency_key`. ## Keep your key safe * Keep the key in an environment variable, not in a request. If you share a collection or workspace, or sync it to the cloud, check that the key's value isn't included. * If a key leaks, revoke it in the dashboard under **Settings → API Access** and create a new one. See [Authentication](/authentication). * The spec updates when the API does. Postman and Insomnia copy it on import, so re-import after a change listed in the [changelog](/changelog). # Quickstart Source: https://docs.ibubbl.com/quickstart Create a key, send an iMessage to your own phone, check its status and receive the reply. In about ten minutes you'll send a real iMessage from your iBubbl line to your own phone, check that it was delivered, and get your reply pushed to your server. **Before you start** * An iBubbl account with an **active line**. iBubbl sets up and runs the line for you; you don't connect a Mac or an Apple ID. [Sign up](https://ibubbl-dashboard.vercel.app/signup) if you haven't. * **Your own mobile number**, to send the first messages to. > [!WARNING] > > **Every request is live.** There is no sandbox or test mode, and no test line: each send delivers a real message from your line. Send only to your own phone while you test. **1. Create an API key** In the [dashboard](https://ibubbl-dashboard.vercel.app), open **Settings → API Access** and click **Create key**. Copy the key (`ib_pk_` followed by 64 hex characters); this is the only time the full key is shown. Keep it in an environment variable rather than in your code. The examples on this page read it from `IBUBBL_API_KEY`: ```bash theme={null} export IBUBBL_API_KEY="ib_pk_..." ``` See [Authentication](/authentication) for rotation and security practices. **2. Send a message to your own phone** Put your own mobile number in `to`. ```bash cURL theme={null} # One key per message: generate it once, and reuse it if you retry. IDEMPOTENCY_KEY=$(uuidgen) curl -X POST https://api.ibubbl.com/v1/messages \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"to\": \"+14155551234\", \"body\": \"Hello from the iBubbl API\", \"idempotency_key\": \"$IDEMPOTENCY_KEY\" }" ``` ```typescript TypeScript theme={null} import { randomUUID } from "node:crypto"; // One key per message: generate it once, and reuse it if you retry. const idempotencyKey = randomUUID(); const res = await fetch("https://api.ibubbl.com/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+14155551234", body: "Hello from the iBubbl API", idempotency_key: idempotencyKey, }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.status); ``` ```python Python theme={null} import os import uuid import requests API_KEY = os.environ["IBUBBL_API_KEY"] # One key per message: generate it once, and reuse it if you retry. idempotency_key = str(uuid.uuid4()) res = requests.post( "https://api.ibubbl.com/v1/messages", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "to": "+14155551234", "body": "Hello from the iBubbl API", "idempotency_key": idempotency_key, }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["status"]) ``` The response is `202 Accepted`: ```json theme={null} { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "status": "pending", "to": "+14155551234", "body": "Hello from the iBubbl API", "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f", "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", "conversation_kind": "one_to_one", "chat_guid": null, "channel": null, "sent_at": "2026-11-10T15:04:10.112Z", "held_until": null, "held_reason": null } ``` Save the `id`; you'll use it in the next step. > [!NOTE] > > **`202` means accepted, not delivered.** iBubbl has taken the message and will send it from your line; it hasn't reached the phone yet. Track it with the next step or with [webhooks](/webhooks/events). > [!NOTE] > > **`idempotency_key` is required.** Use a new one for each message, and send the **same** key and the same payload again if a request times out or fails with a `5xx`: iBubbl returns the original message instead of sending twice. See [Idempotency](/idempotency). > [!NOTE] > > **`to` must be in E.164 format**: `+`, the country code, then the number, with no spaces or punctuation (`+14155551234`). Anything else returns `422 validation_error`. **3. Check the status** Fetch the message by its `id`: ```bash cURL theme={null} curl https://api.ibubbl.com/v1/messages/8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b \ -H "Authorization: Bearer $IBUBBL_API_KEY" ``` ```typescript TypeScript theme={null} const messageId = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b"; const res = await fetch(`https://api.ibubbl.com/v1/messages/${messageId}`, { 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}`); console.log(data.status, data.channel); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] message_id = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b" res = requests.get( f"https://api.ibubbl.com/v1/messages/{message_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["status"], data["channel"]) ``` Once your phone has it, `status` is `delivered` (or `read`, if you have read receipts on), and `channel` says how it went out: `imessage`, or `sms` when your number couldn't be reached on iMessage. ```json theme={null} { "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "status": "delivered", "channel": "imessage", "sent_at": "2026-11-10T15:04:12.381Z", "held_until": null, "held_reason": null, "...": "..." } ``` > [!NOTE] > > **Outside sending hours, the message waits.** iBubbl only sends during each contact's [sending hours](/concepts/working-hours) (by default Monday to Saturday 9 AM–8 PM, Sunday 12–8 PM, in the contact's timezone). If you're outside them, the message stays `pending` with `held_reason: "working_hours"`, and `held_until` says when it will go out. That's expected, not an error. In production, don't poll: subscribe to webhooks, as in the next step. **4. Receive a reply** Register an HTTPS endpoint you control that logs the request body, subscribed to `message.inbound`. To test from your laptop, expose a local server with a tunnel such as `cloudflared tunnel --url http://localhost:8000` or `ngrok http 8000`, and use the HTTPS URL it prints. ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/webhooks \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-endpoint.example.com/ibubbl-webhooks", "event_types": ["message.inbound"] }' ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.ibubbl.com/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://your-endpoint.example.com/ibubbl-webhooks", event_types: ["message.inbound"], }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.secret); // store the secret now ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] res = requests.post( "https://api.ibubbl.com/v1/webhooks", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "url": "https://your-endpoint.example.com/ibubbl-webhooks", "event_types": ["message.inbound"], }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["secret"]) # store the secret now ``` The `201 Created` response includes the signing `secret` (`whsec_…`). It is shown only once: store it, because you'll need it to verify signatures. Now reply to the message on your phone. Your endpoint receives a `POST` like this: ```json theme={null} { "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "type": "message.inbound", "created_at": "2026-11-10T15:06:12.456Z", "data": { "message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e", "from_number": "+14155551234", "to_number": "+14155550100", "body": "Got it!", "received_at": "2026-11-10T15:06:12.456Z", "attachments": [], "reply_to_message_id": null, "conversation_kind": "one_to_one", "sender_phone": "+14155551234", "participants": [ {"phone": "+14155551234", "first_name": null, "last_name": null} ] } } ``` Return any `2xx` within 10 seconds, or iBubbl retries the delivery. > [!WARNING] > > **Before you go live:** verify the `X-iBubbl-Signature` header on every request ([Signature verification](/guides/webhook-signatures)), de-duplicate on the event `id`, and only ever send automatically in response to `message.inbound`. Sending in response to `message.sent` or another status event of your own messages makes a loop. Subscriptions can't be edited. When you're done testing, revoke this one with [`DELETE /v1/webhooks/{subscription_id}`](/api-reference/webhooks/revoke) and create your production subscription. ## Next steps > [card] [Webhook events](/webhooks/events) > > Every event type and its payload. > [card] [Send messages](/guides/send-messages) > > All four ways to address a message, including groups. > [card] [Receive messages](/guides/receive-messages) > > Replies, attachments, opt-outs, and an auto-reply that can't loop. > [card] [Errors](/errors) > > Every error code, and which ones to retry. > [card] [Build with AI](/build-with-ai) > > Hand these docs and a ready-made system prompt to your coding agent. # Support Source: https://docs.ibubbl.com/support How to reach iBubbl, and what to include so we can answer quickly. Email **[support@ibubbl.com](mailto:support@ibubbl.com)** with questions about the API, a request that isn't behaving as documented, a webhook that isn't arriving, or a mistake in these docs. ## What to include The more of this you send, the faster we can find your request in our logs: * **The `X-Request-Id` response header.** Every API response carries one, and it finds your request directly. See [Request IDs](/errors#request-ids) for how to read it, and how to send your own. * **When it happened**: the date and time, with the timezone (UTC is best). This matters most when you don't have a request ID, such as for a webhook. * **The endpoint**: method and path, for example `POST /v1/messages`. * **The IDs involved**: the message `id`, the `contact_id`, the webhook subscription `id`, or the event `id` (`evt_…`), whichever apply. * **The HTTP status and response body** you got back, and the request body if there's no request ID. * **The email address of your iBubbl account.** For a webhook problem, also include the endpoint URL the subscription points at, and what the dashboard's webhook log shows for the delivery. > [!WARNING] > > **Never send us your API key or webhook signing secret**, in full or in a screenshot. Leave the `Authorization` header out of anything you paste. If you need to say which key you used, give its prefix (the first 14 characters, such as `ib_pk_a3f2c8d9`), which the dashboard shows next to each key. If a key or secret has been exposed, revoke it in the dashboard first and create a new one. ## Before you write * [Errors](/errors) lists every error code, what it means and whether to retry. * [Webhook events](/webhooks/events) describes every payload, including why events can arrive out of order or twice. * [Changelog](/changelog) lists recent changes to the API and these docs. # Versioning Source: https://docs.ibubbl.com/versioning How iBubbl evolves the API without breaking integrations. The iBubbl API is versioned in the URL path. The current version is **`v1`**. ``` https://api.ibubbl.com/v1/messages ^^ ``` ## Stability guarantees Within `/v1/`, iBubbl will **never**: * Remove an endpoint. * Remove a field from a response. * Change a field's type or stop accepting it as input. * Rename an enum value (e.g., `status: "queued"`). * Change the semantics of an error code. * Change the HMAC signature algorithm for webhooks. iBubbl **may**: * Add new endpoints. * Add new optional request fields (existing requests keep working). * Add new response fields (treat unknown fields defensively in your parser). * Add new event types to the webhook catalog (filter by `event_types` if you only want a subset). * Add new error codes for new failure modes. * Add new values to an existing enum — for example a new message `status` or `failed_reason`. Handle values you don't recognise (log them, treat them as "other") instead of failing. * Tighten validation. We aim to keep previously-accepted requests passing, but we have broken that once: since **2026-11-05**, the contacts endpoints (`POST /v1/contacts`, `PATCH /v1/contacts/{contact_id}`, `POST /v1/contacts/{contact_id}/tags`) reject unknown body fields with `422 validation_error` instead of ignoring them. `POST /v1/messages` still ignores unknown fields. Any future tightening will be announced in these docs before it takes effect. Treat unknown fields, enum values and event types as forward-compat. Your code should not error on an extra response field, status or event type it doesn't recognize. ## Breaking changes Breaking changes ship behind a new version prefix — `/v2/` will exist alongside `/v1/`, not replace it. When a major version ships, iBubbl will: 1. Announce the `/v1/` sunset date in these docs at least **12 months** before `/v1/` is sunset. 2. Show a dashboard banner with the migration timeline. 3. Publish a `/v2/` migration guide enumerating every change. 4. Keep `/v1/` fully operational for the deprecation window. ## Deprecation policy A field, endpoint, or behavior is "deprecated" when iBubbl announces it will be removed in a future major version. Deprecated items continue to work in their original version. They're marked in the docs with a deprecation notice and (where it makes sense) emit a `Deprecation` HTTP response header pointing at the migration path. ## Today The current state of `v1`: * Single stable version. * No deprecated endpoints or fields. * No announced sunset date. Build against `/v1/` with confidence. The contract you ship today is the contract you'll have a year from now. # Webhook Events Source: https://docs.ibubbl.com/webhooks/events Catalog of webhook event types with example payloads. Every webhook POST iBubbl sends to your URL has the same envelope shape. The `type` field tells you which event it is; the `data` field has the event-specific payload. ## Envelope ```json theme={null} { "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", "type": "message.inbound", "created_at": "2026-05-17T14:23:45.123Z", "data": { "...": "event-specific fields" } } ``` | Field | Type | Description | | - | - | - | | `id` | string | Unique event ID (`evt_`). The same on every retry of the event; use it to de-duplicate retries. | | `type` | string | One of the event types below. | | `created_at` | string (ISO 8601 UTC) | When iBubbl recorded the event. | | `data` | object | Event-specific payload. | > [!NOTE] > > **Ignore event types you don't handle.** iBubbl adds new event types over time, and a subscription without an `event_types` filter receives them as they ship. Return `2xx` for any `type` your integration doesn't recognise. > [!NOTE] > > Webhook payloads identify the contact and line by **phone number** (`from_number`, `to_number`, `contact_phone`), not by iBubbl's internal contact ID. If you need the contact ID, look it up via [`POST /v1/contacts`](/api-reference/contacts/upsert) — the upsert is idempotent and returns the ID. ### The `channel` field `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.cancelled` and `message.inbound` carry `channel`: how the message travelled. | Value | Meaning | | - | - | | `imessage` | iMessage. | | `sms` | A text, including RCS. An outbound message goes out as a text when the recipient can't be reached on iMessage. | | `null` | Not known. An outbound message that never went out (cancelled, expired, or failed before sending), a send whose channel wasn't reported, or an inbound message received before iBubbl recorded the channel. | Each event carries the value at the moment it fired; [`GET /v1/messages/{id}`](/api-reference/messages/retrieve) returns the latest. `message.queued` doesn't carry `channel`, because nothing has been sent yet. See [Lines and routing](/concepts/lines-and-routing#which-channel-a-message-used). ### The `conversation_id` field Every `message.*` and `message.reaction.*` event carries `conversation_id`: the conversation the message (or the reacted-to message) is in. It's the same value as `conversation_id` on the [message object](/api-reference/messages/retrieve), and it's what [Mark a conversation read](/api-reference/conversations/mark-read) and [Start typing](/api-reference/conversations/start-typing) take, so you can act on a `message.inbound` without looking anything up first. See [Typing while your AI thinks](/guides/reactions-read-receipts-typing#typing-while-your-ai-thinks). ### Ordering and duplicates * **Events can arrive out of order.** Retries, and events that fire close together, mean `message.read` can reach you before `message.delivered`. Order by the envelope's `created_at` (and treat `status` transitions as described below), not by arrival time. * **Events can arrive more than once.** Delivery is at-least-once. De-duplicate on the envelope `id`; for the rare case where the same change is recorded twice, also de-duplicate on `data.message_id` plus `type`. * **Failed deliveries are retried** up to 5 times over about 37 minutes. See [Retries and deduplication](/guides/webhook-retries) for the schedule and de-duplication code. * **A message's status can move "backwards".** `message.failed` can follow `message.sent` (and even `message.read`), and a failed message can later move to `delivered` or `read`. See [`message.failed`](#message-failed). When in doubt, `GET /v1/messages/{id}` returns the current state. ## Event types > [accordion] message.queued > > Fired when a one-to-one message is accepted. That covers every accepted one-to-one send: `status: "pending"` with `failed_reason: null` when it is on its way (including one waiting for the contact's [sending hours](/concepts/working-hours)), or `status: "queued"` when it is parked by a capacity hold. Group sends do not fire this event. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.queued", > "created_at": "2026-05-17T22:30:00.000Z", > "data": { > "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "status": "queued", > "to_number": "+14155551234", > "body": "Hey Sam — quick check-in about your order.", > "classification": "cold", > "line_id": "2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f", > "failed_reason": "line_offline" > } > } > ``` > > For a `queued` message, `failed_reason` names the capacity hold: today always `line_offline` (`hourly_cap_hit` is a legacy reason no current line uses; see [Limits and queues](/concepts/limits)). (Despite the name, this isn't a final failure: queued messages are retried automatically. One still queued 60–65 minutes after it was accepted expires, and [`message.failed`](#message-failed) fires with `status: "expired"` and `failed_reason: "queue_expired"`.) For a `pending` message it is `null`. > > A `pending` message is different: if its line has been offline for over an hour, it moves to `failed` and fires [`message.failed`](#message-failed) with `failed_reason: "agent_offline"`. > > A `message.sent` event follows when the queued message dispatches, or `message.cancelled` if it was stopped first. > > A retried delivery of this event repeats the original payload. > [accordion] message.sent > > Fired when the message has gone out from your iBubbl line. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.sent", > "created_at": "2026-05-17T14:23:45.123Z", > "data": { > "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155550100", > "to_number": "+14155551234", > "body": "Hey Sam — quick check-in about your order.", > "status": "sent", > "occurred_at": "2026-05-17T14:23:45.123Z", > "reply_to_message_id": null, > "channel": "imessage" > } > } > ``` > > `from_number` is the iBubbl line that sent the message; `to_number` is the contact. For a group message, `to_number` is the group's primary contact (the first participant), not the whole group. > > `reply_to_message_id` is non-null when this message was sent as a reply (quote) to a previous inbound; otherwise null. > > The `message.sent` event for a new group's first message can occasionally be delivered twice; de-duplicate on `data.message_id` plus `type`. > [accordion] message.delivered > > Fired when the recipient's device confirms delivery. Same payload shape as `message.sent`, with `status: "delivered"` and `occurred_at` set to the delivery time. > > ```json theme={null} > { > "id": "evt_9c4b5d83-2e3f-5a6b-0c1d-3e4f5a6b7c8d", > "type": "message.delivered", > "created_at": "2026-05-17T14:23:47.010Z", > "data": { > "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155550100", > "to_number": "+14155551234", > "body": "Hey Sam — quick check-in about your order.", > "status": "delivered", > "occurred_at": "2026-05-17T14:23:46.900Z", > "reply_to_message_id": null, > "channel": "imessage" > } > } > ``` > > If delivery and read are reported together, you may receive only `message.read`. Delivery receipts aren't available for every message (for example, some SMS), so don't wait on this event to consider a message sent. > [accordion] message.read > > Fired when the recipient reads the message (only when they have read receipts turned on). Same payload shape as `message.sent`, with `status: "read"` and `occurred_at` set to the read time. > > ```json theme={null} > { > "id": "evt_0d5c6e94-3f4a-6b7c-1d2e-4f5a6b7c8d9e", > "type": "message.read", > "created_at": "2026-05-17T14:30:02.215Z", > "data": { > "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155550100", > "to_number": "+14155551234", > "body": "Hey Sam — quick check-in about your order.", > "status": "read", > "occurred_at": "2026-05-17T14:30:02.100Z", > "reply_to_message_id": null, > "channel": "imessage" > } > } > ``` > > When a contact reads a message, iMessage implies they've read your earlier messages in the conversation too. iBubbl marks those earlier messages read as well, but fires `message.read` only for the message the receipt was for. > [accordion] message.failed > > Fired when a send fails. `failed_reason` carries a stable failure code; `error_message` is a free-form diagnostic for your logs — don't parse it or show it to end users. > > It also fires when a `queued` message expires: then `status` is `"expired"` (the message's status in [`GET /v1/messages/{id}`](/api-reference/messages/retrieve)) and `failed_reason` is `queue_expired`. Both are final; branch on `failed_reason`, not on `status`. > > `message.failed` can follow a `message.sent` for the same message. That happens when iMessage reports the send failed after it was handed over. It can also follow `message.read` when no delivery was ever confirmed. A message with a confirmed delivery never moves to `failed`. > > A failed message can also move back. If Apple later confirms delivery or a read for a message that failed with an `imessage_*` reason, it moves to `delivered` or `read` and you receive that event. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.failed", > "created_at": "2026-05-17T14:23:45.123Z", > "data": { > "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155550100", > "to_number": "+14155551234", > "body": "Hey Sam — quick check-in about your order.", > "status": "failed", > "occurred_at": "2026-05-17T14:23:45.123Z", > "reply_to_message_id": null, > "channel": "imessage", > "failed_reason": "imessage_undeliverable", > "error_message": "Message failed to send" > } > } > ``` > > `occurred_at` on this event is when the message was sent (or accepted, if it never went out), not when it failed; use the envelope's `created_at` for the failure time. > > `failed_reason` values (treat them as opaque codes; new ones may be added): > > * `imessage_undeliverable` — iMessage couldn't deliver it to this number: the number isn't reachable on iMessage. Resending fails the same way; reach the contact another way. > * `imessage_error:` — iMessage reported another failure; `` is its error number > * `mac_send_failed` — your line couldn't send it, or it wasn't confirmed sent within 10 minutes > * `bluebubbles_unreachable` — your line couldn't be reached at send time > * `agent_offline` — your line was offline for over an hour, so the message was failed rather than sent late > * `line_inactive` — the line was suspended or retired before the message went out > * `queue_expired` — the message was still `queued` 60–65 minutes after it was accepted, so it was dropped. This one arrives with `status: "expired"`, not `"failed"`, and `channel: null`. Its `idempotency_key` is released, so send it again if it still matters. > * `no_eligible_line` — a queued message lost the line it was waiting for, so it can't go out > * `cold_cap_exhausted`, `followup_cap_exhausted`, `daily_cap_exhausted`, `unresponsive_contact` — legacy codes: a queued message failed a limit re-check on a line in the old limits mode. No current line uses that mode (see [Limits and queues](/concepts/limits)). > > See the [Error Reference](/errors) for the full catalog. > [accordion] message.cancelled > > Fired when a message that hadn't been sent yet is stopped in the dashboard: one waiting for the contact's [working hours](/concepts/working-hours), or queued by a capacity hold. iBubbl also cancels an [after-hours auto-reply](/concepts/after-hours-reply) that couldn't go out within 20 minutes of the text it answers. It will never send. This is terminal, like `message.failed`, but nothing went wrong: the message was withdrawn on purpose. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.cancelled", > "created_at": "2026-05-17T14:23:45.123Z", > "data": { > "message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155550100", > "to_number": "+14155551234", > "body": "Hey Sam — quick check-in about your order.", > "status": "cancelled", > "occurred_at": "2026-05-17T14:23:45.123Z", > "reply_to_message_id": null, > "channel": null > } > } > ``` > > `occurred_at` is when it was cancelled. Retrying the original `POST /v1/messages` with the same `idempotency_key` returns this cancelled message; it does not send it again. > [accordion] message.inbound > > Fired when a contact replies to you. This is the event most integrations care about. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.inbound", > "created_at": "2026-05-17T14:24:12.456Z", > "data": { > "message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155551234", > "to_number": "+14155550100", > "body": "yeah sounds good, what time?", > "received_at": "2026-05-17T14:24:12.456Z", > "attachments": [], > "reply_to_message_id": null, > "conversation_kind": "one_to_one", > "sender_phone": "+14155551234", > "participants": [ > {"phone": "+14155551234", "first_name": "Sam", "last_name": "Lee"} > ], > "channel": "imessage" > } > } > ``` > > `from_number` is the contact (the sender) and `to_number` is your iBubbl line (the recipient). `from_number` is the sender's address exactly as iMessage reported it: usually E.164, but it can be an Apple ID email address, or a number without the leading `+` for some SMS senders. > > `reply_to_message_id` is non-null when the contact is quoting one of your previous messages. > > `channel` is how the message arrived: `imessage`, or `sms` for a text (RCS included). It is `null` for messages received before iBubbl started recording it. > > ### Attachments > > If the message contained attachments (images, GIFs, videos, audio, files), `attachments` lists them, and the event fires once, after every attachment has been fetched. Each entry: > > ```json theme={null} > { > "id": "6c7d8e9f-0a1b-2c3d-4e5f-6a7b8c9d0e1f", > "kind": "image", > "mime_type": "image/jpeg", > "url": "https://…signed download URL…", > "expires_at": "2026-05-18T14:24:12.456Z", > "size_bytes": 204811, > "width": 1170, > "height": 2532 > } > ``` > > * `kind` is one of `image`, `gif`, `video`, `audio`, `file`. > * `url` is a signed download link that **expires after 24 hours** (`expires_at`). Download the file promptly if you need to keep it. Retries of the same event carry the same URL. > * An attachment that couldn't be fetched has `"unavailable": true` and no `url`. > * Attachments of unsupported types are left out of the array. > > ### Group inbound > > For group conversations (`conversation_kind: "group"`), `sender_phone` identifies which of the group's participants posted the message, and `participants` lists the participants other than your iBubbl line: > > ```json theme={null} > { > "id": "evt_9c4b5d83-2e3f-5a6b-0c1d-3e4f5a6b7c8d", > "type": "message.inbound", > "created_at": "2026-05-17T14:24:12.456Z", > "data": { > "message_id": "6c7d8e9f-0a1b-2c3d-4e5f-6a7b8c9d0e1f", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155551234", > "to_number": "+14155550100", > "body": "what time tomorrow?", > "received_at": "2026-05-17T14:24:12.456Z", > "attachments": [], > "reply_to_message_id": null, > "conversation_kind": "group", > "sender_phone": "+14155551234", > "participants": [ > {"phone": "+14155551234", "first_name": "Sarah", "last_name": "Smith"}, > {"phone": "+14155557654", "first_name": "Bob", "last_name": "Jones"} > ], > "channel": "imessage" > } > } > ``` > > `sender_phone` always equals `from_number`. For 1:1 conversations, `participants` holds the one contact. The event doesn't carry the group's `chat_guid`; to reply into the group, call `GET /v1/messages/{message_id}` to get it. See [Group Conversations](/concepts/group-conversations) for the full model, including why `participants` can list people who have left. > > > [!NOTE] > > > > If the inbound body matches a STOP-pattern keyword (see [Suppression & Opt-Outs](/concepts/suppression)), iBubbl marks the contact opted out and fires a separate `contact.opted_out` event. You'll receive **both** events for the same incoming text. In a group, opting out the sender blocks all subsequent outbound to the group with `group_outbound_blocked_suppression` — the rest of the group can't be messaged in that thread either. > [accordion] message.edited > > Fired when a contact edits a message they previously sent you (iMessage allows edits for about 15 minutes), or when a message you sent is edited from the dashboard. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.edited", > "created_at": "2026-05-17T14:25:00.000Z", > "data": { > "message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "new_body": "yeah sounds good, what time were you thinking?", > "edited_at": "2026-05-17T14:25:00.000Z", > "direction": "inbound" > } > } > ``` > > The `message_id` matches the original `message.inbound` (or outbound) message — your store can update the existing record. `direction` is `inbound` for edits the contact made to a message they sent, or `outbound` for edits to a message you sent. > > When a contact unsends a message, no event fires. > [accordion] message.reaction.added > > Fired when a tapback reaction is added to a message. Most commonly this is a contact reacting to one of your messages (`direction: "inbound"`). A tapback you send, from the dashboard or with [Add a tapback](/api-reference/messages/react), fires as `direction: "outbound"` once it has reached the contact's phone; if it can't be sent, [`message.reaction.failed`](#message-reaction-failed) fires instead. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.reaction.added", > "created_at": "2026-05-17T14:26:00.000Z", > "data": { > "reaction_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", > "target_message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155551234", > "reaction_type": "love", > "emoji": "❤️", > "direction": "inbound" > } > } > ``` > > `reaction_type` values: `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`. > > `emoji` carries the rendered Unicode character — useful if you're displaying reactions in your own UI without maintaining a tapback-to-emoji mapping. > > `from_number` is the contact who reacted. Outbound reactions (`direction: "outbound"`) have no `from_number`. `target_message_id` is the message being reacted to, and `conversation_id` its conversation. > > `reaction_id` is the reaction's `id` from [Add a tapback](/api-reference/messages/react) for one you sent. > [accordion] message.reaction.removed > > Fired when a previously-added reaction is removed: the reactor un-tapped it, or replaced it with a different tapback (iMessage keeps one per person per message). For your own tapbacks, that's [Remove a tapback](/api-reference/messages/remove-reaction) (once the phone confirms), or a newer tapback of yours on the same message replacing one that had been sent. Same payload shape as `message.reaction.added`. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.reaction.removed", > "created_at": "2026-05-17T14:27:00.000Z", > "data": { > "reaction_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", > "target_message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "from_number": "+14155551234", > "reaction_type": "love", > "emoji": "❤️", > "direction": "inbound" > } > } > ``` > > The `reaction_id` matches the original `.added` event so you can update or delete the reaction in your own store. > [accordion] message.reaction.failed > > Fired when a tapback you sent, from the dashboard or with [Add a tapback](/api-reference/messages/react), or its removal, couldn't be sent from your line. Always `direction: "outbound"`. It fires once per reaction. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "message.reaction.failed", > "created_at": "2026-05-17T14:26:03.000Z", > "data": { > "reaction_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", > "target_message_id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d", > "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", > "reaction_type": "love", > "emoji": "❤️", > "direction": "outbound", > "action": "add", > "failed_reason": "sms_target" > } > } > ``` > > `action` is `add` when the tapback never reached the contact (no `message.reaction.added` fired for it), or `remove` when taking it back failed and it may still show on their phone. > > `failed_reason` values (treat them as opaque codes; new ones may be added): > > * `sms_target` — the message or the conversation is on SMS, where tapbacks can't be sent > * `target_not_found` — your line couldn't find the message on its side > * `bb_rejected` — your line refused the tapback > * `messages_error` — Messages on your line reported an error > * `expired` — it couldn't be sent within 10 minutes, so it was dropped rather than sent late > * `target_unsent` — the contact unsent the message before the tapback went out > * `retract_rejected` — the removal was refused (`action: "remove"`) > > [Retrieve](/api-reference/messages/retrieve) shows nothing for a failed tapback; this event is how you learn of it. A failed tapback can be sent again with [Add a tapback](/api-reference/messages/react), which starts a new reaction. > [accordion] contact.opted\_out > > Fired when a contact opts out. > > ```json theme={null} > { > "id": "evt_8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b", > "type": "contact.opted_out", > "created_at": "2026-05-17T14:28:00.000Z", > "data": { > "contact_phone": "+14155551234", > "source": "auto_keyword", > "triggered_by_message_id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e", > "opted_out_at": "2026-05-17T14:28:00.000Z" > } > } > ``` > > `source` values: > > * `auto_keyword` — auto-detected from a STOP-pattern inbound reply > * `manual` — marked opted out by a rep from the dashboard > * `ghl_dnd` — the contact was marked Do Not Disturb (SMS or all channels) in your connected GoHighLevel sub-account > * `api` — opted out through the API with [`POST /v1/contacts/{id}/opt-out`](/api-reference/contacts/opt-out) > > `triggered_by_message_id` is the inbound message that triggered the keyword detection (for `auto_keyword`), or null otherwise. > > The event fires once per person: the first time they opt out. A person who is already opted out (under any form of their number) doesn't produce another event. > > After this event, [`POST /v1/messages`](/api-reference/messages/send) to this contact returns `422 contact_opted_out`. See [Suppression & Opt-Outs](/concepts/suppression) for what happens to messages that were already accepted. ## Filtering subscriptions When creating a subscription, pass `event_types` to receive only a subset: ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/webhooks \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/replies-only", "event_types": ["message.inbound", "message.edited"] }' ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.ibubbl.com/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://api.example.com/replies-only", event_types: ["message.inbound", "message.edited"], }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.event_types); ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] res = requests.post( "https://api.ibubbl.com/v1/webhooks", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "url": "https://api.example.com/replies-only", "event_types": ["message.inbound", "message.edited"], }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["event_types"]) ``` Filtering is exact-match on the `type` field. Omitting `event_types` (or passing `null` or `[]`) subscribes to every event type, including types added later. # Webhooks Overview Source: https://docs.ibubbl.com/webhooks/overview Receive events on your server in real time instead of polling. Webhooks let iBubbl push events to your backend shortly after they happen — inbound replies from your contacts, delivery state changes, reactions, opt-outs. You avoid the latency and rate-limit cost of polling. The first delivery attempt usually arrives within a minute of the event. There is no latency guarantee, so don't build logic that depends on precise timing. ## Subscription model You register an HTTPS URL via [`POST /v1/webhooks`](/api-reference/webhooks/create). iBubbl returns a subscription with a signing secret. You can register **multiple subscriptions** per account — one per environment, one per service, or one filtered to a subset of event types. ```bash cURL theme={null} curl -X POST https://api.ibubbl.com/v1/webhooks \ -H "Authorization: Bearer $IBUBBL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/ibubbl-webhooks", "event_types": ["message.inbound", "contact.opted_out"] }' ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.ibubbl.com/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://api.example.com/ibubbl-webhooks", event_types: ["message.inbound", "contact.opted_out"], }), signal: AbortSignal.timeout(10_000), }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`); console.log(data.id, data.secret); // store the secret now ``` ```python Python theme={null} import os import requests API_KEY = os.environ["IBUBBL_API_KEY"] res = requests.post( "https://api.ibubbl.com/v1/webhooks", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "url": "https://api.example.com/ibubbl-webhooks", "event_types": ["message.inbound", "contact.opted_out"], }, timeout=10, ) data = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {data['detail']['error']}") print(data["id"], data["secret"]) # store the secret now ``` If you omit `event_types` (or pass `null` or `[]`), the subscription receives **all** events, including event types added later. See the [Webhook Events catalog](/webhooks/events) for the full list. ## Changing a subscription Subscriptions can't be edited. To change the URL or the event filter, [create](/api-reference/webhooks/create) a new subscription, switch your endpoint to its new signing secret, then [revoke](/api-reference/webhooks/revoke) the old one. Both receive events while they overlap, with the same event `id`s, so your de-duplication covers the switch. ## The signing secret The create response includes a `secret` field (format: `whsec_<64 hex chars>`). **This is the only time the full secret is returned.** Store it immediately in your secrets manager. You use the secret to verify the `X-iBubbl-Signature` header on every incoming webhook POST, ensuring the request actually came from iBubbl and wasn't forged. See [Signature verification](/guides/webhook-signatures) for the algorithm and reference verifiers in Python + Node. If you lose the secret, revoke the subscription and create a new one — iBubbl cannot recover or re-display it. ## Delivery and retries Delivery is **at least once**, in no guaranteed order. Your endpoint must return a `2xx` within 10 seconds; anything else, including a `3xx` or a timeout, is retried up to 5 attempts over about 37 minutes, then marked dead. De-duplicate on the event `id`. [Retries and deduplication](/guides/webhook-retries) has the exact schedule, what iBubbl does and doesn't do after a failure, and de-duplication code. ## Best practices > [accordion] Return 2xx fast > > Process asynchronously. iBubbl's 10s timeout is firm; slow endpoints look like failures and trigger unnecessary retries. > [accordion] De-duplicate on the event ID > > Use the event `id`, plus `data.message_id` and `type` for the rare change recorded twice. Never de-duplicate on content. See [Retries and deduplication](/guides/webhook-retries). > [accordion] Verify signatures on every request > > Don't make exceptions for "internal" testing or "trusted" networks. Signature verification is cheap and catches forged requests + misrouted traffic. See [Signature verification](/guides/webhook-signatures). > [accordion] Watch the dashboard webhook log > > **Settings → Webhooks → Recent deliveries** shows every attempt, its HTTP status and retry state. Use it to spot endpoints that are silently failing. To try it out, see [Test webhooks locally](/guides/test-webhooks-locally). ## What's next > [card] [Webhook Events](/webhooks/events) > > The catalog of event types and their payload shapes. > [card] [Signature verification](/guides/webhook-signatures) > > HMAC-SHA256 verification with reference code in Python + Node, and a test vector. > [card] [Retries and deduplication](/guides/webhook-retries) > > The retry schedule, and handling duplicates and out-of-order events. > [card] [Test webhooks locally](/guides/test-webhooks-locally) > > Receive real events on your laptop through a tunnel.