集成指南
托管核验链接、名单筛查、Quick Scan 与工作流 Webhook 的完整集成参考。
... 表示结构示意,不能直接作为 JSON 提交。Quick Scan 调用使用 apiVersion=1.0.0。核验链接
生成一条一次性网页链接,终端用户点开就进入托管的 KYC 或 KYB 流程。链接怎么送到用户手里由你决定——邮件、聊天工具、任何现有渠道都行,用户在浏览器里把资料填完。如果你自己没有网站或 App 可以嵌 SDK,就走这条路。
接口:
sdk.launch.link.generate
POST
使用 apiVersion 1.0.0。签名方式、网关地址和
x-domain 请求头与其他 OpenAPI 接口完全一致(见身份验证)。
请求
{
"email": "enduser@example.com",
"customerId": "cust_internal_id",
"eventType": "KYC_EVENT"
}
| 字段 | 类型 | 必填 | 描述 |
|---|---|---|---|
email |
字符串 | 是 | 终端用户邮箱 |
customerId |
字符串 | 是 | 你的内部用户 ID(对同一客户保持稳定) |
eventType |
字符串 | 是 |
链接打开哪一类流程:KYC_EVENT(个人)或
KYB_EVENT(企业)
|
响应
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"msg": "success",
"code": 200,
"data": {
"url": "https://id-link-platform.width.com/?linkToken=71a2743a-ca3c-436f-bc24-af3a73405a58",
"expiresAt": 1786679539986
}
}
}
| 字段 | 描述 |
|---|---|
data.data.url |
要交给终端用户的链接。查询参数
linkToken
标识这一次邀请——整条 URL 视同密钥,只发给你在
email 里填的那个地址。
|
data.data.expiresAt |
失效时刻,毫秒时间戳。链接自生成起 7 天 内有效;过期后再调一次本接口换一条新的。 |
email
再调一次本接口,前一条立刻作废——终端用户手里那条当场就打不开了,而且没有任何办法恢复。所以重新签发之前先确认对方没在填;如果他正填到一半,把新链接补发给他。用不同邮箱签出来的链接互不影响。
错误处理
eventType 填了不支持的值时,错误信息在
data 里,网关层的 apiCode 仍然是
200 — 判断成败要读 data.code,不是 HTTP
状态码:
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"msg": "Invalid eventType: TRANSACTION_EVENT",
"code": 400,
"data": {}
}
}
名单筛查
把一个待筛查主体(个人或企业)送进平台做名单筛查。平台会按客户号建档,在你的工作流里跑完筛查,命中就产生告警并开一个案件交给分析员裁定。使用
eventType: "MANUAL_CASE_EVENT"。
customerId
下次再来时直接复用。如果只想临时查一下、不建客户也不开案件,请改用Quick Scan 一次性筛查。
接口:
workflow.apply.submit
POST
POST 到网关,query string 照常带
api=workflow.apply.submit、apiVersion=2.0.0、apiKey 和
sign,再加上装着你的 domain ID 的
x-domain 请求头。请求体是 UTF-8 编码的 JSON,Content-Type: application/json;sign 是对 实际发出去的那份字节做
HMAC-SHA256
——先把请求体序列化成字节,对这份字节签名,再把同一份字节发出去,别让框架在签完之后又重新序列化一遍。完整规则和各语言代码见身份验证。
请求
{
"eventType": "MANUAL_CASE_EVENT",
"source": "API",
"workflowId": "{workflowId}",
"data": {
// subject fields, see below
}
}
| 字段 | 类型 | 必填 | 描述 |
|---|---|---|---|
eventType |
字符串 | 是 |
固定填 MANUAL_CASE_EVENT。该事件在平台界面上显示为
人工。
|
source |
字符串 | 是 |
服务端对接固定填 API。
|
workflowId |
字符串 | 本场景填 | 这次筛查跑哪条工作流。工作流 ID 在平台的工作流列表里取。不传也能跑——平台会自动路由到最新发布的那条——但名单筛查这个场景请把工作流写明,免得别人后来发布了新版本,你的提交在你不知情的情况下换了流程。 |
data |
对象 | 是 |
被筛查的主体。字段直接放在
data 的第一层,不要再往下嵌。
|
主体字段
| 字段 | 必填 | 描述 |
|---|---|---|
customerId |
是 | 客户号。平台按这个键建档或关联到已有档案,人工裁定的结论也记在这个键上——同一客户的多个案件共享已经裁定过的结果。请用对该客户长期不变的号;跨案件记忆全靠它。 |
name |
是 | 主体名,整条作为一个字符串传——不要拆成姓和名两个字段。个人传英文全名,企业传公司注册名。 |
aliasName |
建议 |
主体的其它名字,比如原文名(中文名等)。每个名字都会单独跑一遍筛查,原文名对命中准确性帮助显著。多个名字用英文逗号分隔——也就是说别名本身不能带逗号:写成
Chen, Dawei 会被当成两个名字,请写
Chen Dawei。这条只管 aliasName;name
不会被拆。
|
customerType |
是 |
个人填 individual,企业填
corporate。
|
sex |
建议(个人) |
性别。F / M 或英文全称都可以。
|
dateOfBirth |
建议(个人) |
出生日期。完整日期(1966-03-15)或只给年份(1966)都可以。
|
countryOfNationality |
建议(个人) | 国籍。ISO 3166 两位或三位码均可,平台自动归一。 |
countryOfResidence |
可选 | 居住地国家,企业则为主营业地国家。 |
occupation |
可选(个人) | 职业 / 职务。展示在案件页上。 |
idType, idNumber |
可选(个人) | 主体的证件类型与号码,须成对提供,且只传真实身份证件(护照、身份证等),不要传内部编号。生效时机:仅在该客户首次提交、平台还没有他的档案时随筛查一起送去做证件比对;档案建起来之后以档案上维护的证件为准,事件里再传不生效。 |
countryOfIncorporation |
建议(企业) |
注册国。未传时以 countryOfResidence 兜底。
|
incorporationDate |
可选(企业) | 成立日期,完整日期或只给年份均可。会写入客户档案并在案件页展示;当前版本不送筛查引擎参与比对。 |
incorporationNumber |
可选(企业) | 公司注册号。参与名单记录的注册号比对——对企业客户这是权重最高的证据,所以同样宁缺毋滥,只传真实注册号。 |
个人的 sex、dateOfBirth、countryOfNationality
和企业的 countryOfIncorporation、incorporationNumber
会作为佐证参与比对与降分排序:传得越全,同名噪音越少。字段缺失时按中性处理,不会误伤主体。
响应
本接口是异步的:提交被接受即返回,筛查在平台侧继续跑。
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"executionId": "exec-xxxxxxxxxxxxxxxx",
"requestId": "req-xxxxxxxxxxxxx",
"status": "RUNNING"
}
}
| 字段 | 描述 |
|---|---|
data.executionId |
本次执行的编号。请留存:向平台方问进度或排查问题时用它,用workflow.execution.status轮询结果时也传它。 |
data.status |
提交成功时为
RUNNING。筛查结果与风险评分最终体现在平台的案件页上。
|
提交失败时返回网关标准错误结构。常见原因是签名与实际发出的字节不一致、缺
x-domain 请求头、请求体不是合法 JSON、必填字段缺失。
完整示例
个人主体:
{
"eventType": "MANUAL_CASE_EVENT",
"source": "API",
"workflowId": "{workflowId}",
"data": {
"customerId": "CIF-0001234",
"name": "CHEN, DAWEI",
"aliasName": "陳大偉",
"customerType": "individual",
"sex": "M",
"dateOfBirth": "1968",
"countryOfNationality": "TW",
"countryOfResidence": "TW",
"occupation": "Director"
}
}
企业主体:
{
"eventType": "MANUAL_CASE_EVENT",
"source": "API",
"workflowId": "{workflowId}",
"data": {
"customerId": "CIF-0005678",
"name": "HUAYUAN INTERNATIONAL TRADING CO., LTD",
"aliasName": "華遠國際貿易有限公司",
"customerType": "corporate",
"countryOfIncorporation": "HK",
"incorporationDate": "2003",
"incorporationNumber": "0691355",
"countryOfResidence": "HK"
}
}
Quick Scan 一次性筛查
Quick Scan 提供一次性合规筛查能力,无需创建客户或配置工作流。提交一个姓名,系统会同时调用多个筛查引擎(World-Check、Dow Jones、ArtemiScan、互联网搜索、内部监控名单),返回命中结果,适合做临时的制裁 / PEP(Politically Exposed Person,政治公众人物)姓名核查。
api / apiVersion /
apiKey / sign)加 x-domain
请求头,以及同样的请求体 HMAC-SHA256 签名。基础地址与请求格式见
OpenAPI 参考 → 快速入门,完整签名规则见身份验证章节。
调用流程
-
quickscan.submit— 提交筛查,立即返回id(异步执行) -
quickscan.detail— 轮询状态,DONE时返回命中摘要 -
quickscan.hits— 按引擎分页获取完整命中详情(可选)
接口列表
| API 名称 | 说明 | 超时 |
|---|---|---|
quickscan.submit
POST
|
提交筛查 | 30 秒 |
quickscan.detail
POST
|
查询状态和结果 | 10 秒 |
quickscan.hits
POST
|
分页获取命中详情 | 10 秒 |
quickscan.submit — 提交筛查
请求
{
"data": {
"name": "Vladimir Putin",
"customerType": "Individual",
"providers": ["WORLD_CHECK", "DOW_JONES", "ARTEMISCAN", "INTERNET_SEARCH"],
"description": "AML compliance check",
"providerSettings": {
"artemiscan": { "sensitivity": 0.6 },
"worldCheck": { "matchStrength": "STRONG", "maxHits": 20 },
"dowJones": { "searchType": "Precise", "maxHits": 20 },
"internetSearch": { "resultCount": 20 }
}
}
}
data 字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
name |
字符串 | 是 | 筛查对象姓名 |
customerType |
字符串 / 整数 | 是 |
"Individual" 或 "Business"(也兼容整数
1 / 2)
|
providers |
字符串数组 | 是 | 筛查引擎列表(至少一个) |
description |
字符串 | 否 | 备注说明 |
providerSettings |
对象 | 否 | 各引擎参数(省略则用默认值) |
可选引擎(providers)
| 引擎 | 说明 | 消耗 Credit |
|---|---|---|
WORLD_CHECK |
Refinitiv World-Check 制裁 / PEP 名单 | 是 |
DOW_JONES |
Dow Jones Risk & Compliance 名单 | 是 |
ARTEMISCAN |
ArtemiScan 综合风险筛查 | 是 |
INTERNET_SEARCH |
互联网公开信息搜索 | 是 |
WATCHLIST |
内部监控名单(租户自定义) | 否(免费) |
引擎参数(providerSettings)
| 引擎 | 参数 | 默认值 | 可选值 |
|---|---|---|---|
artemiscan |
sensitivity |
0.6 |
0~1(越大匹配越宽松) |
worldCheck |
matchStrength |
STRONG |
EXACT / STRONG / WEAK |
worldCheck |
maxHits |
20 |
5 / 10 / 20 / 50 |
dowJones |
searchType |
Precise |
Precise / Near / Broad |
dowJones |
maxHits |
20 |
5 / 10 / 20 / 50 |
internetSearch |
resultCount |
20 |
10 / 20 / 50 |
响应
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"status": "RUNNING"
}
}
| 字段 | 说明 |
|---|---|
data.id |
筛查记录 ID,用于后续查询 |
data.status |
RUNNING(筛查进行中)
|
quickscan.detail — 查询结果
请求
{
"data": {
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d"
}
}
响应 — 进行中
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"status": "RUNNING"
}
}
响应 — 完成,命中数 ≤ 50(内联返回)
当 totalHits ≤ 50 时,hits
字段内联返回全部命中详情:
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"status": "DONE",
"totalHits": 31,
"truncated": false,
"hitCounts": {
"WORLD_CHECK": 7,
"DOW_JONES": 3,
"ARTEMISCAN": 1,
"INTERNET_SEARCH": 20
},
"hits": {
"WORLD_CHECK": [ { ... }, { ... } ],
"DOW_JONES": [ { ... } ],
"ARTEMISCAN": [ { ... } ],
"INTERNET_SEARCH": [ { ... }, { ... } ]
}
}
}
响应 — 完成,命中数大于 50
当总命中数大于 50 时,hits 为 null、truncated
为 true,需使用 quickscan.hits 分页获取详情:
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-xxx",
"status": "DONE",
"totalHits": 128,
"truncated": true,
"hitCounts": {
"WORLD_CHECK": 45,
"DOW_JONES": 33,
"ARTEMISCAN": 30,
"INTERNET_SEARCH": 20
},
"hits": null
}
}
字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
status |
字符串 |
RUNNING / DONE / FAILED
|
totalHits |
整数 | 全部引擎命中总数 |
truncated |
布尔值 |
true = 命中过多,需用
quickscan.hits 分页获取
|
hitCounts |
对象 |
各引擎命中数量 { "WORLD_CHECK": 7, ... }
|
hits |
对象 / null |
命中详情(仅 totalHits ≤ 50 时返回)
|
quickscan.hits — 分页获取命中详情
当 quickscan.detail 返回
truncated: true 时,使用此接口按引擎分页获取命中详情。truncated: false
时也可用此接口按需获取单个引擎的结果。
请求
{
"data": {
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"provider": "WORLD_CHECK",
"page": 1,
"pageSize": 20
}
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id |
字符串 | 是 | 筛查记录 ID |
provider |
字符串 | 是 |
引擎名称(WORLD_CHECK / DOW_JONES /
ARTEMISCAN / INTERNET_SEARCH /
WATCHLIST)
|
page |
整数 | 否 |
页码(默认 1)
|
pageSize |
整数 | 否 |
每页条数(默认 20,最大 100)
|
响应
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"totalHits": 7,
"truncated": true,
"hits": {
"WORLD_CHECK": [
{
"firstName": "Vladimir",
"lastName": "PUTIN",
"category": "PEP",
"subCategory": "PEP - Current",
"addresses": [ { "country": { "code": "RUS", "name": "Russian Federation" } } ],
"dobs": [ { "dateOfBirth": "1952-10-07" } ]
}
]
}
}
}
| 字段 | 说明 |
|---|---|
totalHits |
该引擎的总命中数 |
truncated |
是否还有更多页(true = 还有下一页)
|
hits |
当前页命中详情 { "PROVIDER": [...] }
|
代码示例
端到端示例:提交、轮询到 DONE,若
truncated 再分页获取。注意签名规则 —
先把请求 body
序列化成字符串,再对这个字符串计算签名,然后把同一个字符串作为 HTTP
body 发出去。
Python:
import hmac, hashlib, json, os, time, urllib.request, urllib.error
GATEWAY = "https://api-gateway-platform.width.com/gateway"
API_KEY = os.environ["WIDTH_API_KEY"]
API_SECRET = os.environ["WIDTH_API_SECRET"]
DOMAIN_ID = os.environ["WIDTH_DOMAIN_ID"]
def sign(body: str) -> str:
return hmac.new(API_SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
def post(api: str, body: str) -> dict:
url = f"{GATEWAY}?api={api}&apiVersion=1.0.0&apiKey={API_KEY}&sign={sign(body)}"
req = urllib.request.Request(url, data=body.encode(), method='POST',
headers={'Content-Type': 'application/json', 'x-domain': DOMAIN_ID})
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read().decode())
if result.get("apiCode") != 200 or result.get("data", {}).get("code") != 200:
raise RuntimeError(f"WIDTH request failed: {result}")
return result
# 1. Submit a screening
submit_body = json.dumps({"data": {
"name": "Vladimir Putin",
"customerType": "Individual",
"providers": ["WORLD_CHECK", "DOW_JONES", "ARTEMISCAN"],
"providerSettings": {
"worldCheck": {"matchStrength": "STRONG", "maxHits": 20},
"dowJones": {"searchType": "Precise", "maxHits": 20},
"artemiscan": {"sensitivity": 0.6}
}
}}, separators=(',', ':'))
result = post("quickscan.submit", submit_body)
scan_id = result["data"]["id"]
print(f"Submitted: {scan_id}")
# 2. Poll for the result
while True:
detail_body = json.dumps({"data": {"id": scan_id}}, separators=(',', ':'))
detail = post("quickscan.detail", detail_body)
status = detail["data"]["status"]
print(f"Status: {status}")
if status in ("DONE", "FAILED"):
break
time.sleep(3)
if status == "FAILED":
raise RuntimeError(f"Screening failed: {scan_id}")
# 3. If truncated, paginate
if detail["data"].get("truncated"):
for provider, count in detail["data"]["hitCounts"].items():
page = 1
while True:
hits_body = json.dumps({"data": {
"id": scan_id, "provider": provider, "page": page, "pageSize": 20
}}, separators=(',', ':'))
hits = post("quickscan.hits", hits_body)
items = hits["data"]["hits"][provider]
print(f"{provider} page {page}: {len(items)} hits")
if not hits["data"]["truncated"]:
break
page += 1
else:
# Result already inlined
for provider, items in detail["data"]["hits"].items():
print(f"{provider}: {len(items)} hits")
cURL:
提交筛查
curl -X POST '{baseUrl}?api=quickscan.submit&apiVersion=1.0.0&apiKey={ak_xxx}&sign={sign}' \
-H 'Content-Type: application/json' \
-H 'x-domain: {domainId}' \
-d '{"data":{"name":"Vladimir Putin","customerType":1,"providers":["WORLD_CHECK","DOW_JONES"]}}'
查询结果
curl -X POST '{baseUrl}?api=quickscan.detail&apiVersion=1.0.0&apiKey={ak_xxx}&sign={sign}' \
-H 'Content-Type: application/json' \
-H 'x-domain: {domainId}' \
-d '{"data":{"id":"qs-xxx"}}'
分页获取命中
curl -X POST '{baseUrl}?api=quickscan.hits&apiVersion=1.0.0&apiKey={ak_xxx}&sign={sign}' \
-H 'Content-Type: application/json' \
-H 'x-domain: {domainId}' \
-d '{"data":{"id":"qs-xxx","provider":"WORLD_CHECK","page":1,"pageSize":20}}'
错误码
网关层错误码(apiCode)
| apiCode | apiMsg | 原因 |
|---|---|---|
200 |
OK |
请求成功 |
999001 |
SIGN_ERROR |
签名校验失败 |
999301 |
API_INVALID_REQUEST |
请求格式错误 |
999302 |
API_NOT_FOUND |
API 名称错误 |
999304 |
RATE_LIMIT_TRY_AGAIN |
触发限流 |
999305 |
TENANT_NOT_FOUND |
apiKey 对应租户不存在
|
业务层错误码(data.code)
| code | 含义 |
|---|---|
200 |
成功 |
400 |
参数错误(缺少必填字段、provider 无效等) |
403 |
Credit 额度不足 |
404 |
筛查记录不存在 |
500 |
服务内部错误 |
常见问题
Q:提交后多久能拿到结果?
A:通常 3~10 秒。World-Check 和 Dow Jones 一般 2~5
秒返回,互联网搜索可能稍慢。建议每 3 秒轮询一次
quickscan.detail。
Q:truncated: true 是什么意思?
A:当总命中数超过 50 条时,quickscan.detail
不会在响应中内联全部命中详情(避免响应体过大)。此时
hits 为 null,需通过
quickscan.hits 按引擎分页获取。
Q:每次筛查消耗多少 Credit?
A:每个引擎消耗 1 个 Credit(WATCHLIST
免费)。例如选择 3 个引擎 = 消耗 3 个 Credit。
Q:customerType 有什么影响?
A:"Individual" 和 "Business"
决定了筛查引擎的匹配策略。Business
类型会额外匹配公司名称、注册号等字段。也兼容整数
1(Individual)/ 2(Business)。
Q:签名总是不通过?
A:确保签名时用的 body 字节与实际发送的完全一致。一次性构造 JSON 字符串 → 签名 → 发送同一个字符串。不要 pretty-print,不要再次序列化。
Q:可以同时提交多个筛查吗?
A:可以。每次
quickscan.submit 调用都是独立的,各自返回不同的
id。可并行提交,独立轮询结果。
Webhook 集成
Webhook 让平台在工作流内部回调你自己的服务器 — 与 OpenAPI 的拉取模型相对,是「推送」方向。本节涵盖配置、请求格式、签名校验、重试语义与参考实现。
概念
平台里的 Webhook 是一份可复用的 HTTP 配置,工作流节点可以引用它发起调用。两条调用路径共用同一份配置:
| 路径 | 触发时机 | 模式 | 是否重试 | 是否解析响应 |
|---|---|---|---|---|
| Webhook 动作节点 | 工作流执行到该节点时 | 同步(工作流等待) | 节点级可配 | 是 — 按 JsonPath 解析为输出 |
| Decision Webhook 钩子 |
Decision 节点决策落定为 approve /
reject 后的可选回调
|
异步 Fire-and-forget | 否 | 否(失败仅审计) |
两条路径都读同一份
HttpServiceDefinition(URL、method、headers、签名等),一次配置两处可用。
在平台配置 Webhook(UI)
路径:侧栏 → Tools → Webhook → + New Webhook
| 字段 | 是否必填 | 说明 |
|---|---|---|
| Webhook 名称 | 是 | 展示名,节点编辑器里通过此名引用 |
| HTTP 方法 | 是 |
POST(默认)/ PUT /
PATCH / GET / DELETE。GET
和 DELETE 不发送 body
|
| Webhook URL | 是 |
绝对 URL,支持 {var}
占位符替换,运行时由 input map 提供,例如
https://api.example.com/users/{userId}
|
| 请求头 | 可选 |
{name, defaultValue} 列表;value 同样支持
{var} 占位符
|
| 输入 | 可选 | 声明的输入字段,作为 JSON 请求体的顶层 key |
| 输出 | 可选 |
{paramName, jsonPath, paramType, defaultValue}
列表,从响应体提取后作为节点输出(仅同步路径生效)
|
| 签名 | 可选 | 见下文「签名校验」 |
保存后会得到一个 Webhook ID,工作流节点编辑器通过此 ID 引用 Webhook。
请求格式
请求头
-
Webhook 上配置的 headers 会原样发送(先做
{var}替换)。 - 启用签名时,额外注入两个签名头(见「签名校验」)。
-
对带 body 的方法(POST/PUT/PATCH),
Content-Type默认application/json; charset=utf-8;如需覆盖,在 Webhook 的 Headers 区添加Content-Type项即可。
请求体(仅 POST/PUT/PATCH)
inputs map 直接序列化成 JSON 作为 body — 声明的输入字段成为顶层 key:
POST /receive HTTP/1.1
Content-Type: application/json; charset=utf-8
{
"customerId": "cust-001",
"action": "approve",
"score": 86
}
GET 和 DELETE 不发送 body。
占位符替换
Webhook URL 和 header value 支持
{var}(单大括号)替换。替换源是该次调用解析出的 inputs
map。
签名校验
平台对外发请求时使用 HMAC 签名,便于接收方验证身份。
配置项(每个 Webhook 独立)
| 字段 | 取值 |
|---|---|
type |
NONE(默认)/ HMAC_SHA256
|
payloadFormat |
TIMESTAMP_BODY(默认)/ BODY
|
secret |
共享密钥;落库前加密,保存后无法再查看,请妥善备份 |
signatureHeader |
签名头名,默认 X-Webhook-Signature
|
timestampHeader |
时间戳头名,默认 X-Webhook-Timestamp
|
签名规则
algorithm = HMAC_SHA256
timestamp = current Unix epoch in milliseconds, as a decimal string
content = if payloadFormat == BODY → body
else (TIMESTAMP_BODY) → timestamp + "\n" + body
signature = lowercase hex( HMAC(secret, content) )
-
body是发到对端的原始字节 — 带 body 的方法下是 序列化后的JSON.stringify(inputs);GET / DELETE 时为空字符串""。 -
secret和content都按 UTF-8 编码处理。 -
平台同时发送
X-Webhook-Timestamp(或您自定义的时间戳头),接收方按相同规则可重新计算签名。
接收方校验流程
-
从
X-Webhook-Timestamp取出时间戳; -
(建议)若
|now - timestamp| > 5 分钟,拒绝请求(防重放); - 用共享 secret 重新计算签名;
-
与
X-Webhook-Signature做等长比较(防时序攻击)。
参考实现:Node.js
参考验签器要求时间戳为 ASCII 十进制毫秒数,签名为 64 个小写十六进制字符,与上方签名规则一致。格式不合法的请求头会被拒绝。
const crypto = require('crypto');
function verify(req, secret) {
const timestamp = req.header('X-Webhook-Timestamp');
const signature = req.header('X-Webhook-Signature');
if (typeof timestamp !== 'string' || typeof signature !== 'string') return false;
if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) return false;
if (!Number.isSafeInteger(Number(timestamp))) return false;
// Replay protection (5-minute window)
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
// payloadFormat = TIMESTAMP_BODY (default)
// Capture req.rawBody before any JSON parser; preserve the exact bytes.
if (!Buffer.isBuffer(req.rawBody)) return false;
const content = Buffer.concat([Buffer.from(`${timestamp}\n`, 'utf8'), req.rawBody]);
const expected = crypto
.createHmac('sha256', secret)
.update(content, 'utf8')
.digest('hex');
// Constant-time comparison
const a = Buffer.from(signature, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
参考实现:Python
import hmac, hashlib, time, re
def verify(request_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get("X-Webhook-Timestamp")
sig = headers.get("X-Webhook-Signature")
if not isinstance(ts, str) or not re.fullmatch(r"[0-9]+", ts):
return False
if not isinstance(sig, str) or not re.fullmatch(r"[0-9a-f]{64}", sig):
return False
try:
timestamp = int(ts)
except ValueError:
return False
if abs(int(time.time() * 1000) - timestamp) > 5 * 60 * 1000:
return False
content = f"{ts}\n".encode() + request_body # payloadFormat = TIMESTAMP_BODY
expected = hmac.new(secret.encode(), content, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
响应处理(仅 Webhook 动作节点)
只有同步的 Webhook 动作节点会检查响应并按 outputs 解析;Decision 钩子不解析响应。
| 结果 | 行为 |
|---|---|
2xx |
成功。按 Webhook 的 outputs JsonPath 列表解析
body,命中的字段成为节点输出
|
4xx |
失败。默认不重试(除非该状态码被显式列入
retryOnStatus)
|
5xx |
失败。按 maxAttempts 退避重试
|
| 网络异常 / 超时 |
失败。按 maxAttempts 退避重试
|
输出解析
对每条 outputs
{paramName, jsonPath, paramType, defaultValue}:
- 响应 body 按 JSON 解析;
- 用 JsonPath 表达式取值;
-
按
paramType类型转换后,以paramName暴露为节点输出; -
若 JsonPath 未命中、或 body 非 JSON,使用
defaultValue(未配置则为null)。
// Response body
{ "data": { "score": 92, "reason": "OK" } }
| paramName | jsonPath | 结果 |
|---|---|---|
score |
$.data.score |
92 |
reason |
$.data.reason |
"OK" |
重试语义
| 字段 | 默认 | 说明 |
|---|---|---|
maxAttempts |
1(不重试)
|
限制在 [1, 5] 范围
|
backoffMs |
500 |
指数退避,delay = backoffMs * 2^(attempt-1)
|
retryOnStatus |
空 |
除默认的 5xx + 网络异常 + 超时之外,额外要重试的 HTTP
状态码(常用 408、429)
|
按默认 backoffMs=500、maxAttempts=5
计算,最坏情况累计退避 ~7.5 秒(0.5s + 1s + 2s + 4s)。
Decision Webhook 钩子
Decision 节点可选地在决策落地为 approve /
reject 之后触发一次 Webhook 推送。此路径是
发送后不等待响应:
- 异步发送,工作流不会等响应;
- 不重试,失败仅记审计,不阻断决策流程;
- 不解析响应体;
-
决策元数据
action/code/reason/tags会自动合并到 payload — 仅当您的 inputs 字段里没声明它们时。
接收方实践建议
-
幂等 — 同步路径在网络抖动时可能重发,请按
executionId(或您自定义 ID)去重; - 快速响应 — 平台每次尝试的 HTTP 超时默认 10 秒,耗时操作请先排队、立即 ACK;
-
状态码语义 —
2xx表示成功;4xx表示永久失败(不重试);5xx表示瞬时失败(会重试)。给逻辑失败返 2xx 会被当成成功; - 每次都校验签名 — 验签通过前不要信任 body;
- 拒绝过期时间戳 — 防重放;
-
联调时打全
X-Webhook-Signature和X-Webhook-Timestamp— 方便比对。
故障排查
| 现象 | 可能原因 |
|---|---|
| 接收方返回 401 / 403 |
签名不匹配 — 检查算法、payload 格式、secret;确认
body 是发到对端的原始字节(不是格式化后的
JSON)
|
| POST 请求接收方没收到 body | 方法是 GET / DELETE;或解析后的 inputs map 为空 |
outputs 全部为 null
|
响应不是 JSON、或 JsonPath 未命中。在 Webhook 编辑器
Output 区调整 JsonPath
|
| Decision 节点没有触发 Webhook |
决策没有落到 approve / reject;或
triggerOn 不包含当前 action;或
hook.enabled = false
|
Webhook URL is blank after placeholder resolve
|
URL 里的 {var} 没出现在 inputs 里 — 在 Inputs
区声明,或确保上游节点提供
|
| Signature secret 报错 | secret 为空或解密失败 — 在 Webhook 编辑器 Signature 区重新保存 secret |
速查
-
签名默认头名:
X-Webhook-Signature、X-Webhook-Timestamp(每个 Webhook 可自定义) -
默认算法:
HMAC_SHA256(启用签名时) -
默认 payload 格式:
TIMESTAMP_BODY→timestamp + "\n" + body -
默认重试:关闭(
maxAttempts=1);启用时backoffMs=500 - 每次 HTTP 超时:10 秒