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
| Event | Description |
|---|---|
message.queued | Message accepted and queued for delivery |
message.sent | Message sent to the carrier/provider |
message.delivered | Delivery confirmed by the recipient’s device |
message.read | Read receipt received (iMessage and WhatsApp — WhatsApp only when the recipient has read receipts enabled) |
message.failed | Message delivery failed |
message.fallback | Fallback triggered (e.g., iMessage to SMS) |
message.inbound | Inbound message received from a contact (verification/OTP codes are suppressed — see below) |
message.reaction | Tapback or emoji reaction added or removed |
message.audio_kept | Contact tapped “Keep” on a voice note you sent (iMessage only) |
message.unsent | Message was unsent/retracted |
message.edited | Message was successfully edited |
message.edit_failed | Message edit failed |
message.deleted | Message was soft-deleted |
typing.indicator | Recipient is typing (WhatsApp only; see Typing Indicator below) |
whatsapp.status | WhatsApp 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.
| Event | Description |
|---|---|
chat.participant.added | A participant was added to a group chat |
chat.participant.left | A participant was removed from (or left) a group chat |
chat.title.changed | A group chat’s title was set or changed |
chat.photo.changed | A group chat’s photo was updated |
Scheduled Message Events
| Event | Description |
|---|---|
message.scheduled | Message scheduled for future delivery |
message.schedule_cancelled | Scheduled message was cancelled |
FaceTime Events
| Event | Description |
|---|---|
facetime.incoming | Incoming FaceTime call detected |
facetime.status_changed | Call 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
| Field | Description |
|---|---|
callUuid | Unique identifier for the call session |
caller / address | Phone number or email of the other party. facetime.incoming uses caller; facetime.status_changed uses address |
to | The phone number or email of the receiving iMessage instance |
isAudio / isVideo | Distinguish 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 |
status | Current call status. Possible values: "incoming", "answered", "disconnected", "missed", "rejected", "failed" |
isOutgoing | Whether the call was initiated by the instance (true) or received (false). Present on facetime.status_changed events |
timestamp | Unix 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"
}
}| Field | Description |
|---|---|
messageId | Internal TextBubbles message ID |
externalMessageId | Provider-level GUID |
channel | imessage, sms, or whatsapp |
readAt | ISO-8601 timestamp (present on message.read only) |
message.readfires for iMessage and WhatsApp when the recipient has read receipts enabled on their device. It is a distinct event frommessage.delivered— do not collapse them when tracking delivery state. SMS does not emitmessage.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.
| Event | iMessage | SMS | |
|---|---|---|---|
message.sent | reliable | reliable | reliable |
message.delivered | reliable | best-effort¹ | reliable |
message.read | when enabled² | not emitted | when enabled² |
message.failed | reliable | reliable | reliable |
message.fallback | reliable (on iMessage → SMS fallback) | n/a | n/a |
message.inbound | reliable | reliable | reliable |
message.reaction (outbound from contact) | reliable (native tapbacks + iOS 17+ emoji) | not native³ | reliable |
typing.indicator | not emitted | not emitted | reliable |
¹ 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
| Field | Type | Description |
|---|---|---|
messageId | string | Internal 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 |
externalMessageId | string | Provider-level identifier (iMessage GUID or WhatsApp message id) — use for correlating with provider logs |
from | string | Sender’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 |
to | string | Receiving phone number or email (identifies which instance received the message) |
text | string | Message text content |
channel | string | Channel the message was received on: imessage, sms, or whatsapp |
parentMessageId | string | null | Internal 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 |
replyTo | object | absent | WhatsApp 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 |
attachments | array | List 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[].guid | string | undefined | Unique identifier for the attachment. Present for iMessage attachments; omitted on other channels |
attachments[].mimeType | string | MIME type of the file (e.g., image/jpeg, video/mp4, audio/aac) |
attachments[].filename | string | undefined | Original filename when the sender supplied one |
attachments[].totalBytes | number | File size in bytes |
attachments[].downloadUrl | string | Pre-signed URL to download the attachment. Time-limited (1 hour) and self-authenticated — no API key required. Fetch directly via GET request |
attachments[].note | string | undefined | Present 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 |
otp | object | absent | Present 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), notexternalMessageId, 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"
}
}| Field | Type | Description |
|---|---|---|
state | string | Always "composing" today. Future states (paused, etc.) may be added |
from | string | E.164 phone number of the contact who is typing |
channel | string | Always "whatsapp" — iMessage typing indicators are not surfaced as webhooks |
customerId | string | Customer 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"
}
}| Field | Type | Description |
|---|---|---|
phoneNumber | string | The TextBubbles number whose session changed |
status | string | qr_pending / connecting / connected / disconnected |
qrCode | string | null | Present when status is qr_pending — raw QR string, render with any library |
jid | string | null | Present when status is connected — the paired WhatsApp JID (usually <phone>:<device>@s.whatsapp.net) |
disconnectReason | string | null | Present when status is disconnected — auth_revoked, phone_number_mismatch, or close_<code> |
customerId | string | Customer 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"
}
}| Field | Description |
|---|---|
reaction.type | Reaction 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.emoji | The emoji character. Present on iMessage when type is emoji (iOS 17+), and on WhatsApp whenever a non-empty emoji was sent |
reaction.targetMessageId | Internal messageId the reaction was applied to (null if not found) |
reaction.targetExternalId | Provider-assigned GUID of the original message |
reaction.content | Human-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"
}
}| Field | Description |
|---|---|
externalMessageId | Provider-assigned GUID of the Keep system event itself |
targetMessageId | Internal 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 |
targetExternalId | Provider-assigned GUID of the audio message that was kept |
from | Contact who tapped Keep |
to | Instance 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"
}
}| Field | Description |
|---|---|
chatId | Provider chat identifier |
participant | Contact added to (or removed from) the chat |
actor | Contact 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"
}
}| Field | Description |
|---|---|
title | The new chat title |
actor | Contact 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();