WhatsApp 事件
Bird 为入站和出站 WhatsApp 消息记录事件。出站时间线显示发送返回 202 之后发生了什么:接受、移交给 WhatsApp、投递、已读或失败。入站时间线记录 Bird 何时收到消息。
事件信封
代码示例
{
"data": {
"direction": "outbound",
"from": { "phone_number": "+13124495569" },
"metadata": { "session_id": "sess_4821" },
"tags": [{ "name": "flow", "value": "login-otp" }],
"to": { "phone_number": "+14155550100" },
"whatsapp_id": "wam_01ky7qbvswf3fvyaw3az90391c",
"workspace_id": "ws_01ky7m235keycbnwyajabe1a6b"
},
"timestamp": "2026-07-23T14:51:39.913Z",
"type": "whatsapp.delivered"
}每条消息的公共 WhatsApp webhook 载荷包含 whatsapp_id、workspace_id、direction、from、to、tags 和 metadata。whatsapp.reacted 是例外,因为回应是对消息的标注而非独立消息;下文回应部分给出了其结构。地址可以包含 E.164 格式的 phone_number、bsuid 中的 Meta 业务范围用户 ID,或两者兼有。从 WhatsApp 用户收到的消息还会携带该用户公开的资料,位于 username 和 display_name 中。当发送时未携带时,tags 和 metadata 为 null。以回复形式发送的消息还会在其时间线的每个出站事件上携带 in_reply_to_message_id,从 whatsapp.accepted 到 whatsapp.read、whatsapp.failed 或 whatsapp.rejected,指明其回复的消息。
生命周期事件
事件按时间顺序排列。出站消息可能在 whatsapp.failed 或 whatsapp.rejected 处终止,whatsapp.read 仅在收件人打开消息时出现。入站消息只有一个 whatsapp.received 时间线事件。
| 事件 | 含义 |
|---|---|
| whatsapp.accepted | Bird 接受了发送请求。这是 202 报告的内容。 |
| whatsapp.sent | Bird 已将消息移交给 WhatsApp 网络。 |
| whatsapp.delivered | WhatsApp 确认消息已投递到收件人的设备。 |
| whatsapp.read | 收件人打开了消息。 |
| whatsapp.failed | 消息未投递。error.code 说明了阻止原因。 |
| whatsapp.rejected | Bird 在发送前拒绝了消息。未产生费用。 |
| whatsapp.received | Bird 收到了来自联系人的入站消息。 |
whatsapp.delivered 也是 Meta 对消息价格的分成被扣费的时刻,但事件本身不会报告费用:WhatsApp 事件载荷不包含费用信息。使用 GET /v1/whatsapp/messages/{message_id} 回读消息以查看费用。参见费用与计费。
whatsapp.read 不会更改消息的 status。已投递的消息保持 delivered;消息同时在 read_at 中记录已读。
whatsapp.delivered 可能被完全跳过。 当收件人的设备上已打开该聊天时,Meta 会报告已读而不报告投递,因此时间线显示为 whatsapp.accepted → whatsapp.sent → whatsapp.read,中间没有 whatsapp.delivered。应将 read 视为投递证明:等待 delivered 才认定消息送达的消费者,恰好会卡在最快看到消息的收件人上;而仅根据 delivered 计算投递率则会低估。在这种情况下消息的 status 保持 sent,因为只有投递回执才会推进它。
事件类型列表是开放的:随时可能添加新类型,因此应将无法识别的值视为未来事件而非错误。
失败事件
whatsapp.failed 和 whatsapp.rejected 是终态。拒绝意味着 Bird 在发送到 WhatsApp 之前阻止了消息,因此不会产生费用。原因包括被屏蔽或已退订的收件人、钱包余额不足或目的地未配置价格。失败意味着消息未投递,error.code 说明了由谁做出的判定。大多数错误码携带 WhatsApp 的裁定,由其报告的代码映射而来。internal_error 是例外:它表示消息根本未到达 WhatsApp,原因可能是发送号码没有可用的凭据,或者 Bird 重试发送直至放弃。meta_error_code 在可用时包含 WhatsApp 的代码,internal_error 类型的失败按定义没有该代码。
两种事件都包含一个 error 对象,其中有一个稳定的 Bird code、一个可读的 description、一个可选的 meta_error_code 和 occurred_at。该对象仅在这些事件类型的 API 记录和 webhook 载荷中出现。
回应事件
表情回应是对消息的标注而非独立消息,因此不会出现在任何一条时间线上。回应不会产生 whatsapp.* 事件,入站回应不会触发 whatsapp.received webhook,也没有任何 webhook 会携带回应。每次变更都记录在被回应消息自身的回应日志中:每次放置的表情、每次替换为不同表情,以及每次撤回,无论是联系人还是你的业务号码发起的。
有一种情况不会被记录。Bird 通过一个保留 15 天的提供商 ID 将回应与其消息匹配,而 WhatsApp 允许对最多 30 天前的消息进行回应,因此对超过该时限的消息发出的回应无法匹配,既不会出现在日志中,也不会到达 reactions。消息没有条目并不能证明没有人对其作出过回应。
使用 GET /v1/whatsapp/messages/{message_id}/reaction-events 读取该日志,按最新排序。每条记录包含表情、变更者,以及一个 status,值为 received、sent、failed 或 rejected;failed 或 rejected 类型的记录在 error 上携带原因。回应不会产生费用,因此回应失败不涉及计费。要查看消息上当前保留的回应而非变更历史,请使用 GET /v1/whatsapp/messages/{message_id} 读取其 reactions,它会将日志折叠为每个发送者一条记录。
屏蔽事件
除了单条消息的生命周期之外,还有一个事件报告工作区屏蔽列表的变更:whatsapp_suppression.created 在屏蔽生效时触发。载荷包含 suppression_id、E.164 格式的被屏蔽 address、该屏蔽所限定的 waba(为 null 时表示覆盖整个工作区,无论哪个账号发送)、reason 和 workspace_id,这样你的系统无需轮询即可获知新的屏蔽。只有屏蔽生效会触发事件:解除屏蔽目前不会触发,因此在视镜像的屏蔽为仍然有效之前,请重新读取列表:
代码示例
{
"type": "whatsapp_suppression.created",
"timestamp": "2026-08-25T14:52:03.192524705Z",
"data": {
"suppression_id": "was_01krdgeqcxet5s7t44vh8rt9mg",
"address": "+14155550100",
"waba": null,
"reason": "manual",
"workspace_id": "ws_01ky7m21hjffh9s8kq76gyb1xf"
}
}收件人自行声明的退订是一个偏好设置而非屏蔽,会触发 preference.revoked。
从 API 读取事件
GET /v1/whatsapp/messages/{message_id}/events 按时间顺序返回时间线。该有界列表不分页。读取事件需要具有 whatsapp:read 权限的 API 密钥:
const { data } = await bird.whatsapp.listEvents("wa_abc123");
for (const event of data) console.log(event.type, event.occurred_at);events = client.whatsapp.list_events("wa_abc123")
for event in events.data:
print(event.type, event.occurred_at)events, err := client.Whatsapp.ListEvents(context.Background(), "wam_01krdgeqcxet5s7t44vh8rt9mg", bird.WhatsappListEventsParams{})
if err != nil {
log.Fatal(err)
}
for _, e := range events.Data {
fmt.Println(e.Id, e.Type)
}$events = $bird->whatsapp->listEvents('wamid_01krdgeqcxet5s7t44vh8rt9mg');
foreach ($events->getData() ?? [] as $event) {
echo $event->getType(), ' ', $event->getId(), "\n";
}bird whatsapp list-events <message-id>{
"name": "whatsapp_list_events",
"arguments": {
"message_id": "<message-id>"
}
}curl https://us1.platform.bird.com/v1/whatsapp/messages/wam_.../events \
-H "Authorization: Bearer $BIRD_API_KEY"一条经过接受、发送、投递和已读的消息返回四个事件:
代码示例
{
"data": [
{
"id": "ev_01ky7q6a1fejfbvs0myn41hj41",
"occurred_at": "2026-07-23T14:48:34.71Z",
"type": "whatsapp.accepted"
},
{
"id": "ev_01ky7q6a2denvtd6jg1vqwmg13",
"occurred_at": "2026-07-23T14:48:35.671Z",
"type": "whatsapp.sent"
},
{
"id": "ev_01ky7q6a2zff9r2qm74mmg1g6z",
"occurred_at": "2026-07-23T14:48:36.642Z",
"type": "whatsapp.delivered"
},
{
"id": "ev_01ky7q6c21frssf0vj8h50qysw",
"occurred_at": "2026-07-23T14:48:38.65Z",
"type": "whatsapp.read"
}
]
}传入 type 可返回一种确切的公共事件类型,例如 ?type=whatsapp.failed 或 ?type=whatsapp.read。省略该参数则返回完整时间线。
该时间线与你打开消息时 WhatsApp 日志页面渲染的内容一致。

