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 <key>". 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.