/v1/account/webhook-endpoints
account/webhook-endpoints
Webhook:监控将新的 Signals 发送到哪里,以及如何验证。
Webhook 端点是你服务器的 HTTPS 地址,我们将你的监控发现的新信号发送到这里。每条消息都带有签名,你可以借此确认它来自我们。
费用和限制
| 项目 | 积分 |
|---|---|
| 管理 Webhook 端点和投递 | 免费 |
限制:每分钟 30 次请求。 创建、更新和删除:每分钟 10 次。 检查、测试和新密钥:每分钟 6 次。
每个账户最多可拥有 10 个 Webhook 端点;已归档的不计入。每个 Webhook 端点最多可携带 10 个自定义请求头。
管理员令牌或 API 密钥
创建签名密钥以及启用、停用或归档 Webhook 端点需要管理员令牌。你可以在“账户 › API 密钥”下的“开发者:通过 API 管理密钥”区域创建它。它的有效期为一年,新的会替换旧的。
以 Authorization: Bearer YOUR_ADMIN_TOKEN 发送管理员令牌。其他所有调用也可以使用 API 密钥。
与 account/monitors 一样,所有写调用都需要请求头 Idempotency-Key。
端点
| 方法 | 路径 | 用途 | 访问 |
|---|---|---|---|
| GET |
/v1/account/webhook-endpoints |
所有 Webhook 端点,不含已归档的。 | API 密钥或管理员令牌 |
| POST |
/v1/account/webhook-endpoints |
创建 Webhook 端点。 | 管理员令牌 |
| POST |
/v1/account/webhook-endpoints/{id}/verify |
验证。 | API 密钥或管理员令牌 |
| POST |
/v1/account/webhook-endpoints/{id}/test |
发送测试消息。 | API 密钥或管理员令牌 |
| POST |
/v1/account/webhook-endpoints/{id}/rotate-secret |
新的签名密钥。 | 管理员令牌 |
| POST |
/v1/account/webhook-endpoints/{id}/enable |
启用。 | 管理员令牌 |
| POST |
/v1/account/webhook-endpoints/{id}/disable |
停用。 | 管理员令牌 |
| DELETE |
/v1/account/webhook-endpoints/{id} |
归档。 | 管理员令牌 |
| GET |
/v1/account/webhook-deliveries |
最近 50 次投递。 | API 密钥或管理员令牌 |
| POST |
/v1/account/webhook-deliveries/{id}/retry |
重新发送失败的投递。 | API 密钥或管理员令牌 |
| GET |
/v1/account/webhook-events |
最近 50 个事件。 | API 密钥或管理员令牌 |
创建 Webhook 端点
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints" \ -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "CRM-Sync", "url": "https://hooks.example.com/handelsregister", "headers": { "X-Receiver-Token": "YOUR_RECEIVER_TOKEN" } }'
import uuid import requests response = requests.post( "https://handelsregister.ai/api/v1/account/webhook-endpoints", headers={ "Authorization": "Bearer YOUR_ADMIN_TOKEN", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "CRM-Sync", "url": "https://hooks.example.com/handelsregister", "headers": { "X-Receiver-Token": "YOUR_RECEIVER_TOKEN" } }, ) data = response.json()
const response = await fetch("https://handelsregister.ai/api/v1/account/webhook-endpoints", { method: "POST", headers: { "Authorization": "Bearer YOUR_ADMIN_TOKEN", "Idempotency-Key": crypto.randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ "name": "CRM-Sync", "url": "https://hooks.example.com/handelsregister", "headers": { "X-Receiver-Token": "YOUR_RECEIVER_TOKEN" } }), }); const data = await response.json();
| 名称 | 类型 | 说明 | 示例 |
|---|---|---|---|
| Idempotency-KeyHeader | 字符串必填 | 每个操作一个唯一值,最好是 UUID。见 Idempotency-Key。限制: 1 至 128 个字符 |
req-20260802-01 |
| nameBody | 字符串必填 | 供你自己参考的名称。限制: 1 至 120 个字符 |
CRM-Sync |
| urlBody | 字符串 (uri)必填 |
你服务器的地址:可公开访问,端口 443 上的 https,使用主机名而非 IP 地址。限制: 1 至 2,048 个字符 |
https://hooks.example.com/handelsregister |
| headersBody | 对象可选 | 随每条消息发送的自定义请求头,例如供你服务器使用的令牌。限制: 最多 10 项 |
{"X-Receiver-Token":"YOUR_RECEIVER_TOKEN"} |
请求头名称不得以 webhook- 开头,也不得覆盖 Content-Type 或 Host 等标准请求头。这些值只写不读;之后 API 只显示名称。
响应包含签名密钥 whsec_…。它只显示这一次。请立即保存。
1{2 "endpoint": {3 "id": "wep_01m6b3j1g0cdz98hw8zdefbf52",4 "name": "CRM-Sync",5 "url": "hooks.example.com/…",6 "status": "pending_verification",7 "verified_at": null,8 "header_names": [9 "X-Receiver-Token"10 ],11 "consecutive_failures": 0,12 "created_at": "2026-11-01T08:28:01+00:00"13 },14 "signing_secret": "whsec_…",15 "meta": {16 "message": "Endpoint created. Store the signing secret now — it is shown only once.",17 "request_credit_cost": 0,18 "credits_remaining": 14519 }20}
新的 Webhook 端点状态为 pending_verification,只有在验证后才会接收消息。名称、URL 和请求头之后无法更改。如需新地址,请创建新的 Webhook 端点。
在响应中,url 只显示主机;我们将路径缩写为 /…。
验证
通过 POST …/{id}/verify,我们会向你的 URL 发送一条类型为 endpoint.verification 的签名消息。data.challenge 包含一个随机值。
你的服务器在 15 秒内以 2xx 状态响应,并在响应头 webhook-verification 中返回该值。Webhook 端点随后变为 active。
1{2 "id": "msg_01m6b3jsqq7ntwzkdj099ayraz",3 "type": "endpoint.verification",4 "timestamp": "2026-11-01T08:28:26.743Z",5 "schema_version": 1,6 "data": {7 "challenge": "189fb34b2af819bb13cb47d9806dbf30"8 }9}
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/verify" \ -H "x-api-key: YOUR_API_KEY" \ -H "Idempotency-Key: $(uuidgen)"
import uuid import requests response = requests.post( "https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/verify", headers={ "x-api-key": "YOUR_API_KEY", "Idempotency-Key": str(uuid.uuid4()), }, ) data = response.json()
const response = await fetch("https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/verify", { method: "POST", headers: { "x-api-key": "YOUR_API_KEY", "Idempotency-Key": crypto.randomUUID(), }, }); const data = await response.json();
成功时,API 以 "verified": true 响应,否则以 422 和 "verified": false 响应。再次尝试时,你需要新的 Idempotency-Key。
每个 Webhook 端点每小时允许 6 次尝试。
1{2 "verified": true,3 "endpoint": {4 "id": "wep_01m6b3j1g0cdz98hw8zdefbf52",5 "name": "CRM-Sync",6 "url": "hooks.example.com/…",7 "status": "active",8 "verified_at": "2026-11-01T08:28:26+00:00",9 "header_names": [10 "X-Receiver-Token"11 ],12 "consecutive_failures": 0,13 "created_at": "2026-11-01T08:28:01+00:00"14 },15 "meta": {16 "request_credit_cost": 0,17 "credits_remaining": 14518 }19}
之后,POST …/{id}/test 可随时发送一条类型为 endpoint.test 的签名测试消息。响应显示投递结果。
1{2 "delivery": {3 "id": "del_01m6b3jsyrnc3sz1mt31dq6107",4 "message_id": "msg_01m6b3jsynxzhvagm7n2nq7mpv",5 "event_id": "evt_01m6b3jsynxzhvagm7n2nq7mpt",6 "event_type": "endpoint.test",7 "endpoint_id": "wep_01m6b3j1g0cdz98hw8zdefbf52",8 "status": "succeeded",9 "attempts": 1,10 "last_status_code": 200,11 "last_error_category": null,12 "next_attempt_at": "2026-11-01T08:28:26+00:00",13 "succeeded_at": "2026-11-01T08:28:26+00:00",14 "created_at": "2026-11-01T08:28:26+00:00"15 },16 "meta": {17 "request_credit_cost": 0,18 "credits_remaining": 14519 }20}
验证签名
每条消息都按开放标准 Standard Webhooks 签名。处理消息前,请先验证签名。
| 请求头 | 含义 |
|---|---|
| webhook-id |
消息的 ID(msg_…)。重试时保持不变。用它识别重复。
|
| webhook-timestamp | 本次投递尝试的时间,Unix 秒。 |
| webhook-signature |
一个或多个 v1,<Base64> 形式的签名,以空格分隔。
|
验证消息的方法:
- 取密钥字符串中
whsec_之后的部分,并进行 Base64 解码。这就是密钥。 - 用两个请求头和未经改动的正文拼成文本
webhook-id.webhook-timestamp.Body。 - 用该密钥对其计算 HMAC-SHA256,并将结果进行 Base64 编码。
- 以恒定时间将结果与请求头中的每个
v1签名比较。只要有一个匹配,消息就是真实的。
请使用到达时原样的正文,而不是重新序列化的 JSON。拒绝时间戳偏差超过五分钟的消息。
import base64, hashlib, hmac, time def verify_webhook(secret: str, headers: dict, body: bytes) -> bool: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] # 拒绝超过五分钟的消息 if abs(time.time() - int(timestamp)) > 300: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() # 密钥轮换期间,请求头带有两个签名 for part in headers["webhook-signature"].split(" "): version, _, signature = part.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return True return False
import crypto from "node:crypto"; export function verifyWebhook(secret, headers, rawBody) { const msgId = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; // 拒绝超过五分钟的消息 if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = crypto .createHmac("sha256", key) .update(`${msgId}.${timestamp}.${rawBody}`) .digest(); // 密钥轮换期间,请求头带有两个签名 return headers["webhook-signature"].split(" ").some((part) => { const [version, signature] = part.split(","); const given = Buffer.from(signature ?? "", "base64"); return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected); }); }
你也可以不自己写代码,而使用 Standard Webhooks 的官方库,例如 Python 和 JavaScript 的 standardwebhooks 包。
通过 POST …/{id}/rotate-secret,你会在响应中一次性获得新的密钥。在 7 天内,消息会带有两个签名,一个用新密钥,一个用旧密钥。这样你可以无中断地切换。
负载
每条消息都是一个外层结构相同的 JSON 对象。构成事件的内容位于 data 中。
| 字段 | 含义 |
|---|---|
| id |
消息的 ID,与请求头 webhook-id 相同。
|
| event_id |
事件的 ID(evt_…)。
|
| type | 事件类型,见下文。 |
| timestamp | 我们检测到事件的时间,UTC。 |
| schema_version |
结构的版本,目前为 1。
|
| data | 事件数据。 |
| 事件类型 | 含义 |
|---|---|
| organization.signal.detected |
某个监控发现了新信号。data.signal 的结构与 signals 中的信号相同,另有 data.monitor,以及 data.links.signal 下指向 API 中该信号的链接。
|
| organization.signal.sample |
来自公司真实历史的示例,在“账户 › 监控”下发送。结构与上述相同,另加 data.sample: true。
|
| endpoint.test |
通过 /test 发送的测试消息。
|
| endpoint.verification | 验证时的质询。 |
1{2 "id": "msg_01m65pqg9x0176cteqvtxw68sa",3 "event_id": "evt_01m65pqg9sx7mwmw0edc1mfbbe",4 "type": "organization.signal.sample",5 "timestamp": "2026-10-30T06:07:37.000Z",6 "schema_version": 1,7 "data": {8 "monitor": {9 "id": "mon_01m65pn3vm11sjjp1j076cbvec",10 "type": "organization.signals"11 },12 "sample": true,13 "signal": {14 "event": {15 "id": "36c7c8fddf99bb0299d6815bcaa01013",16 "topic": "TRANSFORMATIONS",17 "topic_name": {18 "de": "Umwandlungen & M&A",19 "en": "Transformations & M&A"20 },21 "occurred_on": "2022-12-22",22 "announced_on": "2023-08-10",23 "date_basis": "SOURCE_EVENT"24 },25 "organization": {26 "type": "ORGANIZATION",27 "entity_id": "94eb910fef13f94585c48c263fa446d4",28 "current_profile": {29 "name": "Hetzner Online GmbH",30 "registration": {31 "register_type": "HRB",32 "register_number": "6089",33 "court": "Ansbach",34 "register_locality": "Ansbach",35 "registration_authority_id": "D3201",36 "register_entity_name": "Ansbach HRB 6089"37 },38 "legal_form": {39 "name": {40 "de": "Gesellschaft mit beschränkter Haftung"41 },42 "short": {43 "de": "GmbH"44 }45 },46 "seat": {47 "city": "Gunzenhausen"48 },49 "status": {50 "code": "ACTIVE"51 }52 }53 },54 "parties": {55 "counterparty": {56 "entity": {57 "type": "ORGANIZATION",58 "entity_id": "dd4d2d6854b2dbd1ffce74e669f3852e",59 "current_profile": {60 "name": "Headtrip GmbH",61 "registration": {62 "register_type": "HRB",63 "register_number": "33432",64 "court": "Nürnberg",65 "register_locality": "Nuremberg",66 "registration_authority_id": "D3310",67 "register_entity_name": "Nürnberg HRB 33432"68 },69 "legal_form": {70 "name": {71 "de": "Gesellschaft mit beschränkter Haftung"72 },73 "short": {74 "de": "GmbH"75 }76 },77 "seat": {78 "city": "Nürnberg"79 },80 "status": {81 "code": "INACTIVE"82 }83 }84 },85 "relationship": "TRANSFERRING_COMPANY"86 }87 },88 "register_entry": {89 "entry_number": "3",90 "entry_date": "2023-08-10",91 "description_text": "Verschmelzung zur Aufnahme der Headtrip GmbH",92 "context": "PRIMARY",93 "flags": []94 },95 "source": {96 "kind": "COMMERCIAL_REGISTER"97 },98 "details": {99 "schema": "TRANSFORMATION",100 "kind": "MERGER",101 "specifics": {102 "merger_target": "ABSORPTION"103 },104 "direction": "INCOMING",105 "is_cross_border": false,106 "resolution_date": "2022-12-22",107 "agreement_date": "2022-12-22"108 }109 },110 "links": {111 "signal": "https://handelsregister.ai/api/v1/signals/36c7c8fddf99bb0299d6815bcaa01013"112 }113 }114}
一条消息最大 20 KB。如果信号更大,会省略 details 和 register_entry,并将 data.truncated 设为 true。此时请通过链接获取完整的信号。
请忽略你不认识的事件类型,并仍以 2xx 响应。这样在新增类型时,你的服务器仍能保持稳定。
投递和重试
当你的服务器在 15 秒内以 2xx 状态响应时,投递视为成功。我们不跟随重定向。
否则我们会再次尝试:最多 10 次,间隔逐渐增大,历时约三天。如果你的服务器发送 Retry-After,我们会遵守。
如果你的服务器以 410 响应,我们会停止投递并停用该 Webhook 端点。
如果连续 5 次投递最终失败,Webhook 端点会以 disabled_failures 停用。使用 POST …/{id}/enable 可以重新启用。
webhook-id 识别重复。请快速响应,之后再处理消息。
GET /v1/account/webhook-deliveries 显示最近 50 次投递,使用 ?endpoint=wep_… 可只看一个 Webhook 端点。GET /v1/account/webhook-events 显示最近 50 个事件。
对于最终失败的投递,可用 POST /v1/account/webhook-deliveries/{id}/retry 重新发送,webhook-id 和正文保持不变。这在 30 天内有效;我们保存消息的时间就是这么长。
| 状态 | 含义 |
|---|---|
| withheld | 等待该次检查完成计费。 |
| pending | 等待发送。 |
| in_flight | 正在发送。 |
| retry_wait | 等待下一次尝试。 |
| succeeded | 已投递。 |
| exhausted | 所有尝试均失败。 |
| blocked_endpoint | 该 Webhook 端点目前无法接收任何内容。 |
| cancelled | 已取消,例如因为消息已过期。 |
启用、停用、归档
POST …/{id}/disable 暂时停止投递。没有其他 Webhook 端点的监控随后会以 paused_configuration 暂停。
POST …/{id}/enable 重新启用已验证的 Webhook 端点。然后请用 resume 恢复已暂停的监控。
DELETE …/{id} 永久归档 Webhook 端点。如需暂时停止,请改为停用。
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/disable" \ -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ -H "Idempotency-Key: $(uuidgen)"
import uuid import requests response = requests.post( "https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/disable", headers={ "Authorization": "Bearer YOUR_ADMIN_TOKEN", "Idempotency-Key": str(uuid.uuid4()), }, ) data = response.json()
const response = await fetch("https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/disable", { method: "POST", headers: { "Authorization": "Bearer YOUR_ADMIN_TOKEN", "Idempotency-Key": crypto.randomUUID(), }, }); const data = await response.json();
错误
错误的结构与 account/monitors 相同:error 中为代码,meta.message 中为说明。错误不收费。
| 状态 | 原因 | 你可以怎么做 |
|---|---|---|
| 400 |
api_key 位于 URL 中。 |
在请求头 x-api-key 中发送密钥。 |
| 400 |
该 URL 不允许作为目标、某个请求头不被允许、已达到最大数量,或 Webhook 端点未处于有效状态。meta.message 给出原因。 |
修正该值,或先进行验证。 |
| 400 |
Idempotency-Key 格式无效。 |
修正格式,例如使用 UUID。 |
| 401 | 没有 API 密钥或令牌,或其无效。 |
在请求头 x-api-key 中发送密钥。 |
| 403 |
Bearer 令牌缺少权限 account:read。 |
使用 API 密钥或带 account:read 的令牌。 |
| 403 | 你账户的电子邮件地址尚未确认,或账户已被锁定。 | 打开确认邮件中的链接。如果账户被锁定,客服可以提供帮助。 |
| 403 | 此调用需要管理员令牌。API 密钥不够,其他令牌也不行。 |
用 Authorization: Bearer 加管理员令牌发送。 |
| 403 |
Bearer 令牌缺少权限 monitoring:manage。 |
使用 API 密钥或管理员令牌。 |
| 404 | 没有此 ID 的 Webhook 端点或投递。 | 检查 ID。 |
| 409 |
该 Idempotency-Key 已用于不同的数据、已超过 24 小时,或仍在处理中。 |
使用新键或稍等片刻。 |
| 422 |
某个值缺失或无效,例如 name 或 url。errors 指出该字段。 |
修正该值,并用新键重试。 |
| 422 | 验证失败。 | 检查质询请求头和 2xx 状态,然后用新键重试。 |
| 428 |
缺少请求头 Idempotency-Key。 |
发送该请求头。 |
| 429 | 请求过多。 |
按 Retry-After 等待后重试。 |
| 503 | 验证、测试和重新发送暂时不可用。 | 稍后重试。 |
1{2 "error": "invalid_request",3 "meta": {4 "message": "The endpoint is not active.",5 "request_credit_cost": 0,6 "credits_remaining": 1457 }8}