WebhooksEvent Reference

Event Reference

All webhook event types and their payloads.

Fan-out: if you have multiple webhooks registered and more than one is subscribed to an event type, each subscribed webhook receives its own delivery of that event — signed with that webhook’s own secret. See Webhook Configuration for how to register multiple webhooks.

Payload Structure

Every webhook event follows this structure:

{
  "id": "evt_550e8400-e29b-41d4-a716-446655440000",
  "type": "message.delivered",
  "timestamp": "2026-03-28T10:00:03.000Z",
  "data": {
    "messageId": "msg_xyz",
    "externalMessageId": "external-guid",
    "from": "+19876543210",
    "to": "+14155551234",
    "text": "Hello!",
    "channel": "imessage",
    "metadata": { "customKey": "customValue" },
    "status": "delivered"
  }
}

Message Events

EventDescription
message.queuedMessage accepted and queued for delivery
message.sentMessage sent to the carrier/provider
message.deliveredDelivery confirmed by the recipient’s device
message.readRead receipt received (iMessage and WhatsApp — WhatsApp only when the recipient has read receipts enabled)
message.failedMessage delivery failed
message.fallbackFallback triggered (e.g., iMessage to SMS)
message.inboundInbound message received from a contact (verification/OTP codes are suppressed — see below)
message.reactionTapback or emoji reaction added or removed
message.audio_keptContact tapped “Keep” on a voice note you sent (iMessage only)
message.unsentMessage was unsent/retracted
message.editedMessage was successfully edited
message.edit_failedMessage edit failed
message.deletedMessage was soft-deleted
typing.indicatorRecipient is typing (WhatsApp only; see Typing Indicator below)
whatsapp.statusWhatsApp session changed pairing state (qr_pending / connecting / connected / disconnected). Realtime-only — not sent to webhook URLs; subscribe via the Realtime/SSE stream

Chat Events

Fired when a contact performs a chat-state action — currently sourced from iMessage group-chat updates. Customers typically use these to keep a local mirror of chat metadata in sync.

EventDescription
chat.participant.addedA participant was added to a group chat
chat.participant.leftA participant was removed from (or left) a group chat
chat.title.changedA group chat’s title was set or changed
chat.photo.changedA group chat’s photo was updated

Scheduled Message Events

EventDescription
message.scheduledMessage scheduled for future delivery
message.schedule_cancelledScheduled message was cancelled

FaceTime Events

EventDescription
facetime.incomingIncoming FaceTime call detected
facetime.status_changedCall status update (answered, disconnected, etc.)

facetime.incoming

Fired when an incoming FaceTime call is detected on an instance.

{
  "type": "facetime.incoming",
  "eventId": "ft-event-uuid",
  "callUuid": "call-uuid-123",
  "caller": "+14155551234",
  "to": "+19876543210",
  "isAudio": false,
  "isVideo": true,
  "timestamp": 1711584000000
}

facetime.status_changed

Fired when the status of a FaceTime call changes (e.g., the call is answered or disconnected).

{
  "type": "facetime.status_changed",
  "eventId": "ft-event-uuid",
  "callUuid": "call-uuid-123",
  "address": "+14155551234",
  "to": "+19876543210",
  "status": "answered",
  "isAudio": false,
  "isVideo": true,
  "isOutgoing": false
}

FaceTime field reference

FieldDescription
callUuidUnique identifier for the call session
caller / addressPhone number or email of the other party. facetime.incoming uses caller; facetime.status_changed uses address
toThe phone number or email of the receiving iMessage instance
isAudio / isVideoDistinguish between FaceTime Audio and FaceTime Video calls. A FaceTime Audio call has isAudio: true, isVideo: false; a FaceTime Video call has isAudio: false, isVideo: true
statusCurrent call status. Possible values: "incoming", "answered", "disconnected", "missed", "rejected", "failed"
isOutgoingWhether the call was initiated by the instance (true) or received (false). Present on facetime.status_changed events
timestampUnix epoch milliseconds when the call was detected. Present on facetime.incoming events

