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 it

Node.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 use app_clip; to deep-link into an already-installed app use rich_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 optionally subcaption) — it's the text every recipient sees on the fallback card.
  • Set app_name so the fallback card is attributed to your app.
  • Set image_url for 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 — use rich_link if you need that.
  • Set app_store_id so recipients without your app land on the correct App Store listing rather than a generic "not available" state.
  • Don't rely on url to 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 a rich_link instead.

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 url is opaque to Blooio — it's whatever the sending app encoded. Your integration (or the app that owns that bundle_id) is responsible for decoding it and deciding what to do. Received bubbles can come from any iMessage app a contact used, so branch on bundle_id / team_id to 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:

  1. Receive a message.received event with message_type: "imessage_app".
  2. Branch on bundle_id / team_id to confirm it's your app.
  3. Decode url with your own logic to read the current state.
  4. Reply by sending a new imessage_app bubble whose url carries the next state (reuse the same bundle_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.