接收项目 Webhook
项目 Webhook 会把 Connector for SaaS 的事件发送到你后端的 HTTPS 端点。你可以用它得知终端用户何时完成或未能完成账号连接,也可以用它接收 Trigger 订阅产生的事件。
一个项目最多可以创建 3 个 Webhook。每个 Webhook 分别选择事件类型和接收的账号授权类型,每次投递都使用该 Webhook 的签名 Secret 签名。默认情况下,Webhook 会收到项目内所有连接账号的事件;如果要把某个账号的事件发到单独的接收端,可以把该账号路由到专用 Webhook。
事件类型
| 事件类型 | 发送时机 |
|---|---|
connection.succeeded | 终端用户通过授权链接或提交凭据完成账号连接。 |
connection.failed | 账号连接失败,例如用户取消授权,或 provider 拒绝凭据。 |
trigger.message | Trigger 订阅产生了事件。见用 Trigger 接收第三方事件。 |
authTypes 限定 Webhook 只接收对应授权类型账号的事件,可选 oauth2、api_key、custom_credential。连接事件和 Trigger 事件都按它过滤。例如 authTypes: ["oauth2"] 的 Webhook 不会收到 API Key 账号的任何事件。
创建 Webhook
在 OOMOL Console 打开项目,选择项目后进入 Webhooks,点击创建 Webhook。填写 HTTPS 地址,选择事件类型和授权类型后创建。
后端也可以使用 Project API key 创建:
POST https://connector.oomol.com/v1/saas/projects/{projectId}/webhooks
Authorization: Bearer <project-api-key>
Content-Type: application/json
{
"url": "https://api.example.com/oomol/webhooks",
"eventTypes": ["connection.succeeded", "connection.failed", "trigger.message"],
"authTypes": ["oauth2", "api_key", "custom_credential"],
"description": "Production receiver"
}
| 字段 | 说明 |
|---|---|
url | 必填:公网可访问的 HTTPS 地址。 |
eventTypes | 必填:上表中的一个或多个事件类型。 |
authTypes | 必填:要接收事件的一个或多个账号授权类型。 |
scope | 可选:project(默认)接收所有未路由账号的事件;routed 只接收路由到它的账号的事件。创建后不能修改。 |
eventVersion | 可选:该 Webhook 固定使用的消息版本,当前为 1。 |
description | 可选:不超过 200 个字符的备注。 |
响应中的 signingSecret 形如 whsec_…,只返回一次。关闭 Console 对话框或丢弃响应前,先把它保存到后端的密钥管理服务中;之后只能看到 signingSecretPrefix。
使用 PATCH /v1/saas/projects/{projectId}/webhooks/{webhookId} 可以修改地址、事件类型、授权类型、备注或 status(active / disabled)。停用的 Webhook 不会收到投递。scope 不能修改,需要时请另建一个 Webhook。
把单个账号的事件路由到专用 Webhook
当某个连接账号的事件需要发到单独的接收端时,可以路由这个账号。例如把测试账号的事件发到预发环境,或发到某个客户自己运行的接收端。
-
创建
"scope": "routed"的 Webhook。在 Console 中,把接收范围设为仅路由的连接。 -
终端用户连接账号时,把该 Webhook 的 ID 作为
webhookId传入。授权链接(POST /v1/saas/connected-accounts/link)和凭据提交(例如POST /v1/saas/connected-accounts/api-key)都支持这个字段:{ "providerConfigId": "<provider-config-id>", "userId": "customer-1", "webhookId": "<routed-webhook-id>", "returnUri": "https://app.example.com/connections/done" } -
要路由已经连接的账号,或取消路由,调用
PATCH /v1/saas/projects/{projectId}/connected-accounts/{connectedAccountId},请求体为{"webhookId": "<routed-webhook-id>"}或{"webhookId": null}。在 Console 中,打开连接账号,编辑该连接,然后设置事件投递。
| 账号 | 事件发往 |
|---|---|
| 未路由 | 所有 eventTypes 和 authTypes 匹配的 active project Webhook。路由 Webhook 收不到它的事件。 |
| 已路由 | 只发往它的路由 Webhook,且该 Webhook 必须为 active,eventTypes 和 authTypes 也要匹配;否则事件不会投递,也不会改发到 project Webhook。 |
webhookId必须是同一项目中的routedWebhook,否则返回 404webhook_not_found。- 带
webhookId发起的连接,其connection.succeeded或connection.failed事件会发往该 Webhook。 - 重新连接时不传
webhookId,会保留当前路由。 - 已路由账号上的 Trigger 订阅会把
trigger.message事件发往它的路由 Webhook。 - 删除路由 Webhook 后,路由到它的账号会解除路由,事件重新发往
projectWebhook。 - 路由在事件记录时确定。修改路由后,已排队或正在重试的投递仍发往原来的 Webhook;如果该 Webhook 已停用或删除,这些投递会停止,不会改发到新的 Webhook。
- 连接账号的响应包含
webhookId,未路由的账号为null。
校验每次投递
每次投递都是一个带 JSON 请求体的 HTTPS POST,请求头如下:
| 请求头 | 值 |
|---|---|
x-oo-signature | t=<Unix 秒>,v1=<十六进制签名> |
x-oo-event | 事件类型,例如 trigger.message。 |
x-oo-event-id | envelope 的 id,同一事件重试时保持不变。 |
x-oo-webhook-id | 接收本次投递的 Webhook。 |
x-oo-delivery-id | 投递 ID,与投递记录中的一致。 |
签名使用签名 Secret 对 <t>.<原始请求体> 计算 HMAC-SHA256,并以十六进制编码。请在解析 JSON 之前,用原始请求体校验:
import { createHmac, timingSafeEqual } from "node:crypto";
const toleranceSeconds = 300;
export function verifyOomolWebhook(secret: string, rawBody: string, header: string | null) {
if (!header) return false;
let timestamp = Number.NaN;
const signatures: string[] = [];
for (const part of header.split(",")) {
const [key, value] = part.split("=", 2);
if (key === "t") timestamp = Number(value);
if (key === "v1" && value) signatures.push(value);
}
const now = Math.floor(Date.now() / 1000);
if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest();
return signatures.some((signature) => {
const actual = Buffer.from(signature, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
});
}
校验失败时拒绝请求。只要任意一个 v1 匹配就接受:轮换 Secret 期间,请求头会为每个有效 Secret 各带一个签名。
读取事件 envelope
所有事件使用同一种 envelope。用 id 去重:同一事件在重试和不同 Webhook 之间都保持相同的 id。
{
"id": "evt_0199d1f4-7b2a-7c3e-9a51-3f0f2b8c1d20",
"type": "connection.succeeded",
"version": 1,
"createdAt": 1800000000000,
"data": {
"connectionRequestId": "0199d1f3-1c4e-7a90-b2d3-5e6f7a8b9c0d",
"externalUserId": "customer-1",
"account": {
"id": "0199d1f4-2e5f-7b61-8c72-9d0e1f2a3b4c",
"alias": "work",
"status": "active",
"authType": "oauth2"
},
"app": {
"service": "gmail",
"providerConfigId": "0199d1a0-5f6e-7d8c-9b0a-1c2d3e4f5a6b",
"slug": "gmail",
"displayName": "Gmail"
}
}
}
createdAt 是毫秒级 Unix 时间戳。externalUserId 是你的后端传给 Connector 的 userId。connection.failed 事件中,account.id 和 account.status 为 null,data.error 包含 code 和 message。没有经过授权请求的连接(例如直接提交凭据)中,connectionRequestId 为 null。
信任 connection.succeeded 事件前,先确认它对应你的后端发起的某次连接,再保存账号绑定关系。授权请求用 externalUserId 和 connectionRequestId 对照你的后端创建的请求。直接提交凭据时 connectionRequestId 为 null,改用 externalUserId 和 account.id 对照提交接口返回的账号;这个调用返回时,你的后端已经拿到了该账号。trigger.message 的内容见 Trigger 指南中的接收事件。
响应与重试
校验并保存事件后返回 2xx。耗时的处理放到后台任务中执行:投递请求 10 秒超时,并且不跟随重定向。
| 端点结果 | Connector 的处理 |
|---|---|
| 2xx | 标记为投递成功。 |
| 408、425、429、5xx、超时或网络错误 | 重试投递。 |
| 其他 4xx,或 Webhook 已停用、已删除 | 停止向该 Webhook 投递此事件。 |
重试依次等待 5 秒、30 秒、2 分钟、10 分钟、30 分钟、1 小时和 2 小时,最多 8 次尝试,合计约 3.7 小时。除第一档外,每档等待时间有最多 20% 的随机浮动。如果端点在整个重试期间都不可用,投递会以 failed 结束,该事件不会再发送,可以在投递记录中找到它。如果你的端点已保存事件但确认结果丢失,同一 id 的事件可能再次到达。
测试、查看与轮换
| 操作 | Console | API |
|---|---|---|
发送一条示例 connection.succeeded 事件 | 发送测试 | POST /v1/saas/projects/{projectId}/webhooks/{webhookId}/test |
| 查看最近的投递尝试 | 投递记录 | GET /v1/saas/projects/{projectId}/webhooks/{webhookId}/deliveries |
| 更换签名 Secret | 轮换 Secret | POST /v1/saas/projects/{projectId}/webhooks/{webhookId}/rotate-secret |
投递记录保留最近 3 天,每次尝试会显示状态(delivered、retrying 或 failed)、尝试次数、HTTP 状态码和错误信息。
轮换会一次性返回新的 signingSecret。旧 Secret 默认在 24 小时内继续参与签名,请在这段时间内把新 Secret 部署到接收端。可以用 previousSecretExpiresInSeconds 设置其他宽限期,设为 0 时立即停用旧 Secret。再次轮换会结束之前的宽限期。
故障排查
| 现象 | 检查项 |
|---|---|
| 签名始终不匹配 | 在解析或重新序列化 JSON 之前,用原始请求体校验;确认接收端使用的是该 Webhook 的 Secret。 |
| 轮换后签名校验失败 | 在旧 Secret 宽限期结束前部署新 Secret。previousSigningSecretExpiresAt 显示宽限期结束时间。 |
| 同一事件收到两次 | 验签后按 envelope 的 id 去重。签名不覆盖 x-oo-event-id,不要只凭这个请求头去重。 |
| 部分账号收不到事件 | 确认 authTypes 包含该账号的授权类型,且 Webhook 处于 active 状态。如果账号有 webhookId,改为检查该路由 Webhook;project Webhook 不会收到它的事件。 |
投递一次后即显示 failed | 端点返回了不可重试的 4xx。检查接收端的鉴权中间件和路由。 |