Status Event Payloads

Delivery-status events (message.sent, message.delivered, message.read, message.failed, etc.) all share the same shape:

{
  "id": "evt_abc123",
  "type": "message.read",
  "timestamp": "2026-03-28T10:00:07.000Z",
  "data": {
    "messageId": "msg_xyz789",
    "externalMessageId": "AB12CD34-5678-90EF-1234-567890ABCDEF",
    "channel": "imessage",
    "readAt": "2026-03-28T10:00:07.000Z"
  }
}
FieldDescription
messageIdInternal TextBubbles message ID
externalMessageIdProvider-level GUID
channelimessage, sms, or whatsapp
readAtISO-8601 timestamp (present on message.read only)

message.read fires for iMessage and WhatsApp when the recipient has read receipts enabled on their device. It is a distinct event from message.delivered — do not collapse them when tracking delivery state. SMS does not emit message.read.

Per-Channel Reliability

Not every event fires for every channel. The matrix below documents which events your endpoint can rely on per channel — code that branches on channel should use this rather than assume parity.

EventiMessageSMSWhatsApp
message.sentreliablereliablereliable
message.deliveredreliablebest-effort¹reliable
message.readwhen enabled²not emittedwhen enabled²
message.failedreliablereliablereliable
message.fallbackreliable (on iMessage → SMS fallback)n/an/a
message.inboundreliablereliablereliable
message.reaction (outbound from contact)reliable (native tapbacks + iOS 17+ emoji)not native³reliable
typing.indicatornot emittednot emittedreliable

¹ SMS message.delivered: SMS sent through the iMessage provider’s continuity bridge relies on the carrier returning a delivery receipt. Some carriers, some recipients, and some message types do not produce a receipt, in which case message.delivered will never arrive even though the message was delivered. Treat message.sent as the only reliable success signal for SMS, and use message.failed for explicit failures.

² message.read requires the recipient to have read receipts enabled on their device for that conversation. iMessage users can disable read receipts globally or per-conversation; WhatsApp users can disable them globally. If the recipient has them off, the message is read but no event fires.

³ SMS has no native reaction protocol. When an SMS user reacts to your message on iOS, their phone sends the reaction as a plain text message like Loved "your original text". TextBubbles surfaces these as ordinary message.inbound events with the literal text — not as message.reaction events. Conversely, sending a reaction targeting an SMS message via POST /v1/messages/:id/reactions returns CHANNEL_NOT_SUPPORTED.

Inbound Message Payload

{
  "id": "evt_abc123",
  "type": "message.inbound",
  "timestamp": "2026-03-28T10:00:00.000Z",
  "data": {
    "messageId": "msg_xyz789",
    "externalMessageId": "AB12CD34-5678-90EF-1234-567890ABCDEF",
    "from": "+14155551234",
    "to": "+19876543210",
    "text": "Check out this photo",
    "channel": "imessage",
    "parentMessageId": null,
    "attachments": [
      {
        "guid": "att_550e8400-e29b-41d4-a716-446655440000",
        "mimeType": "image/jpeg",
        "filename": "photo.jpg",
        "totalBytes": 248000,
        "downloadUrl": "https://api.textbubbles.com/v1/attachments/eyJpbnN0YW5jZUlkIjoiLi4uIn0.HMAC_SIGNATURE"
      }
    ]
  }
}

Inbound Message Fields

