跳转到主要内容

集成指南

托管核验链接、名单筛查、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": {}
  }
}
本接口只服务身份核验。交易类与 Web3 类事件没有可跳转的托管网页流程,请改用 workflow.apply.submit 提交。

名单筛查

把一个待筛查主体(个人或企业)送进平台做名单筛查。平台会按客户号建档,在你的工作流里跑完筛查,命中就产生告警并开一个案件交给分析员裁定。使用 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 ——先把请求体序列化成字节,对这份字节签名,再把同一份字节发出去,别让框架在签完之后又重新序列化一遍。完整规则和各语言代码见身份验证。

筛查会消耗第三方名单筛查供应商的配额,所以每一次提交都当成花钱的动作来对待:调用前先去重;万一调用超时,先用workflow.apply.query查上一次提交跑到哪一步了,别直接重发。额度到底怎么扣,以 管理控制台 → 账单 用量页上的数字为准(见用量与配额),别照自己的理解推算;正式放量接入之前,先小批量跑一两笔,比一比那一页跑前跑后的差值。

请求

{
  "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,政治公众人物)姓名核查。

Quick Scan 与其余 OpenAPI 接口共用同一网关基础地址、同一套 query-string 请求信封(api / apiVersion / apiKey / sign)加 x-domain 请求头,以及同样的请求体 HMAC-SHA256 签名。基础地址与请求格式见 OpenAPI 参考 → 快速入门,完整签名规则见身份验证章节。

调用流程

  1. quickscan.submit — 提交筛查,立即返回 id(异步执行)
  2. quickscan.detail — 轮询状态,DONE 时返回命中摘要
  3. 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。

请求格式

请求头

请求体(仅 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) )

接收方校验流程

  1. 从 X-Webhook-Timestamp 取出时间戳;
  2. (建议)若 |now - timestamp| > 5 分钟,拒绝请求(防重放);
  3. 用共享 secret 重新计算签名;
  4. 与 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}:

// 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 推送。此路径是 发送后不等待响应:

需要根据响应分支? 请改用 Webhook 动作节点。

接收方实践建议

  1. 幂等 — 同步路径在网络抖动时可能重发,请按 executionId(或您自定义 ID)去重;
  2. 快速响应 — 平台每次尝试的 HTTP 超时默认 10 秒,耗时操作请先排队、立即 ACK;
  3. 状态码语义 — 2xx 表示成功;4xx 表示永久失败(不重试);5xx 表示瞬时失败(会重试)。给逻辑失败返 2xx 会被当成成功;
  4. 每次都校验签名 — 验签通过前不要信任 body;
  5. 拒绝过期时间戳 — 防重放;
  6. 联调时打全 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

速查