开发者

Webhook Beta

当联系人、对话、消息、标签或团队成员发生变化时,接收持久且带签名的通知。

打开 API 参考

创建端点

  1. 01

    打开设置,选择开发者,找到 Webhook,然后选择添加端点。

  2. 02

    填写名称和公开的 HTTPS 端点 URL,然后在事件下至少选择一项。

  3. 03

    选择添加端点。你也可以通过 POST /api/v1/webhooks 创建端点。

Sonny 会拒绝 URL 中包含凭据、localhost、私有/链路本地 IP 段,以及解析到非公开地址的 DNS 名称。

立即保存签名密钥

它以 whsec_ 开头,且只显示一次。请先将其复制到你的密钥管理工具中,再选择我已保存。

将端点限定到指定渠道

在设置 → 开发者中,每个端点在添加时和之后编辑时,都可以选择监听全部渠道或只监听你选择的渠道。在支持渠道限定之前创建的端点会继续监听全部渠道,直到你更改为止。

API 和 MCP 客户端在创建或更新端点时,通过 sourceIds 设置同样的筛选。空数组表示工作区中的所有来源。提供 ID 时,只有当对话属于所选来源之一时,才会投递对话和消息事件。工作区级别的事件(例如联系人或成员变更)不会发送到按来源筛选的端点。

{
  "name": "Product A agent",
  "url": "https://agent.example.com/sonny",
  "events": ["conversation.created", "message.created"],
  "sourceIds": ["cm_source_id"]
}

不再投递你的集成会丢弃的内容

通过 API 回复的智能体会被自己的回复唤醒,除非你另作设置。三个可选筛选条件可以缩小端点接收的范围;它们默认都处于关闭状态,因此现有端点不受影响。

agentGroupIds
只投递由所列客服组负责的对话。它依据的是负责关系,而不是对话进入的渠道,因此来自共享收件箱的对话会送达负责它的组。没有所属组的对话不会被投递;而且由于组通常在对话开始后才分配,conversation.created 往往会在有可匹配的组之前就触发。
customerMessagesOnly
只投递客户发出的消息。跳过团队的回复、内部备注,以及任何通过 API、MCP 或 AI 自动回复发送的内容——包括此端点自己的回复。
excludedSenderIds
跳过所列同事发送的消息。为集成创建一个专属的同事账户并将其排除,就能让它不被自己的回复唤醒,同时仍能在真人接手对话时收到通知——这正是 AI 智能体需要退场的信号。

消息筛选只适用于 message.* 事件;其他所有内容仍由事件类型控制。被筛除的事件在成为投递之前就会被丢弃,因此不会产生任何开销,也绝不会显示为失败。无论哪种情况,负载和 apiVersion 都保持不变。

把一连串消息合并为一次投递

对于被唤醒后会读取整个会话的消费方来说,十秒内收到四次独立投递毫无意义。设置 coalesceSeconds 后,同一对话中的消息会在该时长内被收集起来,然后作为一次投递发送。此功能默认关闭;未设置的端点会继续逐条接收每条消息。

合并后的投递以 conversation.activity 形式到达,包含该对话以及收集到的每个消息 id,因此只需读取一次会话,而不必逐条读取。这是唯一会改变你所接收内容结构的设置,所以需要主动开启。其他筛选条件仍然适用——被筛除的消息永远不会加入合并。每个对话都有自己的时间窗口,重试时也会把这组消息视为一次投递。

{
  "type": "conversation.activity",
  "data": {
    "conversationId": "cm_conversation_id",
    "messageIds": ["cm_first_message", "cm_second_message"]
  }
}

使用 Bearer 或 API 密钥认证的端点

如果你的接收端位于需要特定请求头的网关之后,请在创建或编辑端点时将其添加到自定义请求头中,或通过 API 发送 headers。值会加密存储且绝不返回——已保存的请求头只会返回名称,仅重新提交该名称即可保留已存储的值。发送空数组可移除所有请求头。