FieldTypeDescription
messageIdstringInternal TextBubbles message ID. Use this when calling endpoints like POST /v1/messages/:id/reactions or PUT /v1/messages/:id in response to the inbound message
externalMessageIdstringProvider-level identifier (iMessage GUID or WhatsApp message id) — use for correlating with provider logs
fromstringSender’s phone number in E.164 (or email for iMessage email addresses). For WhatsApp senders on WA’s LID privacy mode, TextBubbles resolves the LID to the real E.164 number before emitting the webhook
tostringReceiving phone number or email (identifies which instance received the message)
textstringMessage text content
channelstringChannel the message was received on: imessage, sms, or whatsapp
parentMessageIdstring | nullInternal messageId this inbound is replying to. Set for iMessage thread replies and WhatsApp quoted replies — a consumer that reads only parentMessageId works on both channels
replyToobject | absentWhatsApp only. Provider-level detail about the quoted parent — same internal messageId as parentMessageId, plus the WhatsApp externalMessageId for correlating with WA-indexed stores. Omit if the inbound was not a quoted reply
attachmentsarrayList of file attachments on the message (empty array if none). Same shape across all channels — your inbox code does not need to branch on channel to fetch media
attachments[].guidstring | undefinedUnique identifier for the attachment. Present for iMessage attachments; omitted on other channels
attachments[].mimeTypestringMIME type of the file (e.g., image/jpeg, video/mp4, audio/aac)
attachments[].filenamestring | undefinedOriginal filename when the sender supplied one
attachments[].totalBytesnumberFile size in bytes
attachments[].downloadUrlstringPre-signed URL to download the attachment. Time-limited (1 hour) and self-authenticated — no API key required. Fetch directly via GET request
attachments[].notestring | undefinedPresent only on rare failure cases when the attachment metadata reached the platform but the bytes couldn’t be persisted. When present, downloadUrl will be absent
otpobject | absentPresent when TextBubbles detected a 2FA / verification code in the inbound body (WhatsApp signup, bank OTP, etc.). See OTP detection below

Replying to inbound messages: always use messageId (internal), not externalMessageId, when invoking TextBubbles API endpoints. For example, to react to the inbound message above, POST /v1/messages/msg_xyz789/reactions.

Verification / OTP codes are suppressed

Inbound messages detected as one-time passcodes, 2FA codes, password-reset codes, or other verification codes (e.g. Apple ID, Google, bank account codes) are not dispatched as message.inbound webhooks or emitted on the /v1/events stream. They are also hidden from GET /v1/messages.

Example: WhatsApp quoted reply

When a contact quote-replies to a message you sent, the webhook surfaces the parent reference so you don’t need to index externalMessageIds yourself:

{
  "id": "evt_abc123",
  "type": "message.inbound",
  "timestamp": "2026-04-20T20:44:43.000Z",
  "data": {
    "messageId": "msg_cad993d1-13f4-47e5-af60-b926d142e8f9",
    "externalMessageId": "3A7D053CB47169B2734B",
    "from": "+14155551234",
    "to": "+16282895642",
    "text": "Reply",
    "channel": "whatsapp",
    "customerId": "4b02f4ab-35d9-444a-b0ee-4c2a3edd347c",
    "replyTo": {
      "messageId": "msg_6d8ef255-7e5a-40ad-8cc2-4816cf9b039d",
      "externalMessageId": "3EB0E921E1F353BB23A1B8"
    }
  }
}

Typing Indicator

WhatsApp contacts emit a typing event while composing a message. The webhook surfaces each emission as a single typing.indicator event. WhatsApp does not emit a corresponding “stopped typing” event — the stream simply goes quiet.

{
  "id": "evt_abc123",
  "type": "typing.indicator",
  "timestamp": "2026-04-20T20:31:16.000Z",
  "data": {
    "state": "composing",
    "from": "+14155551234",
    "channel": "whatsapp",
    "customerId": "4b02f4ab-35d9-444a-b0ee-4c2a3edd347c"
  }
}
FieldTypeDescription
statestringAlways "composing" today. Future states (paused, etc.) may be added
fromstringE.164 phone number of the contact who is typing
channelstringAlways "whatsapp" — iMessage typing indicators are not surfaced as webhooks
customerIdstringCustomer the event belongs to

Recommended consumer behavior: treat each typing.indicator event as “show a typing bubble for ~5 seconds.” WhatsApp re-emits the event every few seconds while the contact keeps typing, so the bubble stays on as long as they continue. When emissions stop, the bubble times out naturally.

