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, 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:
{
"id": "evt_018f7b2a-1c3d-7e4f-9a2b-0c1d2e3f4a5b",
"type": "message.received",
"created_at": 1718000000000,
"organization_id": "org_abc123",
"data": { }
}The examples below show the data object for each event (the envelope is omitted for brevity). 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.
Event types
A subscription receives every event type below. Switch on type in your handler to pick out the ones you act on.
Messages
message.received, message.queued, message.sent, message.delivered, message.failed, message.read, message.reaction
Inbound message (message.received):
{
"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": "received",
"direction": "inbound",
"text": "Hey — is the blue one still in stock?",
"status": "received",
"protocol": "imessage",
"provider_message_id": "p:0/1A2B3C4D-5E6F-7089-ABCD-EF0123456789",
"message_type": "text",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": { "identifier": "+15551234567" },
"channel_address": "+15557654321",
"attachments": []
}Delivery status (message.delivered — also sent, read, failed, queued). A failed event adds an error object:
{
"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",
"sender": "+15557654321",
"recipient": "+15551234567",
"contact": { "identifier": "+15551234567" },
"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:
{
"message_id": "msg_018f7b2a-9f6c-7a11-b2c3-1a2b3c4d5e6f",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"kind": "received",
"direction": "inbound",
"message_type": "imessage_app",
"text": "Your turn!",
"sender": "+15551234567",
"recipient": "+15557654321",
"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!",
"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:
{
"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?",
"status": "delivered",
"message_type": "text",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": { "identifier": "+15551234567" },
"channel_address": "+15557654321"
}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):
{
"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": "sent",
"sender": "+15557654321",
"recipient": "+15551234567",
"contact": { "identifier": "+15551234567" },
"channel_address": "+15557654321"
}Poll vote (poll.voted) — toggled is on/off and voted_options is the voter's current selection:
{
"poll_id": "msg_018f7b2a-aa11-7b22-8c33-9d44e55f6a77",
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"voted_options": ["Wednesday"],
"toggled": "on",
"direction": "inbound",
"poll": { "title": "Which day works best?", "options": ["Monday", "Wednesday", "Friday"] },
"status": "delivered",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": { "identifier": "+15551234567" },
"channel_address": "+15557654321"
}Groups
group.name_changed, group.icon_changed
Name changed (group.name_changed). group.icon_changed is the same shape but carries icon_url / previous_icon_url instead of the name fields:
{
"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"
}Contact card
contact.shared — fired when a contact card is shared into a chat:
{
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"direction": "inbound",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": { "identifier": "+15551234567" },
"channel_address": "+15557654321",
"is_group": false,
"group": null
}Typing
typing.started, typing.stopped
{
"chat_id": "chat_018f7b2a-77aa-7c22-9d3e-4f5a6b7c8d9e",
"direction": "inbound",
"sender": "+15551234567",
"recipient": "+15557654321",
"contact": { "identifier": "+15551234567" },
"channel_address": "+15557654321",
"is_group": false,
"group": null
}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:
{
"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:
{
"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.