mcpField
W
webhook-setup

webhook-setup

github.com/zavudev开发1239 次安装2 天前更新
代码仓库

关于 webhook-setup

面向开发者的 Zavu Webhook 配置技能,用于接收消息、投递和模板事件。内容涵盖 Webhook 类型、事件载荷、发送方配置及多渠道入站消息处理。

主要功能

  1. 01配置发送方和项目级 Webhook
  2. 02选择消息与投递状态事件
  3. 03解析通用事件封装和载荷
  4. 04处理多渠道入站消息
  5. 05重新生成发送方 Webhook 密钥
  6. 06获取邮件附件元数据和链接

适用场景

  1. 01处理客户发来的入站消息
  2. 02跟踪投递和已读状态
  3. 03响应模板审批或类别变更
  4. 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 是发送者。

字段描述
messageIdZavu 消息 ID。
conversationId收件箱会话 ID。始终有值,因为 webhook 会在消息进入会话后发送;新会话的第一条消息携带的 ID 与 conversation.new 相同。反应事件中不存在。请参阅下文“深度链接到收件箱”。
contactIdZavu 联系人。与 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)。
messageTypetext、image、video 等。对 location_request 的回复会以 location 到达(不是新类型),并设置 content.replyToMessageId 指向请求;请据此进行匹配和关联。
text文本正文或媒体说明(如果有)。
providerTimestampWhatsApp、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产生此次点击的广告或帖子的标识符。
sourceTypead 或 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 包含:

字段描述
invitationIdZavu 邀请 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>]

部分包含的内容
tUnix 时间戳,单位为 秒
v1HMAC_SHA256(secret, body)
v2HMAC_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 次后,投递会标记为失败。

最佳实践

  1. 尽快返回 200——在 30 秒内响应,并异步处理
  2. 验证签名——在生产环境中始终验证
  3. 处理程序保持幂等——检查 event.id 以跳过重复事件
  4. 使用原始请求体——签名根据原始请求体计算,而非解析后的 JSON
  5. 使用 ngrok 测试——在开发时将本地服务器公开

评论

支持 Markdown