/v1/account/webhook-endpoints
account/webhook-endpoints
Webhooks: where Monitoring sends new Signals and how you verify them.
A webhook endpoint is the HTTPS address of your server that we send your monitors' new signals to. Every message is signed, so you can check that it comes from us.
Costs and limits
| What | Credits |
|---|---|
| Manage webhook endpoints and deliveries | free |
Limit: 30 requests per minute. Create, update and delete: 10 per minute. Check, test and new secret: 6 per minute.
Each account can have up to 10 webhook endpoints; archived ones do not count. Each webhook endpoint can carry up to 10 custom headers.
Admin token or API key
You need the admin token to create a signing secret and to enable, disable or archive webhook endpoints. You create it under Account › API keys in the section “For developers: manage keys via the API”. It is valid for one year, and a new one replaces the old one.
Send the admin token as Authorization: Bearer YOUR_ADMIN_TOKEN. All other calls also work with the API key.
As with account/monitors, all write calls need the header Idempotency-Key.
Endpoints
| Method | Path | Purpose | Access |
|---|---|---|---|
| GET |
/v1/account/webhook-endpoints |
All webhook endpoints, without archived ones. | API key or admin token |
| POST |
/v1/account/webhook-endpoints |
Create a webhook endpoint. | Admin token |
| POST |
/v1/account/webhook-endpoints/{id}/verify |
Verify. | API key or admin token |
| POST |
/v1/account/webhook-endpoints/{id}/test |
Send a test message. | API key or admin token |
| POST |
/v1/account/webhook-endpoints/{id}/rotate-secret |
New signing secret. | Admin token |
| POST |
/v1/account/webhook-endpoints/{id}/enable |
Enable. | Admin token |
| POST |
/v1/account/webhook-endpoints/{id}/disable |
Disable. | Admin token |
| DELETE |
/v1/account/webhook-endpoints/{id} |
Archive. | Admin token |
| GET |
/v1/account/webhook-deliveries |
The last 50 deliveries. | API key or admin token |
| POST |
/v1/account/webhook-deliveries/{id}/retry |
Send a failed delivery again. | API key or admin token |
| GET |
/v1/account/webhook-events |
The last 50 events. | API key or admin token |
Create a webhook endpoint
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();
| Name | Type | Description | Example |
|---|---|---|---|
| Idempotency-KeyHeader | stringrequired | A unique value per operation, ideally a UUID. See Idempotency-Key.Limits: 1 to 128 characters |
req-20260802-01 |
| nameBody | stringrequired | A name for your own reference.Limits: 1 to 120 characters |
CRM-Sync |
| urlBody | string (uri)required |
Your server's address: publicly reachable, https on port 443, with a host name instead of an IP address.Limits: 1 to 2,048 characters |
https://hooks.example.com/handelsregister |
| headersBody | objectoptional | Custom headers sent with every message, such as a token for your server.Limits: at most 10 entries |
{"X-Receiver-Token":"YOUR_RECEIVER_TOKEN"} |
Header names must not start with webhook- and must not override standard headers such as Content-Type or Host. The values are write-only; later the API shows only the names.
The response contains the signing secret whsec_…. We show it only this once. Store it right away.
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}
A new webhook endpoint has the status pending_verification and only receives messages after it is verified. Name, URL and headers cannot be changed later. For a new address, create a new webhook endpoint.
In responses, url shows only the host; we shorten the path to /….
Verify
With POST …/{id}/verify we send a signed message of type endpoint.verification to your URL. data.challenge holds a random value.
Your server answers within 15 seconds with a 2xx status and returns the value in the response header webhook-verification. The webhook endpoint then becomes 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();
If it works, the API answers with "verified": true, otherwise with 422 and "verified": false. For a new attempt you need a new Idempotency-Key.
Each webhook endpoint allows 6 attempts per hour.
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}
After that, POST …/{id}/test sends a signed test message of type endpoint.test whenever you like. The response shows the result of the delivery.
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}
Verify the signature
Every message is signed following the open standard Standard Webhooks. Verify the signature before you process a message.
| Header | Meaning |
|---|---|
| webhook-id |
ID of the message (msg_…). It stays the same on retries. Use it to detect duplicates.
|
| webhook-timestamp | Time of this delivery attempt in Unix seconds. |
| webhook-signature |
One or more signatures of the form v1,<Base64>, separated by spaces.
|
How to verify a message:
- Take the part of the secret after
whsec_and decode it from Base64. That is the key. - Build the text
webhook-id.webhook-timestamp.Bodyfrom the two headers and the unchanged body. - Compute HMAC-SHA256 of it with the key and encode the result in Base64.
- Compare the result in constant time with every
v1signature in the header. If one matches, the message is genuine.
Use the body exactly as it arrives, not re-serialized JSON. Reject messages whose timestamp is off by more than five minutes.
import base64, hashlib, hmac, time def verify_webhook(secret: str, headers: dict, body: bytes) -> bool: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] # Reject messages older than five minutes 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() # During a secret rotation, the header carries two signatures 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"]; // Reject messages older than five minutes 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(); // During a secret rotation, the header carries two signatures 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); }); }
Instead of writing your own code, you can use the official Standard Webhooks libraries, such as the package standardwebhooks for Python and JavaScript.
With POST …/{id}/rotate-secret you get a new secret, once, in the response. For 7 days messages carry two signatures, one with the new and one with the old secret. That way you switch over without downtime.
Payload
Every message is a JSON object with the same envelope. What makes up the event is in data.
| Field | Meaning |
|---|---|
| id |
ID of the message, equal to the header webhook-id.
|
| event_id |
ID of the event (evt_…).
|
| type | Type of the event, see below. |
| timestamp | When we detected the event, in UTC. |
| schema_version |
Version of the structure, currently 1.
|
| data | The event data. |
| Event type | Meaning |
|---|---|
| organization.signal.detected |
A monitor has found a new signal. data.signal has the same structure as a signal in signals, plus data.monitor and, under data.links.signal, the link to the signal in the API.
|
| organization.signal.sample |
An example from the company's real history, sent under Account › Monitoring. Same structure as above, plus data.sample: true.
|
| endpoint.test |
Test message via /test.
|
| endpoint.verification | Challenge during 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}
A message is at most 20 KB. If a signal gets larger, details and register_entry are left out and data.truncated is true. You then fetch the whole signal through the link.
Ignore event types you do not know and still answer with 2xx. That way your server stays stable when new types are added.
Deliveries and retries
A delivery counts as successful when your server answers within 15 seconds with a 2xx status. We do not follow redirects.
Otherwise we try again: up to 10 attempts, with growing gaps over about three days. If your server sends Retry-After, we follow it.
If your server answers with 410, we stop delivering and disable the webhook endpoint.
If 5 deliveries in a row fail for good, the webhook endpoint is disabled with disabled_failures. With POST …/{id}/enable you turn it back on.
webhook-id. Answer quickly and process the message only after that.
GET /v1/account/webhook-deliveries shows the last 50 deliveries, with ?endpoint=wep_… for one webhook endpoint. GET /v1/account/webhook-events shows the last 50 events.
You send a delivery that failed for good again with POST /v1/account/webhook-deliveries/{id}/retry, with the same webhook-id and the same body. This works for 30 days; that is how long we keep the messages.
| Status | Meaning |
|---|---|
| withheld | Waits until the check has been billed. |
| pending | Waits to be sent. |
| in_flight | Being sent right now. |
| retry_wait | Waits for the next attempt. |
| succeeded | Delivered. |
| exhausted | All attempts failed. |
| blocked_endpoint | The webhook endpoint cannot receive anything right now. |
| cancelled | Cancelled, for example because the message expired. |
Enable, disable, archive
POST …/{id}/disable stops delivery for the time being. Monitors without another webhook endpoint then pause with paused_configuration.
POST …/{id}/enable turns a verified webhook endpoint back on. Then resume paused monitors with resume.
DELETE …/{id} archives the webhook endpoint for good. To stop it for a while, disable it instead.
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();
Errors
Errors have the same structure as for account/monitors: a code in error, the explanation in meta.message. Errors cost nothing.
| Status | Cause | What you can do |
|---|---|---|
| 400 |
api_key is in the URL. |
Send the key in the header x-api-key. |
| 400 |
The URL is not allowed as a destination, a header is not permitted, the maximum number is reached, or the webhook endpoint is not active. meta.message gives the reason. |
Correct the value or verify first. |
| 400 |
The Idempotency-Key has an invalid format. |
Correct the format, for example use a UUID. |
| 401 | No API key or token, or an invalid one. |
Send the key in the header x-api-key. |
| 403 |
The bearer token lacks the ability account:read. |
Use the API key or a token with account:read. |
| 403 | Your account's email address is not confirmed yet, or the account is locked. | Open the link from the confirmation email. If the account is locked, support can help. |
| 403 | This call needs the admin token. An API key is not enough, and neither is another token. |
Send it with Authorization: Bearer and the admin token. |
| 403 |
The bearer token lacks the ability monitoring:manage. |
Use the API key or the admin token. |
| 404 | No webhook endpoint or delivery with this ID. | Check the ID. |
| 409 |
The Idempotency-Key was already used with different data, is older than 24 hours, or is still being processed. |
Use a new key or wait briefly. |
| 422 |
A value is missing or invalid, such as name or url. errors names the field. |
Correct the value and retry with a new key. |
| 422 | Verification failed. | Check the challenge header and the 2xx status, then retry with a new key. |
| 428 |
The header Idempotency-Key is missing. |
Send the header. |
| 429 | Too many requests. |
Retry after Retry-After. |
| 503 | Verifying, testing and resending are temporarily unavailable. | Retry a little later. |
1{2 "error": "invalid_request",3 "meta": {4 "message": "The endpoint is not active.",5 "request_credit_cost": 0,6 "credits_remaining": 1457 }8}