把端点改到另一个主机会清除可复用的值:必须重新输入已保存的值,确保凭据绝不会被转发到非其签发对象的目标。只更改路径则会保留它们。

Sonny 自己的请求头优先于你的,因此自定义请求头永远无法替换 sonny-signature、content-type 或投递身份相关的请求头。能验证签名时请优先验证签名:它会认证每一个负载,而静态令牌只能识别调用方。

{
  "name": "Gateway",
  "url": "https://api.example.com/hooks/sonny",
  "events": ["conversation.created"],
  "headers": [{ "name": "Authorization", "value": "Bearer …" }]
}

安全与可靠性

对原始请求体签名
HMAC-SHA256 签名覆盖 Unix 时间戳、一个点号,以及未经改动的 UTF-8 请求体。
防重放
即使 HMAC 有效,也请拒绝早于或晚于当前时间五分钟以上的时间戳。
持久重试
非 2xx 响应会在 1m, 5m, 30m, 2h, 6h 后重试。第 6 次尝试为最终尝试。

请求约定

sonny-signature
t=<unix-seconds>,v1=<sha256-hex>
sonny-event
事件类型,用于快速路由。
sonny-delivery-id
稳定的 ID,用于幂等处理和技术支持。
user-agent
Sonny-Webhooks/1.0
{
  "id": "cm_event_id",
  "type": "message.created",
  "apiVersion": "2026-07-15",
  "createdAt": "2026-07-15T12:00:00.000Z",
  "data": {
    "conversationId": "cm_conversation_id",
    "messageId": "cm_message_id"
  }
}

验证签名

先读取原始请求体。先解析 JSON 再重新序列化会改变空白字符,导致有效的签名验证失败。

import { createHmac, timingSafeEqual } from "node:crypto";

const rawBody = await request.text(); // do not parse/re-serialize first
const signature = request.headers.get("sonny-signature");
const secret = process.env.SONNY_WEBHOOK_SECRET;
if (!signature || !secret) throw new Error("Missing webhook signature or secret");
const { t, v1 } = Object.fromEntries(signature.split(",").map(p => p.split("=")));

if (!/^\d+$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1)) {
  throw new Error("Malformed signature");
}
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) {
  throw new Error("Stale webhook");
}
const expected = createHmac("sha256", secret)
  .update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) {
  throw new Error("Invalid signature");
}

事件目录

webhook.heartbeat

每五分钟向已启用的订阅者(包括 *)发送一次,即使没有新消息也会发送。使用常规的签名投递/重试流程,并忽略对话筛选。可在心跳缺失或过期时发出告警;它不会扫描等待回复的对话。

{"intervalSeconds":300,"nextExpectedAt":"2026-09-29T16:05:00.000Z"}
contact.created

创建了一个联系人。

{"contactId":"cm_contact_id"}
contact.updated

更新或合并了一个联系人。

{"contactId":"cm_contact_id"}
contact.deleted

一个联系人被归档或合并到另一个联系人。仍可通过 archived=true 读取。

{"contactId":"cm_contact_id"}
contact.erased

一个联系人及其所有对话、消息和附件已被永久清除(例如 GDPR 删除请求)。它将无法再被读取;请删除你保留的所有副本。

{"contactId":"cm_contact_id"}
conversation.created

创建了一个对话。

{"conversationId":"cm_conversation_id"}
conversation.updated

一个对话发生了变化。

{"conversationId":"cm_conversation_id"}
conversation.closed

一个对话已关闭。

{"conversationId":"cm_conversation_id"}
conversation.deleted

一个对话被移至回收站,并已从公开 API 中移除。

{"conversationId":"cm_conversation_id"}
message.created

创建了一条消息或内部备注。如有可用,会包含上下文和简短的消息预览;内部备注的文本绝不会包含在内。

