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 + session_id are enough to build a full back-and-forth on top of a custom bubble:

  1. Receive a message.received event with message_type: "imessage_app".
  2. Branch on bundle_id / team_id to confirm it's an app you support.
  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).

Use session_id to correlate the messages that belong to the same exchange.