WhatsApp Session Status

whatsapp.status fires every time a WhatsApp session’s pairing state changes — useful for driving a live pairing UI without polling GET /v1/whatsapp/numbers/:phoneNumber/status. Delivered on the Realtime/SSE stream only (not to webhook URLs).

{
  "id": "evt_abc123",
  "type": "whatsapp.status",
  "timestamp": "2026-04-20T20:40:00.000Z",
  "data": {
    "phoneNumber": "+16282895642",
    "status": "connected",
    "jid": "16282895642:2@s.whatsapp.net",
    "customerId": "4b02f4ab-35d9-444a-b0ee-4c2a3edd347c"
  }
}
FieldTypeDescription
phoneNumberstringThe TextBubbles number whose session changed
statusstringqr_pending / connecting / connected / disconnected
qrCodestring | nullPresent when status is qr_pending — raw QR string, render with any library
jidstring | nullPresent when status is connected — the paired WhatsApp JID (usually <phone>:<device>@s.whatsapp.net)
disconnectReasonstring | nullPresent when status is disconnectedauth_revoked, phone_number_mismatch, or close_<code>
customerIdstringCustomer the session belongs to

Subscribe from the textbubbles UI or from any SSE-capable client via GET /v1/events?events=whatsapp.status.

Reaction Events

Reaction webhooks include structured data about the reaction:

{
  "id": "evt_abc123",
  "type": "message.reaction",
  "timestamp": "2026-03-28T10:00:00.000Z",
  "data": {
    "reaction": {
      "type": "love",
      "targetMessageId": "msg_xyz789",
      "targetExternalId": "guid-of-original-message",
      "content": "Loved \"Hello!\""
    },
    "from": "+14155551234",
    "channel": "imessage"
  }
}

Emoji and sticker reactions (iOS 17+ / macOS 14+) include an emoji field:

{
  "id": "evt_def456",
  "type": "message.reaction",
  "timestamp": "2026-03-28T10:00:05.000Z",
  "data": {
    "reaction": {
      "type": "emoji",
      "emoji": "🥓",
      "targetMessageId": "msg_xyz789",
      "targetExternalId": "guid-of-original-message",
      "content": null
    },
    "from": "+14155551234",
    "channel": "imessage"
  }
}
FieldDescription
reaction.typeReaction type. iMessage: love, like, dislike, laugh, emphasize, question, emoji, remove. WhatsApp: the raw emoji character (e.g. ❤️, 🔥), or the literal string "removed" when the reaction was cleared
reaction.emojiThe emoji character. Present on iMessage when type is emoji (iOS 17+), and on WhatsApp whenever a non-empty emoji was sent
reaction.targetMessageIdInternal messageId the reaction was applied to (null if not found)
reaction.targetExternalIdProvider-assigned GUID of the original message
reaction.contentHuman-readable description or preview of the target message text (null for emoji-only reactions on iMessage)

Audio Kept Events

Fired when a contact taps Keep on a voice note you sent. iMessage auto-deletes voice notes after two minutes unless the recipient keeps them — tapping Keep is an explicit retention signal, useful as a lightweight engagement event.

{
  "id": "evt_audio_kept_123",
  "type": "message.audio_kept",
  "timestamp": "2026-04-21T09:17:46.007Z",
  "data": {
    "externalMessageId": "D6ADF04F-EF82-45F6-BF46-CF7F2B20C9AE",
    "targetMessageId": "msg_cdf628ad-3ed3-49aa-a10f-a0ea19c9220c",
    "targetExternalId": "4DE4DB87-1681-4DC7-A77B-3967BCA74BF7",
    "from": "+14155551234",
    "to": "+13105550199",
    "channel": "imessage"
  }
}
FieldDescription
externalMessageIdProvider-assigned GUID of the Keep system event itself
targetMessageIdInternal message ID of the audio message that was kept. null if the audio predates our retention or was sent through a different iMessage instance — targetExternalId is still populated so you can cross-reference if you store provider GUIDs
targetExternalIdProvider-assigned GUID of the audio message that was kept
fromContact who tapped Keep
toInstance phone number the audio was sent from

