Webhooks 与事件
当工作区中发生事件(邮件送达、收件人退回、WhatsApp 消息被已读)时,Bird 会向订阅了该事件类型的每个 webhook 端点 POST 一个签名的 JSON 事件。Bird 遵循 Standard Webhooks 规范的请求头、签名和载荷结构,因此如果你已经在验证来自其他 Standard Webhooks 平台的 webhook,同一套验证代码无需修改即可使用。
有关 webhook 端点和投递的概述,请参阅什么是 webhook?。
创建端点
代码示例
bird webhooks create https://example.com/webhooks/bird \
--events email.delivered,email.bounced,email.complained \
--description "Production delivery + bounce notifications"端点管理需要 webhooks 权限范围。控制台会话和 CLI 的登录通过你的用户角色携带该权限,API 密钥也可以持有它:授予 webhooks:read 可查看端点和投递尝试,授予 webhooks:write 可管理它们。底层操作从 POST /v1/webhooks 开始。

端点 URL 必须为 HTTPS,最长 2048 个字符,且可公开访问。使用私有地址、回环地址、链路本地地址或其他内部地址的 URL 在创建或更新端点时会被拒绝并返回 422。投递来自你的网络外部的 Bird 投递基础设施。
events 数组最多可列出 100 个来自事件目录的类型。端点仅接收其列出的类型。使用 PATCH /v1/webhooks/{webhook_id} 替换完整列表以用于后续投递。要接收所有事件,请订阅每个类型:目录之外的类型会被拒绝并返回 422,通配符如 sms.* 也不例外。新增类型不会自动扩展现有订阅。
代码示例
{
"id": "whk_01ky7q639cfh99xb7ysy2x2gzj",
"url": "https://example.com/webhooks/bird",
"events": ["email.delivered", "email.bounced", "email.complained"],
"description": "Production delivery + bounce notifications",
"status": "active",
"secret": "whsec_c+dgRBsFELJ9mR4tu2cyhK0gTMMkqntv",
"created_at": "2026-07-23T14:48:29.740Z",
"updated_at": "2026-07-23T14:48:29.740Z"
}端点支持完整的 CRUD 操作:列出、获取、更新和删除。删除端点将停止所有向其投递的操作(包括对先前失败投递的重试),且不可撤销;若要临时停止投递,请将 status 设为 paused。一个工作区可以注册多个端点,每个端点拥有独立的 URL、事件过滤器和密钥。
验证签名
每次投递携带三个请求头:
| 请求头 | 值 |
|---|---|
| webhook-id | 标识事件投递。对该投递的重试和重放会复用同一值。 |
| webhook-timestamp | 本次投递尝试的 Unix 时间戳(秒) |
| webhook-signature | v1,<base64 HMAC-SHA256>,可能包含多个以空格分隔的签名 |
签名是对字符串 {webhook-id}.{webhook-timestamp}.{raw request body} 进行 HMAC-SHA256 运算的结果,密钥为端点密钥(去掉 whsec_ 前缀后对剩余部分进行 base64 解码以获取密钥字节)。你的处理程序应验证签名、拒绝 webhook-timestamp 超过 5 分钟的投递,并基于 webhook-id 进行去重:Bird 提供至少一次投递保证,因此同一投递可能到达多次。
使用 Bird SDK,签名和时间戳检查只需一次调用;去重仍由你的处理程序负责:
// Pass the RAW request body; set the secret via new BirdClient({ webhooks: { secret } }).
const event = bird.webhooks.unwrap(rawBody, headers);
console.log(event.type); // discriminated union: narrow on event.type# Pass the RAW request body (bytes) and the request headers.
event = client.webhooks.unwrap(request.body, request.headers)
if event.root.type == "email.delivered":
print(event.root.data.email_id)package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(
option.WithAPIKey(os.Getenv("BIRD_API_KEY")),
option.WithWebhookSecret(os.Getenv("BIRD_WEBHOOK_SECRET")),
)
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/webhooks/bird", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
event, err := client.Webhooks.Unwrap(body, r.Header)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent) // ack fast, then process
payload, _ := event.AsAny()
switch p := payload.(type) {
case bird.EmailDeliveredEvent:
fmt.Println("delivered:", p.Data.EmailId, p.Data.Recipient)
case bird.EmailBouncedEvent:
fmt.Println("bounced:", p.Type)
}
})
}// Pass the raw request body because parsing changes the bytes used to compute
// the signature.
$rawBody = file_get_contents('php://input') ?: '';
try {
$event = $bird->webhooks->unwrap($rawBody, getallheaders());
// $event is the decoded payload as an array; branch on $event['type'].
echo $event['type'];
} catch (WebhookVerificationError) {
http_response_code(400); // bad signature, stale timestamp, or missing/malformed headers
}如上述示例所示,使用 400 拒绝投递不会丢弃该事件:我们会按照下方的重试计划重试。这是有意为之,也是你期望的行为。验证失败的常见原因是在密钥轮换或错误部署期间,你的处理程序尚未持有密钥,因此重试窗口是你修复密钥并仍然接收该事件的机会。仅在你确实要永久丢弃该投递时才返回 2xx。
任何 Standard Webhooks 参考库均可使用。如果你手动验证,步骤如下:
代码示例
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5-minute tolerance
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
// During secret rotation, the header can contain several signatures. Accept any match.
return headers["webhook-signature"].split(" ").some((part) => {
const sig = Buffer.from(part.replace(/^v1,/, ""), "base64");
return (
sig.length === Buffer.byteLength(expected, "base64") &&
timingSafeEqual(sig, Buffer.from(expected, "base64"))
);
});
}始终基于原始请求体字节计算 HMAC。解析并重新序列化 JSON 会改变空白或键顺序,导致签名失败。
投递语义
每次投递是一个事件对应一次 HTTP POST 请求,使用 Content-Type: application/json,不做批量处理。端点有 15 秒的响应时间;任何 2xx 状态码视为成功,其他一切(包括 3xx 重定向和超时)视为失败。每次失败遵循相同的重试计划。你返回的状态码只影响投递尝试日志中的显示,不影响是否重试:没有状态码可以提前终止投递。快速响应并异步处理:将事件放入队列,在执行实际工作之前返回 200。
首次尝试后,失败的投递按以下计划重试,带有 ±20% 的抖动以避免重试同步:
| 重试 | 距上次尝试的延迟 |
|---|---|
| 1 | 5 秒 |
| 2 | 5 分钟 |
| 3 | 30 分钟 |
| 4 | 2 小时 |
| 5 | 5 小时 |
| 6 | 10 小时 |
| 7 | 10 小时 |
总共 8 次尝试,历时约 27.5 小时。429 或超时会将不足 60 秒的计划延迟提升到 60 秒,实际上只影响第一次重试:加上抖动后,它会在 48 到 72 秒后到达。失败响应上的 Retry-After 请求头可以延长下一次等待时间。我们接受该请求头的值为延迟秒数或 HTTP 日期。如果请求的延迟长于计划延迟,则替换计划延迟,但上限为计划延迟的两倍(在 60 秒提升之后);如果更短则忽略,因此该请求头永远不会让重试提前。抖动在此基础上叠加。每次重试携带相同的 webhook-id,这正是去重机制生效的原因。最终重试失败后,投递永久失败;可通过重放恢复。
投递不保证顺序。email.delivered 可能在同一消息的 email.accepted 之前到达,尤其是涉及重试时。请按事件载荷中的 timestamp 字段排序,切勿按到达顺序排序。
管理你的端点
测试发送
POST /v1/webhooks/{webhook_id}/test 向你的端点发送一个签名的合成事件,并同步返回结果:端点是否接受、返回的 HTTP 状态码以及往返延迟。测试体是一个仅携带事件 type 的最小 JSON 存根,签名方式与真实投递完全相同;它不反映真实的事件载荷。传入 {"event_type": "email.delivered"} 可从目录中选取任意类型(无论是否已订阅),或省略请求体以使用端点首个已订阅的事件类型。
端点有 10 秒的响应时间。不可达的端点会在响应体中产生 status: failed,但请求本身会成功。使用此结果调试连通性。测试发送直接发往你的端点:它们在暂停的端点上也能工作,且不会记录在投递尝试日志中。412 表示端点尚不能测试,因为它缺少有效的签名密钥或已订阅的事件类型。
要进行带有真实事件流的端到端测试,请发送至沙盒地址:沙盒发送会通过正常投递路径发出真实的 webhook 事件,这是在上线前验证处理程序的最佳方式。
重放失败的投递
POST /v1/webhooks/{webhook_id}/replay 将失败的投递排入重新投递队列。端点已成功接收的事件会被跳过,因此重放不会重复投递;重新投递的事件携带其原始 webhook-id,因此你的去重检查也覆盖重放。仅失败的尝试会被重放:端点从未收到的事件没有失败记录,因此重放不会恢复它。
传入 since/until 时间戳来限定时间窗口(默认:请求时刻往前 24 小时)。两个边界均为闭区间,且均按投递尝试时间而非事件发生时间筛选,因此一次比事件晚一天才执行的重试,会按其实际尝试的时刻落入窗口。重放读取的投递尝试日志保留三天,这就是它能追溯的最早历史:将 since 设得更早只会扩大窗口,不会恢复更旧的记录。一次重放最多覆盖窗口内最早的 10,000 个事件。
请求返回 202,事件将异步重新投递。重新投递仅有一次尝试,不适用上述重试计划。 无论端点是否接受,该尝试都会被记录且任务即告完成,因此向仍有问题的端点重放每个事件只消耗一次请求而非八次;修复端点后再次重放即可。这些失败不影响端点健康状态:重放不会将端点推至 degraded 或触发自动暂停。端点接受的重新投递会同时清除这两者。
重放限制为每个组织每 UTC 日 20 次;超出后请求返回 429(WebhookReplayQuotaExceeded)。响应不包含计数或任务 ID。使用 GET /v1/webhooks/{webhook_id}/attempts 跟踪结果,它按从新到旧的顺序列出最近的投递尝试,包含状态码和延迟。每个 HTTP 请求都有自己的条目,因此重试的事件每次尝试显示一条,重新投递也显示为额外一条。
轮换签名密钥
POST /v1/webhooks/{webhook_id}/rotate-secret 生成新密钥并返回一次。在接下来的 24 小时内,Bird 使用新旧两个密钥对每次投递签名。webhook-signature 请求头包含以空格分隔的签名(v1,<old> v1,<new>),使你可以在重叠期间部署新密钥。Standard Webhooks 库会自动尝试所有签名。24 小时后旧密钥停止签名。一个端点最多持有 5 个同时有效的密钥,因此在重叠窗口内反复轮换会因 WebhookTooManySecrets 而失败,直到较早的密钥过期。
自动暂停与重新启用
端点 status 为 active、degraded 或 paused。近期投递失败会将端点标记为 degraded,作为健康警告;我们仍继续投递和重试。持续失败约五天的端点会被自动 paused,所有投递停止;该期间内一次成功投递即可重置计时。暂停的端点不会自行恢复。使用 PATCH /v1/webhooks/{webhook_id} 和 {"status": "active"}(或从控制台的 Webhooks 页面)重新启用,然后重放以重新投递暂停前失败的尝试。请先重新启用:在端点仍处于暂停状态时请求重放不会重新投递任何内容。端点暂停期间到达的事件从未被发送,因此重放也无法恢复它们。
以下任一操作可将 degraded 端点恢复为 active:
| 清除方式 | 原因 |
|---|---|
| 一次投递成功 | 端点再次接受了事件。 |
| 更改端点的 url | 已记录的失败描述的是你不再使用的目标地址。 |
| 重新启用 paused 端点 | 端点重新投入使用,其历史失败不再适用。 |
| 测试发送返回 2xx | 你已证明端点可达。 |
编辑端点的描述或其订阅的事件类型不能说明可达性,因此不会清除 degraded;测试发送失败同样如此。
当端点首次变为 degraded 时,我们会向组织的所有者发送邮件,每次事件仅通知一次,而非每次投递失败都通知。恢复后再次降级会再次发送邮件,但受 24 小时冷却期限制:每个端点每 24 小时最多发送一封降级邮件,因此在 active 和 degraded 之间反复切换的端点不会淹没收件箱。更改端点的 url 会重置冷却期,因此新 URL 上的首次降级即使在上一封邮件的 24 小时内也可以发送通知。
事件目录
事件载荷包含紧凑的、以收件人为范围的事实,用于与你的系统关联。它们不包含完整资源。如需更多上下文,请通过资源 ID 获取该资源。事件类型遵循 resource.action 命名,并按产品分组;每个产品的事件页面包含各事件的载荷字段:
- Email 事件:投递生命周期(从 email.accepted 到 email.delivered 或 email.bounced)、交互(email.opened、email.clicked)、退订以及入站邮件
- SMS 事件:消息生命周期,从 sms.accepted 到终态
- WhatsApp webhooks:whatsapp.accepted 到 whatsapp.delivered、whatsapp.read、whatsapp.failed、whatsapp.rejected、收到消息时的 whatsapp.received、用户对你的消息做出回应时的 whatsapp.reacted,以及有人申请加入需要审批的群组或撤回申请时的 whatsapp.group.join_request_created 和 whatsapp.group.join_request_revoked
- Verify 事件:验证生命周期(verify.verification.created、verify.verification.verified)以及每次验证码尝试的投递(verify.attempt.sent、verify.attempt.delivered、verify.attempt.undelivered)
- Preference 事件:跨渠道同意记录:preference.granted、preference.revoked 和 preference.deleted
每个投递体是 Standard Webhooks 嵌套信封,包含 type、timestamp 和一个特定类型的 data 对象。webhook-id 请求头携带事件标识。信封中的 timestamp 记录事件发生时间。webhook-timestamp 请求头记录当前投递尝试,每次重试时都会变化。
代码示例
{
"type": "email.delivered",
"timestamp": "2026-06-10T14:30:00Z",
"data": {
"email_id": "em_01krdgeqcxet5s7t44vh8rt9mg",
"recipient_id": "er_01krdgeqcxet5s7t44vh8rt9mg",
"workspace_id": "ws_01krdgeqcxet5s7t44vh8rt9mg",
"recipient": "user@example.com",
"recipient_role": "to",
"tags": [{ "name": "category", "value": "welcome" }],
"metadata": { "order_id": "ord_123" },
"broadcast_id": null
}
}每个 email 事件的 data 包含 email_id、recipient_id、workspace_id、recipient 地址及其信封 recipient_role。它还包含发送请求中的 tags 和 metadata,未提供时为 null。它还携带 broadcast_id,指明该发送所属的广播,无广播时为 null。在 email.unsubscribed 和 email.list_unsubscribed 上,null 并不排除广播的存在;email 事件解释了原因。事件类型在此基础上添加自己的字段。每个变体拥有稳定的字段集:字段默认必填,其存在仅取决于事件类型。
事件名称不会被重命名,新类型随产品发布而添加,因此请让处理程序忽略它不识别的类型。
Preference 事件
声明的偏好(即各渠道指南中描述的同意授权和退订:email、SMS、WhatsApp)跨渠道生效,因此其事件在载荷中而非类型中标明渠道。preference.granted 在同意授权生效时触发,preference.revoked 在退订生效时触发,preference.deleted 在已记录的声明被移除且其键恢复为无记录状态时触发。事件表示键的当前记录发生了变化:与当前记录重复的声明不会触发任何事件,因乱序而被拒绝的声明也不会触发。信封中的 timestamp 是声明生效的时间,对于回溯日期的声明,该时间是声明做出的时间,而非到达 Bird 的时间。
每个载荷携带完整的偏好键:channel、handle、sender_scope 和 topic_id,当范围字段未进一步缩小时以 present-with-null 形式存在。键旁边附带声明的 coverage、preference_id、写入追加的历史条目的 transition_id,以及记录声明时匹配的 contact_id(如无则为 null):
代码示例
{
"type": "preference.revoked",
"timestamp": "2026-08-25T14:52:03.192524705Z",
"data": {
"preference_id": "prf_01krdgeqcxet5s7t44vh8rt9mg",
"transition_id": "prt_01krdgeqcxet5s7t44vh8rt9mg",
"channel": "sms",
"handle": "+15550001234",
"sender_scope": null,
"topic_id": null,
"coverage": "non_transactional",
"contact_id": null
}
}后续步骤
- Webhooks API 参考:完整的端点和模式文档
- Email 事件:各事件的载荷字段
- 测试与沙盒:沙盒发送驱动真实的 webhook 投递,非常适合测试处理程序