Webhooks
从 Webhooks 页面或 webhooks API 订阅 whatsapp.accepted、whatsapp.sent、whatsapp.delivered、whatsapp.read、whatsapp.failed、whatsapp.rejected、whatsapp.received 和 whatsapp.reacted。Webhooks 指南涵盖端点、签名和重试。
whatsapp.received 在上述信封之上携带消息内容,因此端点可以直接处理入站消息而无需回读。对交互式消息的点击以 interactive_reply 形式到达,in_reply_to_message_id 指明其回复的消息:
代码示例
{
"data": {
"direction": "inbound",
"from": {
"display_name": "Alex Rivera",
"phone_number": "+14155550100",
"username": "alexr"
},
"in_reply_to_message_id": "wam_01ky7qbvswf3fvyaw3az90391c",
"interactive_reply": {
"list": {
"description": "Next day to 2 days",
"slug": "priority_express",
"text": "Priority Mail Express"
},
"type": "list"
},
"metadata": null,
"tags": null,
"to": { "phone_number": "+13124495569" },
"whatsapp_id": "wam_01ky8b3xq4gd7pmzn2ka51f7te",
"workspace_id": "ws_01ky7m235keycbnwyajabe1a6b"
},
"timestamp": "2026-07-23T14:52:04.118Z",
"type": "whatsapp.received"
}其他内容分支遵循相同的多选一结构:text、image、video、audio、sticker、document、location、contact_cards,以及 unsupported(用于 API 未建模的类型)。GET /v1/whatsapp/messages/{message_id} 记录了每种内容。
回应
whatsapp.reacted 在 WhatsApp 用户对你的消息作出回应时触发。它是唯一不属于消息投递时间线的 WhatsApp 事件:它不会出现在 GET /v1/whatsapp/messages/{message_id}/events 中,也无法在那里按条件筛选。
whatsapp_id 指的是被回应的消息而非回应本身,emoji 是用户所做的变更。一个用户先回应、再更换表情、然后撤回回应,会在该消息上产生三个事件。WhatsApp 在前两次之间不发送移除事件,因此更换以携带新表情的单个事件到达。
代码示例
{
"data": {
"emoji": "👍",
"from": { "phone_number": "+14155550100" },
"to": { "phone_number": "+13124495569" },
"whatsapp_id": "wam_01ky8b3xq4gd7pmzn2ka51f7te",
"workspace_id": "ws_01ky7m235keycbnwyajabe1a6b"
},
"timestamp": "2026-07-23T14:52:11.000Z",
"type": "whatsapp.reacted"
}WhatsApp 以秒为精度报告回应时间,因此这三个事件中的两个可能共享同一个 timestamp。按该值排序无法确定顺序,投递顺序也不行,因为重试使其不可靠。请处理每个事件携带的回应,将其视为它描述的变更。不要从事件中重建序列,也不要将最后到达的事件视为消息上当前的回应,因为时间戳和到达顺序都无法支持这样做。回读消息以获取当前保留的回应:GET /v1/whatsapp/messages/{message_id} 在 reactions 中返回每个发送者一条记录,消息的回应日志包含所有变更。
当用户撤回回应时,emoji 存在且为 null,因此 null 是撤回操作本身而非缺失值。表情完全按照 WhatsApp 发送时的样子投递,不做规范化,因此 ❤ 和 ❤️ 作为不同的字符串到达你这里。
后续步骤
- WhatsApp 日志:渲染此时间线的逐条消息视图
- 发送 WhatsApp 消息:消息生命周期的起点
- Webhooks 指南:端点、签名、重试和完整的事件目录