Webhooks
Receive inbound messages and lifecycle events in real time, and verify their signatures.
Webhooks deliver events to your server as they happen. Create a subscription with POST /v4/webhooks:
curl -X POST https://api.blooio.com/v4/webhooks \
-H "Authorization: Bearer bl_live_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/blooio" }'Try itThe creation response includes a signing_secret once — store it to verify deliveries.
Event payload
Each delivery is a JSON envelope identifying the event, with the resource it relates to in data:
{
"id": "evt_...",
"type": "message.received",
"created_at": 1718000000000,
"organization_id": "org_...",
"data": { }
}Subscriptions registered through the v2 webhook endpoint keep receiving the flat body they were built for:
{
"event": "message.received",
"message_id": "msg_...",
"chat_id": "+15551234567",
"data": { }
}Verifying signatures
Each delivery is signed with your signing_secret (HMAC-SHA256) and sent in the X-Blooio-Signature header. Recompute the signature over {timestamp}.{raw_body} and compare it before trusting the payload — see Verify webhook signatures.
Event types
A subscription receives every event type — inbound and outbound message events (received, sent, delivered, failed, read, reactions), polls, groups, and contact card events. Switch on type in your handler to pick out the ones you act on.
Inspect and replay past deliveries with GET /webhooks/{webhookId}/deliveries, or with GET /webhooks/{webhookId}/logs for a subscription registered through the v2 endpoint.
Webhooks deliver events to your server as they happen. Create a subscription with POST /webhooks — the body is just the HTTPS URL to deliver to:
curl -X POST https://api.blooio.com/v4/webhooks \
-H "Authorization: Bearer bl_live_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/blooio" }'Try itThe creation response includes a signing_secret once — store it to verify deliveries.
Event delivery, signature verification, acknowledgement, and retries. Click a step for details.
Event envelope
Every delivery shares the same envelope. type is the event name, api_version is the payload version the subscription receives, created_at is epoch milliseconds, and data carries the fully-hydrated resource — the message body, poll, group, or chat endpoints — so you never have to call the API back to fetch it. A complete message.sent delivery:
{
"id": "evt_018f7b2a-0a11-7b22-8c33-4d5e6f7a8b90",
"type": "message.sent",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2a-9f6c-7a11-b2c3-1a2b3c4d5e6f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "sent",
"direction": "outbound",
"text": "Yes! Want me to set one aside?",
"status": "sent",
"protocol": "imessage",
"message_type": "text",
"reply_to_message_id": null,
"sender": "+15557654321",
"recipient": "+15551234567",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"attachments": []
}
}{
"id": "evt_018f7b2a-0a11-7b22-8c33-4d5e6f7a8b90",
"type": "message.sent",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2a-9f6c-7a11-b2c3-1a2b3c4d5e6f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "sent",
"direction": "outbound",
"text": "Yes! Want me to set one aside?",
"status": "sent",
"protocol": "imessage",
"message_type": "text",
"reply_to_message_id": null,
"sender": "+15557654321",
"recipient": "+15551234567",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"attachments": []
}
}Every example on this page is a complete delivery body like this one, in the latest payload version unless you pick another.
Endpoints are direction-aware: on inbound events sender is the contact and recipient is your channel; on outbound events they're reversed.
Verifying signatures
Each delivery is signed with your signing_secret (HMAC-SHA256) and sent in the X-Blooio-Signature header (t=<timestamp>,v1=<hmac>). Recompute the signature over {timestamp}.{raw_body} and compare it before trusting the payload — see Webhook signatures. For local development, see Receive webhooks locally.
Retries and duplicates
A delivery succeeds when your endpoint answers with any 2xx status within 15 seconds. Anything else is retried, up to 6 attempts in total, waiting 30 s, 1 min, 2 min, 4 min, then 8 min between them — so the last attempt comes about 15½ minutes after the first. After that the delivery is marked failed; find it with GET /webhooks/{webhookId}/deliveries and resend it with POST /webhooks/{webhookId}/deliveries/{deliveryId}/replay once your endpoint is back.
Deduplicate on the envelope id. It is the event's id — the same on every retry and every replay, and the one GET /events/{eventId} serves. X-Blooio-Delivery identifies a single delivery: it stays the same across that delivery's retries, but a replay is a new delivery with a new value. A replay resends the payload exactly as it was first delivered; it is not rebuilt from the resource's current state.
Versioning
Webhook payloads are versioned by release date. Each subscription is pinned to one version, and every delivery is rendered in it, so a change that could break your handler only reaches you when you choose to upgrade. The version is in the envelope's api_version and in the X-Blooio-Version header of every delivery.
| Version | |
|---|---|
2026-10-01 (latest) |
Removes fields left over from v2 payloads and chat_guid, and reports message errors by their public code. |
2026-09-01 |
The baseline: the payload every subscription received before versioning. |
GET /webhooks/versions returns the same list with each version's changes.
- New subscriptions get the latest version. Pass
api_versiontoPOST /webhooksto pick another. - Subscriptions created before versioning are pinned to
2026-09-01, so what they receive hasn't changed. - New fields are not a new version. Fields are added to every version as they ship, so ignore fields you don't recognize. Only removing, renaming, or changing the meaning of a field makes a new version.
- Replays and retries keep the version the delivery was first sent in, even after you upgrade.
Upgrading
Update your handler for the changes below, then move the subscription with PATCH /webhooks/{webhookId} (or Change payload version on the dashboard's Webhooks page). The next event is rendered in the new version:
curl -X PATCH https://api.blooio.com/v4/webhooks/wh_... \
-H "Authorization: Bearer bl_live_..." \
-H "Content-Type: application/json" \
-d '{ "api_version": "2026-10-01" }'Try itHandle both versions' error.code values while you switch. A handler that reads only 2026-10-01 fields otherwise works on both versions, but error.code on a failed message changes meaning between them.
To try a version before switching, create a second subscription at it that points to a test endpoint, and compare what the two receive. An unsupported value is rejected with 422 invalid_api_version, and the error's details.supported_versions lists the accepted ones.
2026-10-01
| Removed | From | Use instead |
|---|---|---|
event |
typing.*, message.reaction, contact.shared, poll.voted |
The envelope's type |
timestamp |
The same events | The envelope's created_at |
external_id |
The same events | contact.identifier in a 1:1 chat, group.group_id in a group |
internal_id |
The same events | channel_address |
participants |
Group message.reaction |
GET /groups/{groupId}/members |
legacy_reaction_id |
message.reaction |
The envelope's id |
Flat group_id and group_name |
Every event whose data has a group object, except group.* |
group.group_id and group.name |
chat_guid |
Inbound group message.*, including message.reaction |
chat_id and group.group_id |
group.chat_guid |
group.* |
group.chat_id and group.group_id |
group.* events keep their top-level group_id: the group is what they're about.
| Changed | On | Now |
|---|---|---|
error.code |
message.failed, and any message event with an error |
One of the public error codes, the same code the REST API returns. 2026-09-01 keeps the previous codes. |
2026-09-01
The payload every native subscription received before versioning, plus every field added since. On top of the latest version it carries the fields in the table above, including chat_guid on group inbound messages and reactions and group.chat_guid on group.* events. On a failed message, error.code is the code it had before versioning; error.message is the same public description as in the latest version. See the examples in 2026-09-01.
REST events
REST responses aren't versioned. GET /events, GET /events/{eventId} and GET /chats/{chatId}/messages/{messageId}/events return typing.*, contact.shared, message.reaction, poll.* and group.* data in the latest version's shape, with contact, group and channel_address, and without the fields 2026-10-01 removes. Names in contact and group are read when you make the request, so they're the current names rather than the ones at the time of the event. A message.failed row's data is its { "code", "message" } error. Other message events carry the data recorded with them.
Event types
A subscription receives every event type below. Switch on type in your handler to pick out the ones you act on, and ignore types you don't recognize — new ones are added over time.
Messages
message.received, message.queued, message.sent, message.delivered, message.failed, message.read, message.reaction
Inbound message (message.received):
{
"id": "evt_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"type": "message.received",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2a-8e5b-7f00-a1b2-0f1a2b3c4d5e",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "received",
"direction": "inbound",
"text": "Hey — is the blue one still in stock?",
"status": "received",
"protocol": "imessage",
"message_type": "text",
"reply_to_message_id": "msg_018f7b2a-5c4d-7e3f-8a9b-0c1d2e3f4a5b",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"attachments": []
}
}{
"id": "evt_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"type": "message.received",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2a-8e5b-7f00-a1b2-0f1a2b3c4d5e",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "received",
"direction": "inbound",
"text": "Hey — is the blue one still in stock?",
"status": "received",
"protocol": "imessage",
"message_type": "text",
"reply_to_message_id": "msg_018f7b2a-5c4d-7e3f-8a9b-0c1d2e3f4a5b",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"attachments": []
}
}contact is the other party: in a 1:1 chat the person you're talking to, and on an inbound group message the member who sent it. name is a snapshot of the contact's name when the event happened, null if none is on file; read GET /contacts/{contactId} for current details. group is { "group_id", "name" } in a group chat and null in a 1:1 chat. A message you send to a group has contact: null, since there's no single other party.
An inbound group message:
{
"id": "evt_018f7b2c-51a1-7c23-8d34-e45f56a67b78",
"type": "message.received",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2c-51a0-7b12-8c23-d34e45f56a67",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "received",
"direction": "inbound",
"text": "I can drive on Saturday.",
"status": "received",
"protocol": "imessage",
"message_type": "text",
"reply_to_message_id": null,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": { "group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99", "name": "Weekend Trip" },
"channel_address": "+15557654321",
"attachments": []
}
}{
"id": "evt_018f7b2c-51a1-7c23-8d34-e45f56a67b78",
"type": "message.received",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2c-51a0-7b12-8c23-d34e45f56a67",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "received",
"direction": "inbound",
"text": "I can drive on Saturday.",
"status": "received",
"protocol": "imessage",
"message_type": "text",
"reply_to_message_id": null,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": { "group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99", "name": "Weekend Trip" },
"channel_address": "+15557654321",
"attachments": [],
"chat_guid": "any;+;7b2a4e8f9c1d4a6b8e4f3a2b1c0d9e8f"
}
}reply_to_message_id is the message this one replies to when the contact used an inline reply, and null otherwise — the same value GET /chats/{chatId}/messages/{messageId} returns. It is on every message event except message.reaction.
Delivery status (message.delivered — also sent, read, failed, queued). A failed event adds an error object, { "code", "message" }; see message errors for the codes and what to do about each:
{
"id": "evt_018f7b2a-a1b2-7c3d-8e4f-5a6b7c8d9e0f",
"type": "message.delivered",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2a-9f6c-7a11-b2c3-1a2b3c4d5e6f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "delivered",
"direction": "outbound",
"text": "Yes! Want me to set one aside?",
"status": "delivered",
"protocol": "imessage",
"message_type": "text",
"reply_to_message_id": null,
"sender": "+15557654321",
"recipient": "+15551234567",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"attachments": []
}
}{
"id": "evt_018f7b2a-a1b2-7c3d-8e4f-5a6b7c8d9e0f",
"type": "message.delivered",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2a-9f6c-7a11-b2c3-1a2b3c4d5e6f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "delivered",
"direction": "outbound",
"text": "Yes! Want me to set one aside?",
"status": "delivered",
"protocol": "imessage",
"message_type": "text",
"reply_to_message_id": null,
"sender": "+15557654321",
"recipient": "+15551234567",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"attachments": []
}
}For media messages, message_type is media and attachments is populated, e.g. [{ "url": "https://.../photo.jpg", "media_type": "image/jpeg", "caption": "Check this out" }].
Custom iMessage app bubbles arrive with message_type imessage_app and a structured imessage_app object (the sender's bundle_id/team_id, the app-state url, caption, and session id). text mirrors the caption so text-only consumers stay readable:
{
"id": "evt_018f7b2a-b2c3-7d4e-8f5a-6b7c8d9e0f1a",
"type": "message.received",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2b-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "received",
"direction": "inbound",
"text": "Your turn!",
"status": "received",
"protocol": "imessage",
"message_type": "imessage_app",
"reply_to_message_id": null,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"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"
}
}
}{
"id": "evt_018f7b2a-b2c3-7d4e-8f5a-6b7c8d9e0f1a",
"type": "message.received",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2b-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"kind": "received",
"direction": "inbound",
"text": "Your turn!",
"status": "received",
"protocol": "imessage",
"message_type": "imessage_app",
"reply_to_message_id": null,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"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"
}
}
}See Custom bubbles (iMessage apps) for the full field guide. url is opaque to Blooio — branch on bundle_id / team_id to handle the apps you support.
Reaction (message.reaction) — keyed by the reacted message, so text/status reflect that message:
{
"id": "evt_rxn_4f9c2a7e1b3d5f6a8c0e2b4d6f8a1c3e",
"type": "message.reaction",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"message_id": "msg_018f7b2a-9f6c-7a11-b2c3-1a2b3c4d5e6f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"reaction": "love",
"action": "add",
"part_index": 0,
"direction": "inbound",
"text": "Yes! Want me to set one aside?",
"original_text": "Yes! Want me to set one aside?",
"status": "delivered",
"protocol": "imessage",
"message_type": "text",
"is_group": false,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"attachments": [],
"reacted_attachment": null
}
}{
"id": "evt_rxn_4f9c2a7e1b3d5f6a8c0e2b4d6f8a1c3e",
"type": "message.reaction",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"event": "message.reaction",
"timestamp": 1718000000000,
"external_id": "+15551234567",
"internal_id": "+15557654321",
"message_id": "msg_018f7b2a-9f6c-7a11-b2c3-1a2b3c4d5e6f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"reaction": "love",
"action": "add",
"part_index": 0,
"direction": "inbound",
"text": "Yes! Want me to set one aside?",
"original_text": "Yes! Want me to set one aside?",
"status": "delivered",
"protocol": "imessage",
"message_type": "text",
"is_group": false,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321",
"attachments": [],
"reacted_attachment": null
}
}sender and recipient describe the tapback, not the reacted message: on an inbound reaction sender is the person who reacted (in a group, the member who tapped) and recipient is your channel; on a reaction you sent, sender is your channel. On a blooio channel the envelope id is fixed per tapback, so a reaction the device reports twice is delivered once. Treat the id as opaque. original_text is the reacted message's text, on reactions you send through the API as well as inbound ones.
part_index is the zero-based index of the bubble that was reacted to. Single-bubble messages are always 0; for a multipart send it matches the position in the parts array you sent, so "part_index": 2 on a five-image carousel means the recipient reacted to the third image. The event also carries reacted_attachment — the exact attachment reacted to ({ index, url, media_type, size, caption }), or null when the tapback landed on the caption/text.
Polls
poll.created, poll.received, poll.voted
Poll created (poll.created; inbound polls arrive as poll.received):
{
"id": "evt_018f7b2a-d4e5-7f6a-8b7c-8d9e0f1a2b3c",
"type": "poll.created",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"poll_id": "msg_018f7b2a-aa11-7b22-8c33-9d44e55f6a77",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"title": "Which day works best?",
"options": ["Monday", "Wednesday", "Friday"],
"direction": "outbound",
"poll": { "title": "Which day works best?", "options": ["Monday", "Wednesday", "Friday"] },
"status": "queued",
"sender": "+15557654321",
"recipient": "+15551234567",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321"
}
}{
"id": "evt_018f7b2a-d4e5-7f6a-8b7c-8d9e0f1a2b3c",
"type": "poll.created",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"poll_id": "msg_018f7b2a-aa11-7b22-8c33-9d44e55f6a77",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"title": "Which day works best?",
"options": ["Monday", "Wednesday", "Friday"],
"direction": "outbound",
"poll": { "title": "Which day works best?", "options": ["Monday", "Wednesday", "Friday"] },
"status": "queued",
"sender": "+15557654321",
"recipient": "+15551234567",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321"
}
}Poll vote (poll.voted) — voted_options is the voter's current selection. A vote you cast with POST /chats/{chatId}/polls/{pollId}/vote arrives with direction: "outbound" and toggled (on/off) in place of title, voter and is_group:
{
"id": "evt_018f7b2a-e5f6-7a7b-8c8d-9e0f1a2b3c4d",
"type": "poll.voted",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"poll_id": "msg_018f7b2a-aa11-7b22-8c33-9d44e55f6a77",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"title": "Which day works best?",
"voter": "+15551234567",
"voted_options": ["Wednesday"],
"direction": "inbound",
"poll": { "title": "Which day works best?", "options": ["Monday", "Wednesday", "Friday"] },
"status": "delivered",
"is_group": false,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321"
}
}{
"id": "evt_018f7b2a-e5f6-7a7b-8c8d-9e0f1a2b3c4d",
"type": "poll.voted",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"event": "poll.voted",
"timestamp": 1718000000000,
"poll_id": "msg_018f7b2a-aa11-7b22-8c33-9d44e55f6a77",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"title": "Which day works best?",
"voter": "+15551234567",
"voted_options": ["Wednesday"],
"direction": "inbound",
"poll": { "title": "Which day works best?", "options": ["Monday", "Wednesday", "Friday"] },
"status": "delivered",
"is_group": false,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"group": null,
"channel_address": "+15557654321"
}
}Groups
group.name_changed, group.icon_changed, group.merged
Name changed (group.name_changed). group.icon_changed is the same shape but carries icon_url / previous_icon_url instead of the name fields:
{
"id": "evt_018f7b2c-4c10-7d21-8e32-f43a54b65c76",
"type": "group.name_changed",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99",
"name": "Weekend Trip",
"previous_name": "Trip planning",
"group": {
"group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99",
"name": "Weekend Trip",
"icon_url": null,
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"member_count": 4
},
"channel_address": "+15557654321"
}
}{
"id": "evt_018f7b2c-4c10-7d21-8e32-f43a54b65c76",
"type": "group.name_changed",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99",
"name": "Weekend Trip",
"previous_name": "Trip planning",
"group": {
"group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99",
"name": "Weekend Trip",
"icon_url": null,
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"member_count": 4,
"chat_guid": "any;+;7b2a4e8f9c1d4a6b8e4f3a2b1c0d9e8f"
},
"channel_address": "+15557654321"
}
}Merged (group.merged) — a new group's first message landed in an iMessage conversation another of your groups already holds with the same people on that number, so the new group was folded into it. group_id / chat_id are the surviving group and its chat; source_group_id / source_chat_id are the ids you were given when you created the group. Replies in that conversation arrive under the survivor, so switch to it. The old ids keep resolving for reads and sends (see Create a group):
{
"id": "evt_018f7b2c-4e01-7f12-8a23-b34c45d56e67",
"type": "group.merged",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99",
"source_group_id": "grp_018f7b2c-4d55-7e66-8f77-a88b99c0d1e2",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"source_chat_id": "chat_018f7b2c-4d99-7eaa-8fbb-ccddeeff0011",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"message_id": "msg_018f7b2c-4e00-7f11-8a22-b33c44d55e66",
"group": {
"group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99",
"name": "Weekend Trip",
"icon_url": null,
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"member_count": 4
},
"channel_address": "+15557654321"
}
}{
"id": "evt_018f7b2c-4e01-7f12-8a23-b34c45d56e67",
"type": "group.merged",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99",
"source_group_id": "grp_018f7b2c-4d55-7e66-8f77-a88b99c0d1e2",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"source_chat_id": "chat_018f7b2c-4d99-7eaa-8fbb-ccddeeff0011",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"message_id": "msg_018f7b2c-4e00-7f11-8a22-b33c44d55e66",
"group": {
"group_id": "grp_018f7b2a-bb22-7c33-9d44-e55f6a778b99",
"name": "Weekend Trip",
"icon_url": null,
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"channel_type": "blooio",
"member_count": 4,
"chat_guid": "any;+;7b2a4e8f9c1d4a6b8e4f3a2b1c0d9e8f"
},
"channel_address": "+15557654321"
}
}message_id is the first message that opened the conversation; it now belongs to the surviving chat.
Contact card
contact.shared — fired when a contact shares their Name & Photo card with you, and when you share yours into a chat. An inbound share carries the card itself; avatar_url is a hosted copy of the photo:
{
"id": "evt_018f7b2a-f607-7b18-8c29-d3a4b5c6d7e8",
"type": "contact.shared",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"direction": "inbound",
"address": "+15551234567",
"display_name": "Jordan Lee",
"first_name": "Jordan",
"last_name": "Lee",
"avatar_url": "https://bucket.blooio.com/contact-avatars/abc123.png",
"is_pending": false,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"channel_address": "+15557654321",
"is_group": false,
"group": null
}
}{
"id": "evt_018f7b2a-f607-7b18-8c29-d3a4b5c6d7e8",
"type": "contact.shared",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"event": "contact.shared",
"timestamp": 1718000000000,
"internal_id": "+15557654321",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"direction": "inbound",
"address": "+15551234567",
"display_name": "Jordan Lee",
"first_name": "Jordan",
"last_name": "Lee",
"avatar_url": "https://bucket.blooio.com/contact-avatars/abc123.png",
"is_pending": false,
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"channel_address": "+15557654321",
"is_group": false,
"group": null
}
}A name or photo field is null when the card didn't include it; that never clears what Blooio already has stored for the contact. chat_id and the endpoint fields are absent when the contact has no chat with that number yet — use address. When you share your own card, the event has direction: "outbound" and no card fields.
Typing
typing.started, typing.stopped
{
"id": "evt_018f7b2a-0718-7c29-8d3a-e4b5c6d7e8f9",
"type": "typing.started",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"direction": "inbound",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"channel_address": "+15557654321",
"is_group": false,
"group": null
}
}{
"id": "evt_018f7b2a-0718-7c29-8d3a-e4b5c6d7e8f9",
"type": "typing.started",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"event": "typing.started",
"timestamp": 1718000000000,
"external_id": "+15551234567",
"internal_id": "+15557654321",
"group_id": null,
"group_name": null,
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"direction": "inbound",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": {
"contact_id": "ct_018f7b2a-3e4f-7a5b-8c6d-7e8f9a0b1c2d",
"identifier": "+15551234567",
"name": "Jordan Lee"
},
"channel_address": "+15557654321",
"is_group": false,
"group": null
}
}In a group chat group is { "group_id", "name" } and contact is the member typing, or null when the device doesn't say who.
Safety
safety.state_changed — fired when a sending number's warm-up tier or safety action changes. One event carries the full picture; diff whichever field you care about. tier is new, warming, or established. action is one of none, queue, slow, pause_new, reply_only, or review (in escalation order); actions clear automatically as the underlying sending pattern ages out. reasons explains what triggered the current action:
{
"id": "evt_018f7b2a-1829-7d3a-8e4b-f5c6d7e8f9a0",
"type": "safety.state_changed",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"tier": "warming",
"previous_tier": "warming",
"action": "slow",
"previous_action": "none",
"reasons": {
"one_way": { "new_convos_3d_avg": 14.3, "reply_rate_7d": 0.22, "escalated": false }
}
}
}{
"id": "evt_018f7b2a-1829-7d3a-8e4b-f5c6d7e8f9a0",
"type": "safety.state_changed",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"tier": "warming",
"previous_tier": "warming",
"action": "slow",
"previous_action": "none",
"reasons": {
"one_way": { "new_convos_3d_avg": 14.3, "reply_rate_7d": 0.22, "escalated": false }
}
}
}safety.number_banned — fired when Apple bans a sending number for iMessage. Delivered once per affected organization (dedicated owners get one event; every org on a shared pool gets its own). allocation_type is dedicated or shared:
{
"id": "evt_018f7b2a-293a-7e4b-8f5c-a6d7e8f9a0b1",
"type": "safety.number_banned",
"api_version": "2026-10-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"phone_number": "+15557654321",
"allocation_type": "dedicated",
"banned_at": 1718000000000
}
}{
"id": "evt_018f7b2a-293a-7e4b-8f5c-a6d7e8f9a0b1",
"type": "safety.number_banned",
"api_version": "2026-09-01",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": {
"channel_id": "ch_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"phone_number": "+15557654321",
"allocation_type": "dedicated",
"banned_at": 1718000000000
}
}Inspect and replay past deliveries with GET /webhooks/{webhookId}/deliveries.