{"conversationId":"cm_conversation_id","messageId":"cm_message_id","context":{"sourceId":"cm_source_id","sourceName":"Shopstar Go","storeName":"Shiney Store","inboxId":"cm_inbox_id","inboxName":"Go support","agentGroupId":"cm_group_id","agentGroupName":"Support","identityVerified":true,"status":"open","lastRepliedAt":"2026-09-25T09:00:00.000Z"},"message":{"id":"cm_message_id","type":"incoming","senderType":"human","senderId":null,"senderName":"Shiney","textPreview":"Could you check my bill?","textTruncated":false}}
message.updated

聊天记录中一条已发送的回复被编辑。已投递的邮件保持不变。

{"conversationId":"cm_conversation_id","messageId":"cm_message_id"}
tag.created

创建了一个标签。

{"tagId":"cm_tag_id"}
tag.updated

更新了一个标签。

{"tagId":"cm_tag_id"}
tag.deleted

删除了一个标签。

{"tagId":"cm_tag_id"}
member.invited

邀请了一位工作区成员。

{"invitationId":"cm_invitation_id"}
member.updated

成员的角色或状态发生了变化。

{"memberId":"cm_membership_id"}
member.removed

移除了一位成员。

{"memberId":"cm_membership_id"}
invitation.accepted

工作区邀请已被接受。

{"invitationId":"cm_invitation_id","memberId":"cm_membership_id"}
invitation.cancelled

一个待处理的工作区邀请已被取消。

{"invitationId":"cm_invitation_id"}
conversation.activity

同一对话中的消息,合并为一次投递。如有可用,会包含每条消息的快照。对设有合并时间窗口的端点,会以此代替 message.created 发送;不能直接订阅。

{"conversationId":"cm_conversation_id","messageIds":["cm_message_id","cm_other_message_id"]}
webhook.test

由管理员请求的测试事件。

{"message":"This is a test webhook from Sonny."}

投递行为

  • 在 10 秒内返回任意 2xx 状态码,即表示投递成功。
  • 请保持处理程序幂等。使用事件的 id 或 sonny-delivery-id 忽略重复投递。
  • 不会跟随重定向。请改为在 Sonny 中更新端点 URL。
  • 响应体有大小上限,只保留前 4 KiB 用于诊断。
  • 选择测试发送一个 webhook.test 事件。选择投递记录查看最近 50 个事件。当某次投递最终失败时,选择重试再次发送。

发现静默的事件流

在开发者设置中或通过 update_webhook,把 webhook.heartbeat 添加到端点的事件中。已启用的订阅者(包括 *)每五分钟会收到一次带签名的心跳,即使没有新消息到达。心跳会忽略渠道、团队和消息筛选,且不包含任何对话数据。它们与消息使用相同的投递路径和重试机制。请检查 createdAt 和 nextExpectedAt,以免把旧的重试误当成新的心跳;发出告警前请留出轮询和网络延迟的余量。投递积压可能会延迟或抑制心跳。

使用具备 webhooks:read 的 list_webhook_deliveries 查看投递尝试和失败情况。get_status 检查的是数据库连通性,而不是 Webhook 投递。另行定期调用 list_conversations,参数为 status=open、awaitingReply=true、sort=waitingSince 和 direction=asc,即使事件流很安静,也能找出长时间未回复的旧会话。

在开发者设置中开启“精简负载”,或在端点上设置 compact=true,即可省略上下文标签,同时保留路由 ID、发送者、时间和 200 个字符的消息预览。这同样适用于合并投递;内部备注文字仍会被排除。除新增的消息时间戳外,默认负载保持不变。编辑你现有 API 密钥的权限范围,授予 webhooks:read 和 contacts:read,以便查看投递日志和直接查询联系人。这两个权限范围都需要拥有全部渠道权限的密钥以及全部渠道的成员访问权限;仅添加权限无法扩大已限定渠道的密钥范围。

相关文档