---
author: OOMOL
author_url: https://oomol.com/zh-cn/about/
title: 接收项目 Webhook
description: 创建项目 Webhook，校验签名投递，处理 Connector for SaaS 的连接事件和 Trigger 事件。
lang: zh-CN
canonical_url: https://oomol.com/zh-cn/docs/saas-webhooks/
markdown_url: https://oomol.com/zh-cn/docs/saas-webhooks.md
---

# 接收项目 Webhook

项目 Webhook 会把 Connector for SaaS 的事件发送到你后端的 HTTPS 端点。你可以用它得知终端用户何时完成或未能完成账号连接，也可以用它接收 [Trigger 订阅](/zh-cn/docs/saas-triggers/)产生的事件。

一个项目最多可以创建 3 个 Webhook。每个 Webhook 分别选择事件类型和接收的账号授权类型，每次投递都使用该 Webhook 的签名 Secret 签名。默认情况下，Webhook 会收到项目内所有连接账号的事件；如果要把某个账号的事件发到单独的接收端，可以[把该账号路由到专用 Webhook](#把单个账号的事件路由到专用-webhook)。

## 事件类型

| 事件类型 | 发送时机 |
| --- | --- |
| `connection.succeeded` | 终端用户通过授权链接或提交凭据完成账号连接。 |
| `connection.failed` | 账号连接失败，例如用户取消授权，或 provider 拒绝凭据。 |
| `trigger.message` | Trigger 订阅产生了事件。见[用 Trigger 接收第三方事件](/zh-cn/docs/saas-triggers/)。 |

`authTypes` 限定 Webhook 只接收对应授权类型账号的事件，可选 `oauth2`、`api_key`、`custom_credential`。连接事件和 Trigger 事件都按它过滤。例如 `authTypes: ["oauth2"]` 的 Webhook 不会收到 API Key 账号的任何事件。

## 创建 Webhook

在 [OOMOL Console](https://console.oomol.com/) 打开**项目**，选择项目后进入 **Webhooks**，点击**创建 Webhook**。填写 HTTPS 地址，选择**事件类型**和**授权类型**后创建。

后端也可以使用 Project API key 创建：

```http
POST https://connector.oomol.com/v1/saas/projects/{projectId}/webhooks
Authorization: Bearer <project-api-key>
Content-Type: application/json
```

```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

当某个连接账号的事件需要发到单独的接收端时，可以路由这个账号。例如把测试账号的事件发到预发环境，或发到某个客户自己运行的接收端。

1. 创建 `"scope": "routed"` 的 Webhook。在 Console 中，把**接收范围**设为**仅路由的连接**。
2. 终端用户连接账号时，把该 Webhook 的 ID 作为 `webhookId` 传入。授权链接（`POST /v1/saas/connected-accounts/link`）和凭据提交（例如 `POST /v1/saas/connected-accounts/api-key`）都支持这个字段：

   ```json
   {
     "providerConfigId": "<provider-config-id>",
     "userId": "customer-1",
     "webhookId": "<routed-webhook-id>",
     "returnUri": "https://app.example.com/connections/done"
   }
   ```

3. 要路由已经连接的账号，或取消路由，调用 `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` 必须是同一项目中的 `routed` Webhook，否则返回 404 `webhook_not_found`。
- 带 `webhookId` 发起的连接，其 `connection.succeeded` 或 `connection.failed` 事件会发往该 Webhook。
- 重新连接时不传 `webhookId`，会保留当前路由。
- 已路由账号上的 [Trigger 订阅](/zh-cn/docs/saas-triggers/)会把 `trigger.message` 事件发往它的路由 Webhook。
- 删除路由 Webhook 后，路由到它的账号会解除路由，事件重新发往 `project` Webhook。
- 路由在事件记录时确定。修改路由后，已排队或正在重试的投递仍发往原来的 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 之前，用原始请求体校验：

```ts
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`。

```json
{
  "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 指南中的[接收事件](/zh-cn/docs/saas-triggers/#接收事件)。

## 响应与重试

校验并保存事件后返回 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。检查接收端的鉴权中间件和路由。 |
