Webhook events
Event types, headers, payloads, and signature verification for Blooio webhooks.
Overview
Blooio sends POST requests to your configured webhook URL for message lifecycle events. All webhooks are cryptographically signed so you can verify they originated from Blooio.
Headers
Every webhook request includes these headers:
| Header | Description | Example |
|---|---|---|
Content-Type | Always application/json | application/json |
X-Blooio-Event | The event type | message.sent |
X-Blooio-Message-Id | Associated message ID | gK2Ig_XGR2M6UkSgmT9FK |
X-Blooio-Signature | HMAC-SHA256 signature for verification | t=1703123457,v1=abc123... |
Replay headers
When a webhook is replayed, these additional headers are included:
| Header | Description |
|---|---|
X-Blooio-Replay | Set to true for replayed webhooks |
X-Original-Event-Id | The original event ID that was replayed |
Signature verification
Security best practice: Always verify webhook signatures in production to ensure requests are authentic and haven't been tampered with.
Every webhook includes an X-Blooio-Signature header with the format:
X-Blooio-Signature: t=1703123457,v1=f67c3b8ca86b024ee7090f57edb5e4c4f3a050862f50cb7baa73d7541a39f535Where:
t= Unix timestamp (seconds) when the webhook was sentv1= HMAC-SHA256 signature of{timestamp}.{raw_json_body}
Quick verification example
const crypto = require('crypto');
function verifyWebhook(secret, signatureHeader, rawBody) {
const [tPart, sigPart] = signatureHeader.split(',');
const timestamp = tPart.split('=')[1];
const signature = sigPart.split('=')[1];
// Compute expected signature
const payload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
}Getting your signing secret
Your webhook signing secret is returned only once when you create a webhook. If you lose it, you can rotate it via:
- The Dashboard → Webhooks → Click the rotate button
- The API:
POST /v2/api/webhooks/{webhookId}/secret/rotate
Rotating a secret immediately invalidates the old one. Update your endpoint before rotating.
Base fields
All webhook events include these fields:
| Field | Type | Description |
|---|---|---|
event | string | One of: message.received, message.sent, message.delivered, message.failed, message.read, message.reaction, poll.received, poll.created, poll.voted, group.name_changed, group.icon_changed |
message_id | string | Unique identifier for the message |
external_id | string | Phone number or email |
protocol | string | One of: imessage, sms, rcs, non-imessage |
timestamp | integer | Unix timestamp (ms) when the webhook was sent |
internal_id | string | Phone number of the device that handled the event |
Poll webhooks
Poll events are sent for native iMessage polls.
Poll events are only delivered to webhooks configured with webhook_type: "poll" or webhook_type: "all".
poll.received
Fired when an inbound poll is received.
| Field | Type | Description |
|---|---|---|
poll_id | string | Unique poll message identifier |
chat_id | string | Contact identifier or group ID |
sender | string | Phone number/email of the sender |
title | string | Poll title/question |
options | array | Poll option texts |
is_group | boolean | Whether this poll is from a group chat |
group_id | string | Group identifier (group events only) |
group_name | string | Group name (group events only) |
participants | array | Group participants (group events only) |
{
"event": "poll.received",
"poll_id": "pl_abc123",
"chat_id": "+15551234567",
"sender": "+15551234567",
"title": "Lunch spot?",
"options": ["Sushi", "Tacos", "Salad"],
"is_group": false,
"timestamp": 1705123457000
}poll.created
Fired when your org creates/sends a poll.
| Field | Type | Description |
|---|---|---|
poll_id | string | Unique poll message identifier |
chat_id | string | Contact identifier or group ID |
sender | string | Sending number/identifier |
title | string | Poll title/question |
options | array | Poll option texts |
is_group | boolean | Whether this poll targets a group chat |
{
"event": "poll.created",
"poll_id": "pl_def456",
"chat_id": "+15557654321",
"sender": "+14155551234",
"title": "What time?",
"options": ["10:00", "11:00", "12:00"],
"is_group": false,
"timestamp": 1705123459000
}poll.voted
Fired when a participant votes (or updates their vote) on a poll.
| Field | Type | Description |
|---|---|---|
poll_id | string | Identifier of the original poll |
chat_id | string | Contact identifier or group ID |
voter | string | Phone number/email of the voter |
title | string | Poll title |
voted_options | array | Currently selected option texts |
is_group | boolean | Whether vote happened in a group chat |
group_id | string | Group identifier (group events only) |
group_name | string | Group name (group events only) |
participants | array | Group participants (group events only) |
{
"event": "poll.voted",
"poll_id": "pl_def456",
"chat_id": "+15557654321",
"voter": "+15557654321",
"title": "What time?",
"voted_options": ["11:00"],
"is_group": false,
"timestamp": 1705123461000
}Message webhooks
Message lifecycle and reaction events are delivered through the message.* event namespace.
Message events are delivered to webhooks configured with webhook_type: "message" or webhook_type: "all".
message.received
Fired when an inbound message is received.
| Field | Type | Description |
|---|---|---|
text | string | Text content of the inbound message |
attachments | array | Attachment URLs included in the message |
received_at | integer | Unix timestamp (ms) when the device received the message |
sender | string | Phone number or email of the message sender |
is_group | boolean | Whether this message is from a group chat |
group_id | string | Group identifier (only present for group messages) |
group_name | string | Name of the group (only present for group messages) |
participants | array | Array of group participants (only present for group messages) |
{
"event": "message.received",
"message_id": "CZIsqG9ZWd9gwjIEhZpHY",
"external_id": "+15551234567",
"text": "Hello, I need help with my order",
"attachments": [],
"protocol": "imessage",
"timestamp": 1703123457474,
"internal_id": "+14155551234",
"received_at": 1703123456789,
"sender": "+15551234567",
"is_group": false,
"group_id": null,
"group_name": null,
"participants": null
}{
"event": "message.received",
"message_id": "GRP12ab34cd56",
"external_id": "grp_abc123xyz",
"text": "Hey everyone!",
"attachments": [],
"protocol": "imessage",
"timestamp": 1704123457000,
"internal_id": "+14155559876",
"received_at": 1704123456900,
"sender": "+15559876543",
"is_group": true,
"group_id": "grp_abc123xyz",
"group_name": "Sales Team",
"participants": [
{
"contact_id": "ct_xyz789",
"identifier": "+15551234567",
"name": "John Doe"
},
{
"contact_id": "ct_abc456",
"identifier": "+15559876543",
"name": null
}
]
}message.sent
Fired when an outbound message is sent by the device.
| Field | Type | Description |
|---|---|---|
text | string | Text content of the sent message (text variant only) |
attachments | array | Array of attachment URLs (attachment variant only) |
sent_at | integer | Unix timestamp (ms) when the message was actually sent |
{
"event": "message.sent",
"message_id": "gK2Ig_XGR2M6UkSgmT9FK",
"external_id": "+15551234567",
"protocol": "imessage",
"timestamp": 1703123458158,
"text": "Thanks for contacting us! How can I help?",
"internal_id": "+14155551234",
"sent_at": 1703123457370
}message.delivered
Fired when the message is confirmed delivered to the recipient.
Group messages: Delivery receipts are not available for group chats. Messages sent to groups will reach message.sent status but will not receive message.delivered webhooks.
| Field | Type | Description |
|---|---|---|
delivered_at | integer | Unix timestamp (ms) when the message was delivered |
{
"event": "message.delivered",
"message_id": "gK2Ig_XGR2M6UkSgmT9FK",
"external_id": "+15551234567",
"protocol": "imessage",
"timestamp": 1703123460773,
"internal_id": "+14155551234",
"delivered_at": 1703123457563
}message.failed
Fired when message delivery fails.
| Field | Type | Description |
|---|---|---|
error_code | string | Error code indicating the type of failure |
error_message | string | Human-readable error message |
{
"event": "message.failed",
"message_id": "jkl012_failed_msg",
"external_id": "+15551234567",
"protocol": "sms",
"timestamp": 1703123467000,
"internal_id": "+14155551234",
"error_code": "delivery_timeout",
"error_message": "Delivery timeout"
}message.read
Fired when the recipient reads the message (iMessage only, requires read receipts enabled).
Group messages: Read receipts are not available for group chats. This event only fires for 1:1 iMessage conversations where the recipient has read receipts enabled.
| Field | Type | Description |
|---|---|---|
read_at | integer | Unix timestamp (ms) when the message was read |
{
"event": "message.read",
"message_id": "gK2Ig_XGR2M6UkSgmT9FK",
"external_id": "+15551234567",
"protocol": "imessage",
"timestamp": 1703123469000,
"internal_id": "+14155551234",
"read_at": 1703123468402
}message.reaction
Fired when someone adds or removes a tapback reaction (love, like, dislike, laugh, emphasize, question) on a message.
| Field | Type | Description |
|---|---|---|
direction | string | inbound (someone reacted to a message) or outbound (you sent the reaction) |
reaction | string | One of: love, like, dislike, laugh, emphasize, question |
action | string | add (reaction added) or remove (reaction removed) |
sender | string | Phone number or email of the person who sent the reaction |
original_text | string | Text content of the message that was reacted to |
{
"event": "message.reaction",
"direction": "inbound",
"message_id": "msg_abc123def456",
"external_id": "+15551234567",
"reaction": "love",
"action": "add",
"sender": "+15551234567",
"original_text": "Thanks for your help!",
"timestamp": 1703123470000,
"internal_id": "+14155551234"
}{
"event": "message.reaction",
"direction": "inbound",
"message_id": "msg_abc123def456",
"external_id": "+15551234567",
"reaction": "love",
"action": "remove",
"sender": "+15551234567",
"original_text": "Thanks for your help!",
"timestamp": 1703123475000,
"internal_id": "+14155551234"
}Group webhooks
Group events are delivered through the group.* event namespace.
Group events are only delivered to webhooks configured with webhook_type: "all".
group.name_changed
Triggered when a participant changes the name of a group chat.
| Field | Type | Description |
|---|---|---|
group_id | string | Unique identifier for the group |
name | string | The new group name |
previous_name | string | The previous group name (null if previously unnamed) |
timestamp | integer | Unix timestamp (ms) when the event occurred |
{
"event": "group.name_changed",
"group_id": "grp_abc123xyz",
"name": "Sales Team 2025",
"previous_name": "Sales Team",
"timestamp": 1704123457000
}group.icon_changed
Triggered when a participant changes the icon/photo of a group chat.
| Field | Type | Description |
|---|---|---|
group_id | string | Unique identifier for the group |
icon_url | string | URL of the new group icon |
previous_icon_url | string | URL of the previous group icon (null if no previous icon) |
timestamp | integer | Unix timestamp (ms) when the event occurred |
{
"event": "group.icon_changed",
"group_id": "grp_abc123xyz",
"icon_url": "https://bucket.blooio.com/group-icons/abc123.png",
"previous_icon_url": null,
"timestamp": 1704123457000
}Best practices
Troubleshooting
| Issue | Solution |
|---|---|
| Signature verification fails | Ensure you're using the raw request body (not parsed JSON). The signature is computed on the exact bytes received. |
| Missing webhooks | Check webhook logs in the dashboard. Your endpoint may be returning errors or timing out. |
| Old timestamps | If testing with saved payloads, signature verification may fail due to timestamp checks. Increase tolerance for testing. |
| Secret not working | You may have rotated your secret. Get the current secret from the dashboard or rotate again. |