type field tells you which event it is; the data field has the event-specific payload.
Envelope
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.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 — 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.
Each event carries the value at the moment it fired;
GET /v1/messages/{id} returns the latest. message.queued doesn’t carry channel, because nothing has been sent yet. See Lines and routing.
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, and it’s what Mark a conversation read and Start typing take, so you can act on a message.inbound without looking anything up first. See Typing while your AI thinks.
Ordering and duplicates
- Events can arrive out of order. Retries, and events that fire close together, mean
message.readcan reach you beforemessage.delivered. Order by the envelope’screated_at(and treatstatustransitions 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 ondata.message_idplustype. - Failed deliveries are retried up to 5 times over about 37 minutes. See Retries and deduplication for the schedule and de-duplication code.
- A message’s status can move “backwards”.
message.failedcan followmessage.sent(and evenmessage.read), and a failed message can later move todeliveredorread. Seemessage.failed. When in doubt,GET /v1/messages/{id}returns the current state.
Event types
message.queued
message.queued
Fired when a one-to-one message is accepted. That covers every accepted one-to-one send: For a
status: "pending" with failed_reason: null when it is on its way (including one waiting for the contact’s sending hours), or status: "queued" when it is parked by a capacity hold. Group sends do not fire this event.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). (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 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 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.message.sent
message.sent
Fired when the message has gone out from your iBubbl line.
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.message.delivered
message.delivered
Fired when the recipient’s device confirms delivery. Same payload shape as If delivery and read are reported together, you may receive only
message.sent, with status: "delivered" and occurred_at set to the delivery time.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.message.read
message.read
Fired when the recipient reads the message (only when they have read receipts turned on). Same payload shape as 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.sent, with status: "read" and occurred_at set to the read time.message.read only for the message the receipt was for.message.failed
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}) 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.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:<code>— iMessage reported another failure;<code>is its error numbermac_send_failed— your line couldn’t send it, or it wasn’t confirmed sent within 10 minutesbluebubbles_unreachable— your line couldn’t be reached at send timeagent_offline— your line was offline for over an hour, so the message was failed rather than sent lateline_inactive— the line was suspended or retired before the message went outqueue_expired— the message was stillqueued60–65 minutes after it was accepted, so it was dropped. This one arrives withstatus: "expired", not"failed", andchannel: null. Itsidempotency_keyis 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 outcold_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).
message.cancelled
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, or queued by a capacity hold. iBubbl also cancels an after-hours auto-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.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.message.inbound
message.inbound
Fired when a contact replies to you. This is the event most integrations care about.
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:kindis one ofimage,gif,video,audio,file.urlis 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": trueand nourl. - 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: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 for the full model, including why participants can list people who have left.If the inbound body matches a STOP-pattern keyword (see Suppression & Opt-Outs), 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.message.edited
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.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.message.reaction.added
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, fires as direction: "outbound" once it has reached the contact’s phone; if it can’t be sent, message.reaction.failed fires instead.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 for one you sent.message.reaction.removed
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 (once the phone confirms), or a newer tapback of yours on the same message replacing one that had been sent. Same payload shape as The
message.reaction.added.reaction_id matches the original .added event so you can update or delete the reaction in your own store.message.reaction.failed
message.reaction.failed
Fired when a tapback you sent, from the dashboard or with Add a tapback, or its removal, couldn’t be sent from your line. Always
direction: "outbound". It fires once per reaction.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 senttarget_not_found— your line couldn’t find the message on its sidebb_rejected— your line refused the tapbackmessages_error— Messages on your line reported an errorexpired— it couldn’t be sent within 10 minutes, so it was dropped rather than sent latetarget_unsent— the contact unsent the message before the tapback went outretract_rejected— the removal was refused (action: "remove")
contact.opted_out
contact.opted_out
Fired when a contact opts out.
source values:auto_keyword— auto-detected from a STOP-pattern inbound replymanual— marked opted out by a rep from the dashboardghl_dnd— the contact was marked Do Not Disturb (SMS or all channels) in your connected GoHighLevel sub-accountapi— opted out through the API withPOST /v1/contacts/{id}/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 to this contact returns 422 contact_opted_out. See Suppression & Opt-Outs for what happens to messages that were already accepted.Filtering subscriptions
When creating a subscription, passevent_types to receive only a subset:
type field. Omitting event_types (or passing null or []) subscribes to every event type, including types added later.
