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.read。
| 事件 | 含义 |
|---|---|
| whatsapp.accepted | Bird 接受了发送请求。这是 202 报告的内容。 |
| whatsapp.sent | Bird 已将消息移交给 WhatsApp 网络。 |
| whatsapp.delivered | WhatsApp 确认消息已投递到收件人的设备。 |
| whatsapp.read | 收件人打开了消息。 |
| whatsapp.failed | 消息未投递。error.code 说明了阻止原因。 |
| whatsapp.rejected | Bird 在发送前拒绝了消息。未产生费用。 |
| whatsapp.received | Bird 收到了来自联系人的入站消息。 |
适用的 delivered 或 read 回调可触发 Meta 的费用份额。WhatsApp 事件载荷不包含费用信息。通过 GET /v1/whatsapp/messages/{message_id} 回读消息以查看其费用。参见费用与计费。
将入站消息标记为已读会在其时间线中记录 whatsapp.read,但不会触发已读确认 webhook。入站消息保持其 received 状态,并在 WhatsApp 接受确认后记录 read_at。
whatsapp.read 不会更改消息的 status。已投递的消息保持 delivered;消息同时在 read_at 中记录已读。
whatsapp.delivered 可能被完全跳过。 当收件人的设备上已打开该聊天时,Meta 会报告已读而不报告投递,因此时间线显示为 whatsapp.accepted → whatsapp.sent → whatsapp.read,中间没有 whatsapp.delivered。请将 read 视为投递证明:等待 delivered 才认为消息已送达的消费端,恰好会卡在最快看到消息的收件人上;仅根据 delivered 计算投递率会导致低报。在这种情况下,消息 status 保持 sent,因为只有投递回执才会推进该状态。
仅含已读的回调仍可触发适用的 Meta 费用。Bird 在投递和已读路径上使用同一费用标识;缺少投递事件并不意味着 Meta 部分免费。参见费用与计费。
事件类型列表是开放的:新类型可能随时间增加,因此请将无法识别的值视为未来事件而非错误。
失败事件
whatsapp.failed 和 whatsapp.rejected 是终态。拒绝表示 Bird 在将消息发送到 WhatsApp 之前拦截了它,因此不会产生费用。原因包括被屏蔽或已退订的收件人、钱包余额不足或目的地未配置价格。失败表示消息未被投递,error.code 说明了是谁做出的判定。大多数错误码携带 WhatsApp 的裁定,映射自其报告的代码。internal_error 是例外:它记录缺少可用的发送方凭证或处理重试已耗尽。不确定的传输尝试并不能证明 Meta 从未收到该请求。meta_error_code 在可用时包含 WhatsApp 的代码,而 internal_error 失败按定义不包含任何代码。
两种事件都包含一个 error 对象,带有一个稳定的 Bird code、一个人类可读的 description、一个可选的 meta_error_code,以及 occurred_at。该对象仅在这些事件类型的 API 记录和 webhook 载荷中出现。
回应事件
表情回应是对已有消息的标注,不会创建 whatsapp.received 消息。Bird 在联系人添加、更改或移除回应时触发 whatsapp.reacted,详见回应。您的商业号码发送的回应不会触发该 webhook。参见发送回应以添加、替换或移除您的回应,以及接收回应获取 webhook 和 REST API 示例。联系人的回应不会打开客服窗口。
被回应消息的回应日志记录联系人和您的商业号码双方的变更:添加、替换和移除。
有一种情况不会被记录。Bird 通过一个保留 15 天的提供商 ID 将回应与消息匹配,而 WhatsApp 允许对最多 30 天前的消息进行回应,因此对超过该期限的消息的回应无法被匹配,既不会出现在日志中也不会出现在 reactions 中。消息没有条目并不能证明没有人对其做过回应。
使用 GET /v1/whatsapp/messages/{message_id}/reaction-events 读取该日志,按最新排列。每个条目包含表情、变更者,以及 received、sent、failed 或 rejected 的 status;failed 或 rejected 条目在 error 上携带原因。每个条目有一个回应 ID(war_…)和一个 occurred_at 时间戳。移除操作包含 emoji: null。待处理的变更在结果确定前没有条目。回应永远不会产生费用,因此其中的失败不涉及计费。如果需要查看消息上当前生效的回应而非变更历史,请使用 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>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 发送的原样投递,不做规范化,所以 ❤ 和 ❤️ 到达时是不同的字符串。
后续步骤
- 接收回应:处理联系人回应 webhook 并读取当前回应
- 发送回应:添加、替换或移除您的回应
- 将消息标记为已读:确认入站消息并显示输入状态
- WhatsApp 日志:呈现此时间线的逐条消息视图
- 发送 WhatsApp 消息:消息生命周期的起点
- Webhooks 指南:端点、签名、重试和完整事件目录