关于 webhook-setup
面向开发者的 Zavu Webhook 配置技能,用于接收消息、投递和模板事件。内容涵盖 Webhook 类型、事件载荷、发送方配置及多渠道入站消息处理。
主要功能
- 01配置发送方和项目级 Webhook
- 02选择消息与投递状态事件
- 03解析通用事件封装和载荷
- 04处理多渠道入站消息
- 05重新生成发送方 Webhook 密钥
- 06获取邮件附件元数据和链接
适用场景
- 01处理客户发来的入站消息
- 02跟踪投递和已读状态
- 03响应模板审批或类别变更
- 04获取入站邮件的附件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zavudev/ zavu-skills --skill webhook-setup安装后在 Claude Code 中输入/webhook-setup确认已加载。
详细介绍
Webhook 设置
适用场景
在设置 Webhook 端点以接收来自 Zavu 的入站消息、投递状态更新或模板审批通知时,使用此技能。
Sender 与账户(一段说明)
Sender 是作为 Zavu-Sender 传入的 API 标识;账户(WhatsApp Business Account、Facebook Page、Telegram bot、电话号码)则是它路由到的连接,也是计费对象。Sender 免费。在控制面板中连接账户时,会自动创建其 Sender;使用 GET /v1/senders 查找,并以其 channels 数组为准,了解它可以发送哪些内容。完整模型请参阅 channel-setup 技能。
Webhook 类型
- Sender Webhook:消息事件(入站消息、投递状态、模板)——按 Sender 配置
- 项目 Webhook:项目级事件(合作伙伴邀请)——每个项目一个。父项目不会收到其子账户的事件:请使用每个子账户自己的 API 密钥配置 Webhook(
POST /v1/invitations/webhook)。
可用事件
| 事件 | 类别 | 说明 |
|---|---|---|
message.inbound | 入站 | 客户向你发送了消息 |
conversation.new | 入站 | 新联系人的首条消息 |
message.unsupported | 入站 | 收到不支持的消息类型 |
contact.identity_linked | 联系人 | 通过 BSUID 识别的 WhatsApp 联系人与电话号码关联;已知电话号码的联系人获得了 BSUID;或联系人更改了号码。data 包含 contactId、bsuid、phoneNumber、reason(provider_reported、contact_shared、phone_changed、bsuid_changed);当 reason 为 phone_changed 时包含 previousPhoneNumber,如果 WhatsApp 从未报告过 bsuid,则其值为 null;两个联系人合并到 contactId 时包含 mergedContactId;当你持有的 BSUID 不再用于识别该联系人时包含 previousBsuid。每次关联仅发送一次 |
message.queued | 出站 | 消息已排队等待投递 |
message.sent | 出站 | 消息已发送至运营商 |
message.delivered | 出站 | 消息已投递给收件人 |
message.read | 出站 | 收件人已读消息 |
message.failed | 出站 | 消息投递失败 |
broadcast.status_changed | 广播 | 广播状态已更改 |
template.status_changed | 模板 | WhatsApp 模板审批状态已更改,或 Meta 重新为其分类。data 包含 templateId、name、previousStatus、currentStatus、rejectionReason、category |
invitation.status_changed | 邀请 | 合作伙伴邀请状态已更改(进行中、已完成、失败、已取消,或重发后恢复为待处理)。过期时不会发送 |
domain.verified | 域名 | 自定义电子邮件域名已通过验证 |
domain.failed | 域名 | 自定义电子邮件域名验证失败 |
通过 SDK 配置 Webhook
TypeScript - 创建带有 Webhook 的 Sender
const sender = await zavu.senders.create({
name: "My Sender",
phoneNumber: "+15551234567",
webhookUrl: "https://your-app.com/webhooks/zavu",
webhookEvents: ["message.inbound", "message.delivered", "message.failed"],
});
// Store sender.webhook.secret securely - only shown once!
更新 Webhook
await zavu.senders.update({
senderId: "snd_abc123",
webhookUrl: "https://new-url.com/webhooks",
webhookEvents: ["message.inbound"],
webhookActive: true,
});
重新生成密钥
const result = await zavu.senders.webhookSecret.regenerate({
senderId: "snd_abc123",
});
console.log(result.secret); // whsec_new_secret...
Webhook 负载结构
所有事件的顶层信封结构相同;特定于事件的字段位于 data 中。timestamp 表示 Zavu 分发 Webhook 的时间(Unix 毫秒)。
{
"id": "evt_1705312200000_abc123",
"type": "message.inbound",
"timestamp": 1705312200000,
"senderId": "snd_abc123",
"projectId": "prj_xyz789",
"data": { }
}
入站消息数据(message.inbound)
对于 message.inbound,data 携带消息。对于入站消息,to 是你自己的号码(消息的目的地),from 是发送者。
| 字段 | 描述 |
|---|---|
messageId | Zavu 消息 ID。 |
conversationId | 收件箱会话 ID。始终有值,因为 webhook 会在消息进入会话后发送;新会话的第一条消息携带的 ID 与 conversation.new 相同。反应事件中不存在。请参阅下文“深度链接到收件箱”。 |
contactId | Zavu 联系人。与 from 不同,当 WhatsApp 联系人的标识符在 BSUID 和电话号码之间切换时,它不会改变:请以此为准,不要使用 from。例外情况是将同一个人的两个联系人合并;此时 contact.identity_linked 会将旧 ID 作为 mergedContactId 携带。反应事件中不存在。 |
whatsapp | 仅限 WhatsApp,且仅在已知 BSUID 时提供:{ bsuid, username? }。 |
from | 发送者:一对一消息中为联系人,群组消息中为参与者。通常是 E.164 电话号码;但对于采用用户名并隐藏了号码的 WhatsApp 联系人,这是其业务范围用户 ID(BSUID,例如 US.13491208655302741918)——请将其视为不透明值,并在回复时将其作为 to 传回。只有 WhatsApp 在该消息中包含电话号码时,此字段才是电话号码,因此同一个人的值可能会在电话号码和 BSUID 之间切换。 |
to | 你自己的号码(消息的目的地)。 |
channel | 传送渠道(sms、whatsapp、telegram、email、instagram、messenger、voice)。 |
messageType | text、image、video 等。对 location_request 的回复会以 location 到达(不是新类型),并设置 content.replyToMessageId 指向请求;请据此进行匹配和关联。 |
text | 文本正文或媒体说明(如果有)。 |
providerTimestamp | WhatsApp、Telegram、Instagram、Messenger 的提供商原始接收时间(Unix 毫秒);SMS 和电子邮件则为 null。与顶层 timestamp 比较,以检测延迟投递。 |
referral | 点击进入 WhatsApp 广告的归因信息。仅限 WhatsApp,且仅出现在广告来源会话的第一条消息中。请参阅下文“点击进入 WhatsApp 的归因”。 |
telegramChatId | 仅限 Telegram:用于回复的聊天 ID,不带前缀。 |
instagramSenderId | 仅限 Instagram:用于回复的发送者 ID,不带前缀。 |
messengerSenderId | 仅限 Messenger:用于回复的发送者 ID,不带前缀。 |
使用同一发送方回复
使用 POST /v1/messages 发送回复,并将载荷顶层的 senderId 作为 Zavu-Sender 标头传入,这样消息就会从联系人联系过的发送方发出。to 的值取决于渠道:
- SMS、WhatsApp 和电子邮件:
data.from。 - Telegram:
data.telegramChatId。Instagram:data.instagramSenderId。Messenger:data.messengerSenderId。在这些渠道中,data.from带有telegram:/instagram:/messenger:前缀,而to不接受此前缀,发送请求会被拒绝并返回400。
const { senderId, data } = event;
const to =
data.channel === "telegram" ? data.telegramChatId :
data.channel === "instagram" ? data.instagramSenderId :
data.channel === "messenger" ? data.messengerSenderId :
data.from;
await zavu.messages.send({ to, channel: data.channel, text: "Thanks, we got it.", "Zavu-Sender": senderId });
深度链接到收件箱
conversation.new 会在 data 中携带 conversationId(收件箱会话 ID),message.inbound 也一样。构建一个直接链接,让团队可以在 Zavu 控制面板中打开该会话:
https://dashboard.zavu.dev/{locale}/inbox?conv={conversationId}
{locale} 是控制面板的 UI 语言(例如 en、es)。
对于 message.inbound,conversationId 始终有值;反应事件中不存在。
回复 / 引用上下文
当联系人回复(引用)了较早的消息时,message.inbound 中的 data.content 会包含此信息。
| 字段 | 说明 |
|---|---|
replyToMessageId | 被引用消息的 Zavu 消息 ID。如果被引用的消息未存储在 Zavu 中,则省略。 |
replyToProviderMessageId | 被引用消息的服务商消息 ID(WhatsApp WAMID)。只要是回复,就会提供此字段。 |
replyToFrom | 被引用消息的发送方(E.164)。 |
replyToText | 被引用消息文本的截断片段(媒体消息为空)。 |
replyToMessageType | 被引用消息的类型(text、image 等)。 |
仅适用于 WhatsApp:传入的 Telegram 消息不会报告联系人引用的内容。若要在回复中引用自己的消息,请发送发送方 event.senderId 对应的 content.replyToMessageId: event.data.messageId(参见发送消息技能)。
点击 WhatsApp 广告归因
当联系人点击点击跳转至 WhatsApp(CTWA)的广告或帖子与你联系时,data.referral 会记录他们的来源。此功能仅适用于 WhatsApp,且只会在该对话的第一条消息中出现,之后的消息中不会再出现——收到后请将其持久化,因为不会再次发送。自然产生的对话不会包含此字段。
| 字段 | 说明 |
|---|---|
ctwaClid | 点击跳转至 WhatsApp 的点击标识符。Meta 的 Conversions API 需要此字段,才能将转化归因到相应广告。广告来源会包含此字段;帖子来源则不会。 |
sourceId | 产生此次点击的广告或帖子的标识符。 |
sourceType | ad 或 post。 |
sourceUrl | 广告或帖子的 Meta 永久链接。 |
headline | 广告或帖子的标题。 |
body | 广告或帖子的正文文案。 |
mediaType | 广告包含媒体时为 image 或 video。 |
imageUrl / videoUrl / thumbnailUrl | 广告媒体。只会提供与 mediaType 对应的字段。 |
{
"type": "message.inbound",
"data": {
"messageId": "jd7x2k3m4n5p6q7r8s9t0",
"from": "+56912345678",
"channel": "whatsapp",
"messageType": "text",
"text": "Hi, I saw your ad",
"referral": {
"sourceType": "ad",
"sourceId": "120210000000000000",
"headline": "Free first consultation",
"mediaType": "image",
"ctwaClid": "ARIzZm9vYmFyY3R3YWNsaWQ"
}
}
}
不适用的字段会被省略,而不是以空值发送,因此读取前请检查字段是否存在。GET /v1/messages/{messageId} 会在 content.referral 下返回相同对象。
电子邮件附件
传入的电子邮件会以 message.inbound 的形式到达,其中 channel: "email",且 messageType: "text"。Webhook 负载只包含正文(text,以及获取消息时的 htmlBody),不包含附件数据。附件会单独存储,按需获取。
若要获取附件,请使用 Webhook 中的 messageId 调用 GET /v1/messages/{messageId}/attachments。此接口会返回每个附件的元数据,以及短时有效的签名 downloadUrl(每次请求都会重新生成——请及时获取,不要缓存该 URL)。你发送的带附件的外发电子邮件也适用此方法。没有已存储附件的消息会返回空列表。
curl https://api.zavu.dev/v1/messages/MESSAGE_ID/attachments \
-H "Authorization: Bearer $ZAVU_API_KEY"
{
"items": [
{
"id": "att_abc123",
"filename": "invoice.pdf",
"mimeType": "application/pdf",
"size": 102400,
"contentId": null,
"isInline": false,
"downloadUrl": "https://...signed-url...",
"createdAt": "2024-01-15T10:00:00.000Z"
}
]
}
嵌入在 HTML 正文中的行内图片带有 isInline: true,并在 htmlBody 中通过 cid:<contentId> 引用其 contentId。
合作伙伴邀请数据(invitation.status_changed)
合作伙伴邀请每次存储状态发生变化时都会触发,并发送到创建该邀请的项目的项目 Webhook。状态未发生变化时不会发送事件。过期不属于存储的状态变化:超过 expiresAt 的 pending 邀请读取时会显示为 expired,但不会触发事件。data 包含:
| 字段 | 描述 |
|---|---|
invitationId | Zavu 邀请 ID。 |
clientName / clientEmail | 创建邀请时传入的值,或 null。 |
connectionType | 客户端连接的对象:whatsapp_waba 或 messenger。 |
previousStatus / currentStatus | 状态转换。 |
senderId | 在 completed 时存在:在你的项目中创建的发送方。 |
wabaAccountId | 在已完成的 WhatsApp 邀请中存在:WhatsApp Business Account ID。 |
connectedAccount | 在 completed 时存在:{ channel, id, name } — 已关联的 WhatsApp 号码或 Facebook 公共主页。 |
failureReason | 在 failed 时存在。稳定代码包括:fb_cancelled、fb_not_authorized、signup_abandoned、meta_no_pages、internal_error 等。对于未知代码,请按通用失败处理。 |
failed 邀请并非终态:同一链接仍然可用,客户重试后状态会回到 in_progress。只有在 completed 时才执行配置。
{
"id": "evt_1736850000000_abc123",
"type": "invitation.status_changed",
"timestamp": 1736850000000,
"projectId": "jx7xyz789ghi012",
"data": {
"invitationId": "jh7am5bng9p3v2x1k4r8",
"clientName": "Acme Corp",
"clientEmail": "[email protected]",
"connectionType": "messenger",
"previousStatus": "in_progress",
"currentStatus": "completed",
"senderId": "sender_12345",
"connectedAccount": {
"channel": "messenger",
"id": "1077492835456839",
"name": "Acme Store"
}
}
}
签名验证
标头:X-Zavu-Signature: t=<unix_seconds>[,v1=<hex>][,v2=<hex>]
| 部分 | 包含的内容 |
|---|---|
t | Unix 时间戳,单位为 秒 |
v1 | HMAC_SHA256(secret, body) |
v2 | HMAC_SHA256(secret, "{t}.{body}") — 当前方案 |
使用正确的载荷计算哈希,否则每次投递都会返回 401。 v1 仅包含正文。
只有 v2 包含 {t}.{body}。读取发送方上的 webhook.signatureVersion(GET /v1/senders/{senderId}),即可知道接收方使用哪种签名。新发送方默认使用 v2;此前创建的发送方在迁移前均使用 v1。
如果存在 v2,请优先使用;否则回退到 v1,这样同一套实现即可适用于迁移前、迁移期间和迁移后。
算法
1. read the RAW body (no JSON parser in front)
2. parse the header into { t, v1?, v2? }
3. reject if |now - t| > 300
4. signed = v2 present ? `${t}.${body}` : body
expected = HMAC_SHA256(secret, signed)
5. constant-time compare against v2 ?? v1
TypeScript(Express)
import crypto from "crypto";
import express from "express";
const app = express();
// Raw body. NOT express.json() — the signature covers the exact bytes sent.
app.use("/webhooks/zavu", express.raw({ type: "application/json" }));
function verifyZavuSignature(rawBody: string, header: string, secret: string): boolean {
if (!header) return false;
const parts: Record<string, string> = {};
for (const piece of header.split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1);
}
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
const age = Math.floor(Date.now() / 1000) - t;
if (age > 300 || age < -60) return false;
const received = parts.v2 ?? parts.v1;
if (!received) return false;
const signed = parts.v2 ? `${t}.${rawBody}` : rawBody;
const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
// Length first: timingSafeEqual throws on a mismatch.
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
app.post("/webhooks/zavu", (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyZavuSignature(rawBody, req.headers["x-zavu-signature"] as string, process.env.ZAVU_WEBHOOK_SECRET!)) {
return res.status(401).send("Invalid signature");
}
// Answer fast, then work. Zavu retries non-2xx, so a slow handler turns one
// event into five.
res.status(200).send("OK");
processEvent(JSON.parse(rawBody)).catch(console.error);
});
Python(Flask)
import hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
def verify_zavu_signature(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
parts = {}
for piece in header.split(","):
key, sep, value = piece.partition("=")
if sep:
parts[key] = value
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
age = int(time.time()) - t
if age > 300 or age < -60:
return False
received = parts.get("v2") or parts.get("v1")
if not received:
return False
signed = f"{t}.".encode() + raw_body if "v2" in parts else raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
@app.post("/webhooks/zavu")
def zavu_webhook():
raw_body = request.get_data() # bytes, before parsing
header = request.headers.get("X-Zavu-Signature", "")
if not verify_zavu_signature(raw_body, header, os.environ["ZAVU_WEBHOOK_SECRET"]):
return "Invalid signature", 401
enqueue(request.get_json())
return "OK", 200
Go
func verifyZavuSignature(rawBody []byte, header, secret string) bool {
if header == "" {
return false
}
parts := map[string]string{}
for _, piece := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(piece, "="); ok {
parts[k] = v
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
age := time.Now().Unix() - t
if age > 300 || age < -60 {
return false
}
received, hasV2 := parts["v2"]
if !hasV2 {
received = parts["v1"]
}
if received == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
if hasV2 {
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
}
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(received))
}
Ruby(Sinatra)
def verify_zavu_signature(raw_body, header, secret)
return false if header.nil? || header.empty?
parts = {}
header.split(',').each do |piece|
key, _, value = piece.partition('=')
parts[key] = value unless value.empty?
end
t = Integer(parts['t'], exception: false)
return false if t.nil?
age = Time.now.to_i - t
return false if age > 300 || age < -60
received = parts['v2'] || parts['v1']
return false if received.nil?
signed = parts['v2'] ? "#{t}.#{raw_body}" : raw_body
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed)
OpenSSL.secure_compare(expected, received)
end
PHP
function verifyZavuSignature(string $rawBody, ?string $header, string $secret): bool {
if (!$header) return false;
$parts = [];
foreach (explode(',', $header) as $piece) {
$i = strpos($piece, '=');
if ($i !== false) $parts[substr($piece, 0, $i)] = substr($piece, $i + 1);
}
if (!isset($parts['t']) || !ctype_digit($parts['t'])) return false;
$t = (int) $parts['t'];
$age = time() - $t;
if ($age > 300 || $age < -60) return false;
$received = $parts['v2'] ?? $parts['v1'] ?? null;
if ($received === null) return false;
$signed = isset($parts['v2']) ? "{$t}.{$rawBody}" : $rawBody;
return hash_equals(hash_hmac('sha256', $signed, $secret), $received);
}
接收方使用哪种方案?
从发送方读取。配置了 Webhook 时,webhook.signatureVersion 始终存在。
npx zavudev senders signature $SENDER_ID
endpoint https://api.example.com/webhooks/zavu
signature v1
Next step:
npx zavudev senders update snd_abc --signature-version v1+v2
That sends both signatures at once, so your current receiver is unaffected.
npx zavudev senders list 有一列 signature,可以最快查看哪些接收方仍需迁移。
通过 API 可使用 GET /v1/senders/{senderId} -> webhook.signatureVersion 查询。
将 Webhook 迁移到 v2
# 1. Both signatures, one shared t. Your v1 receiver notices nothing.
npx zavudev senders update $SENDER_ID --signature-version v1+v2
# 2. Deploy the verifier above. Confirm real deliveries land in YOUR logs.
# 3. Drop v1.
npx zavudev senders update $SENDER_ID --signature-version v2
通过 API 执行相同操作:
curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \
-H "Authorization: Bearer $ZAVUDEV_API_KEY" \
-d '{"webhookSignatureVersion": "v1+v2"}'
全新发送方默认使用 v2。如果它指向的端点已为仍读取 v1 的旧发送方提供服务,请同时启用两种方案:
npx zavudev senders create --name Support --phone +15551234567 \
--webhook-url https://api.example.com/webhooks/zavu \
--webhook-events message.inbound \
--signature-version v1+v2
从 v1 直接切换到 v2 会返回 400;请先设置为 v1+v2。第 2 步才是关键:从 Zavu 的角度来看,在验证之前就返回 200 的接收方与正常工作的接收方完全相同,因此测试请求通过并不能证明任何问题。请在接收方自己的日志中确认。
完整指南:https://docs.zavu.dev/guides/receiving-messages/signature-migration
工具 Webhook 的格式有所不同
当 Agent 调用带有 webhookUrl 的工具时,Zavu 会使用相同的请求头名称,但格式不同来发送 POST 请求:
X-Zavu-Signature: 2120e306a4ce... bare hex, no t=, no v1=/v2=
X-Zavu-Timestamp: 1786113454812 separate header, MILLISECONDS
X-Zavu-Tool: get_order_status
摘要为 HMAC_SHA256(secret, body),与 v1 相同,但信封格式不同,因此上面的验证器遇到这类请求时会返回 false。请另外编写一个验证器,或根据请求头是否包含 = 进行分支处理。
请注意以下两点:
- 每次工具调用都会签名。 创建工具时,Zavu 会生成一个密钥,并且只会在该响应中返回。如果你已有密钥,可使用
--secret提供。密钥丢失了?请轮换密钥:POST /v1/senders/{senderId}/agent/tools/{toolId}/webhook/secret。如果缺少请求头,请拒绝请求;切勿“跳过未签名请求的验证”。 - 负载中包含自身的
timestamp,位于已签名的请求体内。请根据该时间戳检查时效性,而不要使用X-Zavu-Timestamp请求头。
在 Zavu Function 中使用 defineTool 声明的工具无需处理这些:处理程序在函数内部运行,不经过 HTTP。
重试策略
| 尝试 | 延迟 |
|---|---|
| 第 1 次重试 | 1 分钟 |
| 第 2 次重试 | 5 分钟 |
| 第 3 次重试 | 15 分钟 |
| 第 4 次重试 | 1 小时 |
| 第 5 次重试 | 4 小时 |
重试 5 次后,投递会标记为失败。
最佳实践
- 尽快返回 200——在 30 秒内响应,并异步处理
- 验证签名——在生产环境中始终验证
- 处理程序保持幂等——检查
event.id以跳过重复事件 - 使用原始请求体——签名根据原始请求体计算,而非解析后的 JSON
- 使用 ngrok 测试——在开发时将本地服务器公开