Sign inGet started

WhatsApp 事件

Bird 为入站和出站 WhatsApp 消息记录事件。出站时间线显示发送返回 202 之后发生了什么:接受、移交给 WhatsApp、投递、已读或失败。入站时间线记录 Bird 何时收到消息。

事件信封

公共出站投递事件使用标准 webhook 信封:一个 type、一个 timestamp 和一个特定类型的 data 对象。
代码示例
{
  "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_idworkspace_iddirectionfromtotagsmetadatawhatsapp.reacted 是例外,因为回应是对消息的标注而非独立消息;下文回应部分给出了其结构。地址可以包含 E.164 格式的 phone_numberbsuid 中的 Meta 业务范围用户 ID,或两者兼有。从 WhatsApp 用户收到的消息还会携带该用户公开的资料,位于 usernamedisplay_name 中。当发送时未携带时,tagsmetadatanull。以回复形式发送的消息还会在其时间线的每个出站事件上携带 in_reply_to_message_id,从 whatsapp.acceptedwhatsapp.readwhatsapp.failedwhatsapp.rejected,指明其回复的消息。
事件 API 返回更精简的时间线记录,包含 idtypeoccurred_at 时间戳。消息 ID 已包含在请求 URL 中。

生命周期事件

事件按时间顺序排列。出站消息可能在 whatsapp.failedwhatsapp.rejected 处终止,whatsapp.read 仅在收件人打开消息时出现。入站消息只有一个 whatsapp.received 时间线事件。
事件含义
whatsapp.acceptedBird 接受了发送请求。这是 202 报告的内容。
whatsapp.sentBird 已将消息移交给 WhatsApp 网络。
whatsapp.deliveredWhatsApp 确认消息已投递到收件人的设备。
whatsapp.read收件人打开了消息。
whatsapp.failed消息未投递。error.code 说明了阻止原因。
whatsapp.rejectedBird 在发送前拒绝了消息。未产生费用。
whatsapp.receivedBird 收到了来自联系人的入站消息。
whatsapp.delivered 也是 Meta 对消息价格的分成被扣费的时刻,但事件本身不会报告费用:WhatsApp 事件载荷不包含费用信息。使用 GET /v1/whatsapp/messages/{message_id} 回读消息以查看费用。参见费用与计费
whatsapp.read 不会更改消息的 status。已投递的消息保持 delivered;消息同时在 read_at 中记录已读。
whatsapp.delivered 可能被完全跳过。 当收件人的设备上已打开该聊天时,Meta 会报告已读而不报告投递,因此时间线显示为 whatsapp.acceptedwhatsapp.sentwhatsapp.read,中间没有 whatsapp.delivered。应将 read 视为投递证明:等待 delivered 才认定消息送达的消费者,恰好会卡在最快看到消息的收件人上;而仅根据 delivered 计算投递率则会低估。在这种情况下消息的 status 保持 sent,因为只有投递回执才会推进它。
跳过还会影响计费:Meta 的价格分成基于 delivered 回执扣费,因此以这种方式被阅读的消息不会产生 passthrough_amount。参见费用与计费
事件类型列表是开放的:随时可能添加新类型,因此应将无法识别的值视为未来事件而非错误。

失败事件

whatsapp.failedwhatsapp.rejected 是终态。拒绝意味着 Bird 在发送到 WhatsApp 之前阻止了消息,因此不会产生费用。原因包括被屏蔽或已退订的收件人、钱包余额不足或目的地未配置价格。失败意味着消息未投递,error.code 说明了由谁做出的判定。大多数错误码携带 WhatsApp 的裁定,由其报告的代码映射而来。internal_error 是例外:它表示消息根本未到达 WhatsApp,原因可能是发送号码没有可用的凭据,或者 Bird 重试发送直至放弃。meta_error_code 在可用时包含 WhatsApp 的代码,internal_error 类型的失败按定义没有该代码。
两种事件都包含一个 error 对象,其中有一个稳定的 Bird code、一个可读的 description、一个可选的 meta_error_codeoccurred_at。该对象仅在这些事件类型的 API 记录和 webhook 载荷中出现。

回应事件

表情回应是对消息的标注而非独立消息,因此不会出现在任何一条时间线上。回应不会产生 whatsapp.* 事件,入站回应不会触发 whatsapp.received webhook,也没有任何 webhook 会携带回应。每次变更都记录在被回应消息自身的回应日志中:每次放置的表情、每次替换为不同表情,以及每次撤回,无论是联系人还是你的业务号码发起的。
有一种情况不会被记录。Bird 通过一个保留 15 天的提供商 ID 将回应与其消息匹配,而 WhatsApp 允许对最多 30 天前的消息进行回应,因此对超过该时限的消息发出的回应无法匹配,既不会出现在日志中,也不会到达 reactions。消息没有条目并不能证明没有人对其作出过回应。
使用 GET /v1/whatsapp/messages/{message_id}/reaction-events 读取该日志,按最新排序。每条记录包含表情、变更者,以及一个 status,值为 receivedsentfailedrejectedfailedrejected 类型的记录在 error 上携带原因。回应不会产生费用,因此回应失败不涉及计费。要查看消息上当前保留的回应而非变更历史,请使用 GET /v1/whatsapp/messages/{message_id} 读取其 reactions,它会将日志折叠为每个发送者一条记录。

屏蔽事件

除了单条消息的生命周期之外,还有一个事件报告工作区屏蔽列表的变更:whatsapp_suppression.created 在屏蔽生效时触发。载荷包含 suppression_id、E.164 格式的被屏蔽 address、该屏蔽所限定的 waba(为 null 时表示覆盖整个工作区,无论哪个账号发送)、reasonworkspace_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);
一条经过接受、发送、投递和已读的消息返回四个事件:
代码示例
{
  "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 日志页面渲染的内容一致。
Bird 仪表板中的 WhatsApp 消息详情面板,打开了一条已投递的 bird_order_confirmation 消息:Events 选项卡显示 Accepted、Sent、Delivered 和 Read 的逐条消息生命周期时间线,各带时间戳,下方是变暗的消息列表

Webhooks

Webhooks 页面或 webhooks API 订阅 whatsapp.acceptedwhatsapp.sentwhatsapp.deliveredwhatsapp.readwhatsapp.failedwhatsapp.rejectedwhatsapp.receivedwhatsapp.reactedWebhooks 指南涵盖端点、签名和重试。
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"
}
其他内容分支遵循相同的多选一结构:textimagevideoaudiostickerdocumentlocationcontact_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 发送时的样子投递,不做规范化,因此 ❤️ 作为不同的字符串到达你这里。

后续步骤