Chat Event Payloads

All four chat events share the same envelope. Each carries externalMessageId (the provider-assigned GUID of the underlying system event), chatId (provider chat identifier — for 1:1 chats this is the contact address; for group chats it’s an opaque identifier you can match against the chat resource), and actor (who performed the action).

chat.participant.added / chat.participant.left

{
  "id": "evt_participant_added",
  "type": "chat.participant.added",
  "timestamp": "2026-04-21T10:00:00.000Z",
  "data": {
    "externalMessageId": "<system-event-guid>",
    "chatId": "iMessage;+;chat1234567890",
    "participant": "+14155551235",
    "actor": "+14155551234",
    "to": "+13105550199",
    "channel": "imessage"
  }
}
FieldDescription
chatIdProvider chat identifier
participantContact added to (or removed from) the chat
actorContact who performed the action

chat.title.changed

{
  "id": "evt_title_changed",
  "type": "chat.title.changed",
  "timestamp": "2026-04-21T10:05:00.000Z",
  "data": {
    "externalMessageId": "<system-event-guid>",
    "chatId": "iMessage;+;chat1234567890",
    "title": "Weekend plans",
    "actor": "+14155551234",
    "to": "+13105550199",
    "channel": "imessage"
  }
}
FieldDescription
titleThe new chat title
actorContact who changed the title

chat.photo.changed

{
  "id": "evt_photo_changed",
  "type": "chat.photo.changed",
  "timestamp": "2026-04-21T10:10:00.000Z",
  "data": {
    "externalMessageId": "<system-event-guid>",
    "chatId": "iMessage;+;chat1234567890",
    "actor": "+14155551234",
    "to": "+13105550199",
    "channel": "imessage"
  }
}

The event does not include the new photo — retrieve it through the chat resource.

Subscribing to Events

Select which events to receive when configuring your webhook:

curl -X PUT https://api.textbubbles.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/textbubbles",
    "events": [
      "message.sent",
      "message.delivered",
      "message.read",
      "message.failed",
      "message.inbound",
      "message.reaction",
      "message.inbound"
    ]
  }'

To subscribe to all events, use the wildcard:

curl -X PUT https://api.textbubbles.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/textbubbles",
    "events": ["*"]
  }'

Realtime SSE

If you cannot host a public webhook URL — for example during local development, inside a script, or from a browser app — open a Server-Sent Events stream to GET /v1/events instead. The stream emits the same event types and payloads as webhooks, over a single long-lived HTTP connection.

curl -N -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.textbubbles.com/v1/events?events=message.inbound"

The events query parameter is an optional comma-separated filter; omit it (or pass *) to receive every event.

Each frame in the stream is a standard SSE message:

id: evt_abc
event: message.inbound
data: {"id":"evt_abc","type":"message.inbound","timestamp":"2026-04-14T10:00:00.000Z","data":{"messageId":"msg_...","from":"+14155551234","text":"Hi"}}

A : heartbeat comment is sent every 15 seconds so clients can detect dead connections.

SSE vs webhooks: SSE is fire-and-forget — events emitted while no client is connected are not replayed. Use webhooks when you need at-least-once delivery with retries. SSE is ideal as a lightweight complement: live UI updates during development, admin dashboards, or inbound-message relays where occasional gaps during reconnects are acceptable.

The TypeScript SDK ships a ready-made SSE client:

import { TextBubblesEventClient } from "@textbubbles/js";
 
const events = new TextBubblesEventClient({
  url: "https://api.textbubbles.com/v1/events",
  headers: { Authorization: `Bearer ${process.env.TEXTBUBBLES_API_KEY}` },
});
 
events.on("message.inbound", (e) => console.log(e.data.from, e.data.text));
events.connect();