Custom bubbles (iMessage apps)
Send and receive bring-your-own iMessage app extension bubbles
A custom bubble is a Messages app-extension bubble — the rich, tappable card an iMessage app renders inline in a conversation. Blooio lets you send a bubble that points at your own iMessage app extension, and receive the bubbles your contacts send back as structured data.
Note
Custom bubbles are Blooio iMessage only (peer-to-peer). Blooio is the transport, not the renderer: recipients who have the app installed see its interactive card; everyone else sees a native template card with Apple's built-in App Store fallback.
Sending a custom bubble
Send the imessage_app content type. You identify your own signed iMessage
app extension with bundle_id + team_id, and pass the app-state url your
extension reads when the recipient taps the bubble.
| Field | Required | Description |
|---|---|---|
bundle_id |
yes | Your iMessage extension's bundle id (e.g. com.example.app.imessage). |
team_id |
yes | Your Apple Developer Team ID. Combined with bundle_id to form the balloon plugin id. |
url |
yes | The app-state string your extension understands. Opaque to Blooio — you define its meaning. |
app_name |
no | Name shown on the template card (the not-installed fallback). |
caption |
no | Caption line on the template card. |
subcaption |
no | Secondary line on the template card. |
image_url |
no | Image shown on the template card. |
app_store_id |
no | App Store id (adam id) for the "get this app" link when the recipient doesn't have your app. |
cURL
curl -X POST 'https://api.blooio.com/v4/channels/CHANNEL_ID/messages' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"to": "+15551234567",
"imessage_app": {
"bundle_id": "com.example.app.imessage",
"team_id": "TEAMID1234",
"url": "https://example.com/state?s=eyJzdGVwIjoxfQ",
"app_name": "Example App",
"caption": "Your turn!"
}
}'Try itNode.js
await fetch('https://api.blooio.com/v4/channels/CHANNEL_ID/messages', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: '+15551234567',
imessage_app: {
bundle_id: 'com.example.app.imessage',
team_id: 'TEAMID1234',
url: 'https://example.com/state?s=eyJzdGVwIjoxfQ',
app_name: 'Example App',
caption: 'Your turn!',
},
}),
})Python
requests.post(
'https://api.blooio.com/v4/channels/CHANNEL_ID/messages',
headers={
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
json={
'to': '+15551234567',
'imessage_app': {
'bundle_id': 'com.example.app.imessage',
'team_id': 'TEAMID1234',
'url': 'https://example.com/state?s=eyJzdGVwIjoxfQ',
'app_name': 'Example App',
'caption': 'Your turn!',
},
},
)Note
The interactive overlay only renders for recipients who have your app installed — Blooio does not host or ship a renderer. Everyone else sees the template card built from
caption/app_name/image_url, plus Apple's App Store fallback. For an instant, no-install experience useapp_clip; to deep-link into an already-installed app userich_link.
How the card looks — and what happens on tap
This is the part that surprises people, so read it before you pick imessage_app.
The fallback card is compact. When the recipient does not have your app,
Messages renders a small "thumbnail + caption" row — a little leading image (from
image_url) next to caption / subcaption. It is not a large hero-image
card, and there is no way to make the fallback card big; that layout is fixed
by Messages. A large edge-to-edge image only appears inside your app's own
interactive view, which only recipients who have your app installed will see.
Tapping is app-gated. Tapping the bubble opens your app only if the
recipient has your exact extension installed. If they don't, Messages sends them
to the App Store (to the app_store_id listing if you provided one). It does
not open url in a browser — url is app state, only meaningful to your
extension. So a custom bubble can't "open a link" for someone who doesn't have
your app.
If you want a big, tappable card instead
If your goal is a marketing/notification card — a large image that opens content when tapped, on every device, with no app required — use a different content type:
| You want… | Use | Tap behavior |
|---|---|---|
| A large image card that opens a link | rich_link |
Opens the URL |
| An instant, no-install mini experience | app_clip |
Launches an App Clip |
| Your own interactive app bubble | imessage_app |
Opens your app if installed, else the App Store |
What to include (and skip)
- Always set
caption(and optionallysubcaption) — it's the text every recipient sees on the fallback card. - Set
app_nameso the fallback card is attributed to your app. - Set
image_urlfor the small leading thumbnail. Keep it square and small (thumbnail-sized); it is not rendered as a full-width banner, so a large hero image is wasted here — userich_linkif you need that. - Set
app_store_idso recipients without your app land on the correct App Store listing rather than a generic "not available" state. - Don't rely on
urlto open anything for recipients who lack your app — it is only handed to your extension. Put anything a general audience should be able to open in arich_linkinstead.
Receiving a custom bubble
When a contact sends a custom bubble back, its text is just a placeholder glyph
on the wire — the meaningful content lives in the bubble's payload. Blooio
decodes it for you and delivers a message.received webhook with
message_type: "imessage_app" and a structured imessage_app object (symmetric
to the send shape), so you never have to parse Apple's archive yourself.
| Field | Description |
|---|---|
balloon_bundle_id |
Full Messages plugin id: com.apple.messages.MSMessageExtensionBalloonPlugin:<team_id>:<bundle_id>. |
team_id |
The sending app's Apple Developer Team ID. |
bundle_id |
The sending app's iMessage extension bundle id — use it to route to the right handler. |
app_store_id |
App Store numeric id, when present. |
app_name |
App name carried on the bubble. |
url |
The app-state string the sender's extension wrote. This is the payload — decode it in your own app logic. |
caption |
Caption line, if any. |
subcaption |
Secondary line, if any. |
summary |
Short summary / accessibility label. |
layout_class |
Messages layout class (e.g. MSMessageTemplateLayout). |
session_id |
Session identifier that correlates a multi-step exchange (e.g. the turns of a back-and-forth), when present. |
The top-level text mirrors the bubble's caption so text-only consumers still
see something readable instead of a placeholder.
{
"type": "message.received",
"created_at": 1730000000000,
"organization_id": "org_EXAMPLE",
"data": {
"kind": "received",
"message_type": "imessage_app",
"text": "Your turn!",
"sender": "+15551234567",
"recipient": "+15557654321",
"channel_address": "+15557654321",
"chat_id": "chat_EXAMPLE",
"message_id": "msg_EXAMPLE",
"protocol": "imessage",
"attachments": [],
"imessage_app": {
"balloon_bundle_id": "com.apple.messages.MSMessageExtensionBalloonPlugin:TEAMID1234:com.example.app.imessage",
"team_id": "TEAMID1234",
"bundle_id": "com.example.app.imessage",
"app_store_id": 1234567890,
"app_name": "Example App",
"url": "https://example.com/state?s=eyJ0dXJuIjoyfQ",
"caption": "Your turn!",
"subcaption": null,
"summary": "Example App",
"layout_class": "MSMessageTemplateLayout",
"session_id": "3F2504E0-4F89-41D3-9A0C-0305E82C3301"
}
}
}Note
The
urlis opaque to Blooio — it's whatever the sending app encoded. Your integration (or the app that owns thatbundle_id) is responsible for decoding it and deciding what to do. Received bubbles can come from any iMessage app a contact used, so branch onbundle_id/team_idto handle the ones you support and ignore the rest.
Building a stateful experience
url is enough to build a full back-and-forth on top of a custom bubble for
your own extension:
- Receive a
message.receivedevent withmessage_type: "imessage_app". - Branch on
bundle_id/team_idto confirm it's your app. - Decode
urlwith your own logic to read the current state. - Reply by sending a new
imessage_appbubble whoseurlcarries the next state (reuse the samebundle_id/team_id).
Carry anything your extension needs to link the turns together inside url.
session_id is inbound-only: it tells you which of a contact's bubbles belong
to the same exchange, but a send cannot continue that session — every bubble
you send starts a new one.
Replaying a bubble from an app you don't own (for example, sending back a game invitation another app created) is not supported. Its extension decides what to do with state it did not write, and Blooio cannot make it open.
Delivery status
An imessage_app send follows the usual message lifecycle, minus sent: the
send returns queued (and fires message.queued), then message.delivered
when the recipient's device receives it, and message.read if they have read
receipts on. Track it with the same webhooks and
message status endpoint as any other
message.
Related
- Find My & Check In bubbles — Apple's first-party iMessage apps
- Attachments & carousels — media bubbles and the
badgeattribution - Link previews — rich link cards (
rich_link) - Webhooks — subscribe to
message.received - Webhook signatures — verify inbound webhooks