接收 Webhook
首次接入推荐长连接 Node.js 示例:无需公网接收地址或 Webhook 加密配置。以下步骤适用于主动选择的 HTTPS Webhook。
Webhook 用于接收订阅激活、套餐变更和取消生效等事件通知。GoodVision 将事件发送到应用配置的 HTTPS 接收地址,接收端验证并持久化事件后返回成功,再异步处理业务。
事件采用“至少一次”投递,同一事件可能重复或乱序到达。
HTTP 2xx 是接收 ACK,接收端必须在返回前持久化事件。响应丢失或消费者崩溃会触发重试,接收端按 event_id 业务去重。自动重试耗尽后,在控制台查看失败投递并申请恢复,恢复仍使用原 event_id。
接入步骤
- 配置推送:进入当前应用 → 事件与 Webhook → 添加事件,填写公网 HTTPS 回调地址,测试连接后选择事件并保存。平台自动生成签名密钥和 Token,并启用推送。
- 配置接收端:打开「接收端配置」,主动查看 HMAC Secret 和 Verification Token,将两项保存到服务端。接收端按下文处理连接校验、验签与可靠应答。
- 联调:在 Sandbox 的「事件调试」中选择已订阅事件并发送,进入「投递日志」确认接收端返回
2xx。入队不表示接收成功。
| 配置 | 必填性 | 如何处理 |
|---|---|---|
| 回调地址 | 🔴 必填 | 开发者填写;公网 HTTPS,可响应连接校验 |
| 订阅事件 | 🔴 必填 | 至少选择一个,按业务需要添加 |
| HMAC Secret、Verification Token | 平台生成 | 无需填写或自定义;查看后保存到接收端 |
| Encrypt Key | 可选 | 默认不加密正文;仅启用消息加密时配置接收端解密 |
| 同步回调 | 可选 | 事件推送无需配置;同步请求与响应使用独立 HTTPS 配置 |
多应用分别配置回调地址和事件;可以使用同一接收服务,但每个应用使用各自的凭据。接收端按应用隔离验签、消息与业务处理。事件清单见事件目录。
每个应用仅支持一个未撤销的 Webhook 接收地址。异步事件 event_types[] 和同步回调 callback_types[] 分别配置,修改其中一项时保留另一项的完整配置。
商业订阅、API 调用授权和 Webhook Subscription 分别管理。取消商业订阅不会撤销 API 授权或停止 Webhook。
保存配置失败后的恢复
事件、接收地址或安全配置保存结果未知时,配置窗口保留原内容、版本和操作编号,并暂停编辑和关闭窗口。使用“恢复原请求”确认结果,不重新创建一次保存或轮换。
恢复时若要求登录或身份验证,完成后回到同一应用,再明确恢复原请求。验证不会自动提交,临时显示的凭据会清除。若页面明确提示凭据无法再次交付,读取当前凭据版本,核对后重新确认凭据操作;不要索取旧明文。
若页面明确提示“Webhook 权限需要重新核验”,临时凭据和旧确认会清除。点击“重新核验 Webhook 权限”读取当前应用权限和配置;读取暂时失败时可再次核验。成功后只提供当前允许的操作,不恢复旧明文或旧确认。后续保存或轮换需重新填写并确认;重核本身不会提交配置。
验证接收地址
首次创建 Webhook 或修改 Endpoint 地址时,请先在 Developer Console 点击“测试连接”。平台会向候选地址发送一次校验请求,接收端必须在 1 秒内原样返回 challenge:
POST /webhooks/goodvision
Content-Type: application/json
X-GV-Webhook-Verification: url_verification
{"type":"url_verification","challenge":"opaque_challenge","token":"opaque_verification_token"}HTTP/1.1 200 OK
Content-Type: application/json
{"challenge":"opaque_challenge"}接收地址和响应需满足以下要求:
| 项目 | 要求 |
|---|---|
| 接收地址 | 公网可访问的 HTTPS URL |
| HTTP 状态 | 2xx,不重定向 |
Content-Type | application/json |
| Body | 不超过 64 KiB,仅包含 challenge |
测试通过后,控制台使用有效期 5 分钟的校验证明(proof)保存配置。该证明绑定当前企业、应用和完整 Endpoint URL。证明过期或 URL 变化时,重新测试。
历史 Webhook 缺少校验状态时,在「编辑 Endpoint」中补测。地址已验证且 URL 未变化时,增删事件或回调无需再次测试。
校验请求中的 token 与事件投递使用的 Verification Token 不是同一类凭据。challenge、token 和 proof 都是临时安全材料,不得记录、复用,也不得写入日志、埋点、工单或持久化业务数据。
在控制台调试完整事件
在当前 Sandbox 应用的「事件与 Webhook → 事件调试」中:
- 选择已订阅的生命周期事件。
- 点击「发送测试事件」。
- 查看投递日志,检查接收结果。
平台使用当前 Endpoint、HMAC Secret 和 Verification Token 发送完整的合成订阅摘要。启用加密时使用当前 Encrypt Key。支持事件目录中的全部 19 种订阅生命周期事件。
data.product_code 使用当前应用绑定的产品编码;没有绑定时使用合成值 webhook_test_product。它不表示任何真实产品已经开通。每次测试都有独立事件和合成资源 ID,不模拟同一订阅的连续状态变化;取消预约到生效等连续场景用下文和生命周期指南中的样例验证。
测试要求你拥有当前应用的 Webhook 管理权限,Application 与唯一 Webhook 均已生效,签名凭据已交付,Endpoint 已通过连接校验。Production、未验证、未生效或历史配置冲突时,控制台说明原因并停止发送。
「测试已入队」表示平台已接受请求。打开「投递日志」,点击「重新读取投递」,用返回的 Delivery ID、Event ID 和 Correlation ID 查找记录。
| 响应 | 检查项 |
|---|---|
204 | 已可靠接收 |
401/403 | 签名、时间窗、Nonce 和 Token |
422 | Schema 和解密后的字段 |
5xx 或网络错误 | 接收端是否可用及其所需服务 |
测试仅尝试一次。修复后发送新的测试事件;测试失败不会暂停正常 Webhook。
先完成正常验签,再检查签名正文的 event_id 是否以 evt_webhook_test_ 开头。X-GV-Test-Delivery: true 仅作提示,不能单独用于判断测试请求。
测试中的客户、订阅、租户、套餐和操作均为合成资源。请在单独的测试分支记录结果并应答,不更新真实客户订阅或成员权限,也不使用这些合成 ID 查询真实业务 API。
调试请求只选择 event_type 与当前配置版本;不接受自定义 Endpoint、Secret、Token 或业务正文。接收端看到的 producer 使用为应用签发的实际来源值,与下面静态示例的展示占位不同。
完整示例:订阅已激活
客户在平台客户工作区完成订阅流程后,收到 oem.subscription.activated.v1。下面是未启用加密时的完整请求,data 包含订阅摘要的全部必需字段,而不是仅包含发生变化的字段:
也可下载完整 HTTP 样例。Content-Length 按 Body 的 UTF-8 字节数计算;修改正文时需要重新计算长度与签名。
POST /webhooks/goodvision HTTP/1.1
Host: receiver.example.com
Content-Type: application/json
Content-Length: 1185
X-GV-Delivery-ID: delivery_demo_activation
X-GV-Event-ID: evt_demo_activated
X-GV-Event-Type: oem.subscription.activated.v1
X-GV-Request-Timestamp: 1791244800
X-GV-Request-Nonce: nonce_demo_activation_001
X-GV-Signature: sha256=675ba54e260ff141e61a1090a6b4277710fc9b80e4205b55f4d0dc86fee56d53
{
"event_id": "evt_demo_activated",
"event_type": "oem.subscription.activated.v1",
"schema_version": "1",
"occurred_at": "2026-10-06T00:00:00Z",
"producer": "opaque",
"tenant_id": "org_customer_demo",
"resource_id": "sub_demo_cancel",
"resource_version": 2,
"correlation_id": "req_demo_activation",
"data": {
"id": "sub_demo_cancel",
"organization_id": "org_customer_demo",
"open_platform_application_id": "app_demo_saas",
"environment": "sandbox",
"product_tenant_id": "tenant_demo_cancel",
"party_id": "party_customer_demo",
"order_id": "order_demo_initial",
"product_code": "relaydesk",
"plan_code": "basic_monthly_demo",
"plan_version": 1,
"status": "active",
"current_period_start": "2026-10-06T00:00:00Z",
"current_period_end": "2026-11-06T00:00:00Z",
"status_effective_at": "2026-10-06T00:00:00Z",
"payment_reference": "payment_demo_initial",
"operation_id": "op_demo_activation",
"reason_code": "example_activation",
"resource_version": 2,
"created_at": "2026-10-05T23:55:00Z",
"updated_at": "2026-10-06T00:00:00Z"
},
"verification_token": "demo-verification-token-only"
}本例的 ID、套餐和原因码均为合成值。producer: "opaque" 是来源占位值,实际枚举请使用当前应用的事件 Schema。reason_code 也按当前 Schema 解释,示例值不可用于实际业务判断。
签名使用公开演示值 demo-signing-secret-only,Verification Token 使用 demo-verification-token-only;二者不是有效凭据。签名覆盖示例 Body 的 UTF-8 字节,包括最后一个 LF 换行。固定时间戳只用于复算,今天发送会因超出时间窗被拒绝。
服务端校验通过、Inbox 事务提交后,返回空 Body:
HTTP/1.1 204 No Content这表示“已经可靠接收”,不表示已经完成套餐或权限更新。响应正文不用于返回业务处理结果。重复业务事件在验证请求合法后也返回 204。
该事件表示商业订阅已进入 active。开放成员功能前,还需通过 oem.access_context.changed.v1 和已授权查询确认成员权限,详见生命周期中的权限处理。
请求头与签名
| Header | 用途 |
|---|---|
X-GV-Delivery-ID | 投递记录标识,用于查询投递、重试和接收日志 |
X-GV-Event-ID | 稳定业务事件 ID,用于业务去重 |
X-GV-Event-Type | 带版本的事件名称 |
X-GV-Request-Timestamp | 当前投递尝试的 Unix 秒时间戳 |
X-GV-Request-Nonce | 当前投递尝试的随机值,用于拒绝重放 |
X-GV-Test-Delivery | 仅调试投递发送 true;验签后仍须核对正文测试前缀 |
X-GV-Signature | sha256=<hex> 格式的 HMAC-SHA256 |
签名输入严格为:
signing_input = timestamp + "\n" + nonce + "\n" + raw_body
expected = "sha256=" + hex(HMAC-SHA256(signing_secret, signing_input))raw_body 是 HTTP 收到的原始字节。计算签名前不要解析、格式化或重新序列化 JSON。
- 检查时间戳是否在前后 5 分钟窗口内。
- 在共享存储中原子登记 Nonce,至少保留 10 分钟,拒绝重复值。
- 使用常量时间比较验证签名。
多副本接收端需共享 Nonce 存储,单进程内存无法防止跨副本重放。
接收端校验顺序
- 读取原始 Body,检查投递 ID、事件 ID、事件类型、时间戳、Nonce 和签名 Header 都存在且格式合法。
- 校验时间窗并原子登记 Nonce;过期或重复请求返回
401,Nonce 存储不可用返回503。 - 用当前签名 Secret 对签名输入计算 HMAC-SHA256,并做常量时间比较;失败返回
401。 - 按当前 Endpoint 配置解析明文,或解密
encrypt;两种模式都必须常量时间校验verification_token,失败返回401。 - 移除传输字段
verification_token后,按已签发事件 Schema 验证信封。确认 Header 事件 ID、事件类型与 Body 一致;订阅事件还需核对客户 Organization、Application、环境、产品、资源 ID 和版本,失败返回422。 - 以
event_id幂等写入持久化 Inbox,保存去掉 Token 的事件和delivery_id、correlation_id;写入失败返回503。后台以resource_version防止旧事件覆盖新状态。 - Inbox 事务提交后立即返回
2xx,耗时业务逻辑由后台 Worker 执行。
raw_body = request.raw_body
delivery_id = request.headers["X-GV-Delivery-ID"]
event_id = request.headers["X-GV-Event-ID"]
event_type = request.headers["X-GV-Event-Type"]
timestamp = request.headers["X-GV-Request-Timestamp"]
nonce = request.headers["X-GV-Request-Nonce"]
reject 401 unless timestamp_within_window(timestamp, 5_minutes)
reject 503 if nonce_store_unavailable
reject 401 unless reserve_nonce_once(nonce, 10_minutes)
expected = "sha256=" + hex(hmac_sha256(signing_secret, timestamp + "\n" + nonce + "\n" + raw_body))
reject 401 unless constant_time_equal(expected, request.headers["X-GV-Signature"])
transport = parse_json(raw_body)
if endpoint.encryption_enabled
reject 422 unless transport has only the encrypt field
transport = parse_json(decrypt_aes256cbc(transport.encrypt, encrypt_key))
end
reject 401 unless constant_time_equal(verification_token, transport.verification_token)
event = transport without verification_token
reject 422 unless validate_issued_event_schema(event)
reject 422 unless event.event_id == event_id
reject 422 unless event.event_type == event_type
reject 422 unless event.resource_id == event.data.id
reject 422 unless event.resource_version == event.data.resource_version
if event.event_id starts_with "evt_webhook_test_"
reject 422 unless matches_configured_sandbox_application_product(event)
reject 503 unless commit_test_inbox_if_new(event.event_id, event, delivery_id, event.correlation_id)
return 204 // only record test results; never apply customer state or permissions
end
reject 422 unless matches_configured_customer_application_environment_product(event)
reject 503 unless commit_inbox_if_new(event.event_id, event, delivery_id, event.correlation_id)
return 204这是订阅事件接收伪代码。commit_inbox_if_new 必须在事务中按唯一键区分“新事件提交成功”“相同事件已接收”和“存储失败”;前两种返回成功。校验连接请求走前面的独立 challenge 分支,不作为订阅事件入库。
明文与加密 Body
| 凭据 | 用途 |
|---|---|
| HMAC Secret | 对外层 HTTP 原始 Body 验签 |
| Verification Token | 检查解析后完整事件中的 verification_token |
| Encrypt Key | 仅用于启用加密时的事件解密 |
三类凭据分别用于签名、Token 校验和解密。
明文 Body 包含完整事件信封及顶层 verification_token。先验证并移除 Token,再检查事件 Schema,否则封闭 Schema 会将其作为未知字段拒绝。Token 不保存到 Inbox、普通日志或工单。
启用加密时,完整 JSON 仍包含 Verification Token,外层只有下面一个字段。这是格式示意,尖括号内需要换成真实 Base64:
{
"encrypt": "<Base64 of IV followed by ciphertext>"
}按以下顺序解密:
- 对外层原始 Body 验签。
- Base64 解码,取前 16 字节为 IV,其余为密文。
- 使用
SHA256(UTF-8 Encrypt Key)作为 AES-256-CBC Key 解密,并移除 PKCS#7 Padding。 - 解析 JSON,常量时间校验 Token。
- 移除 Token,再验证事件 Schema。
严格使用 Endpoint 配置的传输模式。解密失败时拒绝处理,不能回退到明文。
本地复算签名
下载激活事件 JSON与离线脚本,放到同一目录,在 Node.js 18 或更高版本执行:
node "webhook-signature.mjs"脚本使用公开演示凭据、当前时间和随机 Nonce,输出包含全部请求头和 Body 的合成请求,不会发起网络请求。可用它核对原始字节的签名计算。
通过实际事件 Schema 校验前,替换 producer 和接入配置为当前应用的值。正式接收端还需实现时间窗、Nonce、Token、Schema、范围检查和持久化。
import { createHmac, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
// Offline example only: these public demo credentials are never valid credentials.
const signingSecret = "demo-signing-secret-only";
const verificationToken = "demo-verification-token-only";
const event = JSON.parse(readFileSync(new URL("./subscription-activated.json", import.meta.url), "utf8"));
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(16).toString("hex");
const rawBody = Buffer.from(JSON.stringify({ ...event, verification_token: verificationToken }), "utf8");
const signature = createHmac("sha256", signingSecret)
.update(`${timestamp}\n${nonce}\n`, "utf8")
.update(rawBody)
.digest("hex");
// Print a synthetic request; do not send it or log a real event body.
process.stdout.write([
"POST /webhooks/goodvision HTTP/1.1",
"Host: receiver.example.com",
"Content-Type: application/json",
`Content-Length: ${rawBody.length}`,
"X-GV-Delivery-ID: delivery_demo_activation",
`X-GV-Event-ID: ${event.event_id}`,
`X-GV-Event-Type: ${event.event_type}`,
`X-GV-Request-Timestamp: ${timestamp}`,
`X-GV-Request-Nonce: ${nonce}`,
`X-GV-Signature: sha256=${signature}`,
"",
rawBody.toString("utf8"),
].join("\r\n"));去重与乱序
- 唯一约束使用
event_id,不要使用delivery_id或correlation_id。 - 同一投递记录重试时
delivery_id、event_id、correlation_id保持不变;每次尝试重新生成时间戳、Nonce 和加密 IV,并重新签名。 - 重复事件返回成功,但不能重复创建订单、取消订阅或提交支付/退款操作。
- 当前资源版本为
N时,只直接应用N+1;N+2先保存为待处理缺口。 - 缺失事件长期未到达时,调用契约授权的状态查询接口完成对账。
| 收到的情况 | HTTP 响应示例 | 后台行为 |
|---|---|---|
| 新事件,校验通过并成功提交 Inbox | 204 | 异步处理一次 |
新 Nonce 的合法重试,event_id 已接收 | 204 | 不重复业务操作 |
| 同一 Nonce 被重放、时间窗过期、签名或 Token 错误 | 401 | 不入库、不执行业务 |
| 不支持的 Schema、Header/Body 不一致或接入范围不匹配 | 422 | 拒绝并检查事件格式与应用配置 |
| Inbox 暂时不可写或 Nonce 存储不可用 | 503 | 不确认接收,等待平台重试 |
| 当前版本为 2,先收到版本 4 | 提交 Inbox 后 204 | 保存缺口,等版本 3 或授权回查,不能直接覆盖 |
delivery_id 用于查看投递记录,event_id 用于业务去重,correlation_id 用于关联排障。完整去掉 Token 的事件只保存在受控 Inbox,不进入浏览器日志、普通应用日志或工单。
签名密钥与轮换
- Webhook 签名密钥只用于当前 Subscription 的 HMAC 验签,不能与 OAuth Client Secret、API Key 或其他 Application 共用。
- 新版本签名密钥安全交付成功后才会生效;交付失败时旧版本继续有效。控制台只显示版本和交付状态,不显示密钥正文。
- 接收方在轮换窗口内可以同时校验当前版本和上一版本,完成切换后及时移除旧版本。
- 明文密钥不得进入 URL、日志、埋点、缓存、工单或普通数据库字段。
失败与恢复
| 投递结果 | 处理方式 |
|---|---|
网络错误、连接超时、408、425、429 或约定的 5xx | 平台自动重试,业务事件默认最多尝试 8 次 |
400、401、403、404、410、422 | 先检查接收地址、证书、密钥和 Schema |
| 连续失败 | 保留失败记录,并可能暂停订阅;在开发者控制台排查和恢复 |
| 控制台合成测试失败 | 仅尝试一次,修复后发送新测试 |
修复接收地址或验签配置后,先查询当前状态核对,再通过支持渠道申请按原 event_id 重新投递。重投使用原记录和事件内容,具有幂等保护并保留审计记录,不创建新业务事件。此操作由平台处理,第三方无需调用管理接口。
同步回调可用性
独立请求、响应示例和结果未知处理见处理同步回调。
开发者控制台可为同一 Endpoint 配置 token.agent.action.v1,在 Sandbox 的“回调配置”发送单次合成测试并查看独立结果。真实 TOKEN 业务请求方尚未接入,保存或测试成功不表示生产业务已调用。
接收端须按已签发的请求/响应 Schema 验签、按当前配置解密并在 5 秒内返回结构化结果。同步回调采用独立处理规则,不使用异步事件的重试与失败投递恢复;结果未知时不自动重试,核对结果后再显式发起新测试。
从控制台安全取得签名密钥
使用控制台交付的 Webhook,可在“接收端配置”主动点击“临时查看 HMAC Secret”。应用所有者或获准查看的协作者经当前登录取得当前版本,随后将密钥保存到接收服务端。离开页面、切换应用/企业、查看权限或签名密钥版本变化会清除临时值。外部交付通道仍按原方式取得密钥。密钥进入交付通道不表示接收端已配置,也不表示事件已接收;配置后请通过事件调试和投递日志验证。
成员事件与登录用户关联
成员相关的 oem.access_context.changed.v1 使用 data.subject_id 对应用户上下文的 space.membership_id,不能与 sub 或用户别名互换。订阅记录按应用、环境、客户空间及 subscription ID 关联,不绑定最后登录用户。