/v1/account/webhook-endpoints
account/webhook-endpoints
Webhooks: wohin Monitoring neue Signals schickt und wie du sie prüfst.
Ein Webhook-Endpoint ist die HTTPS-Adresse deines Servers, an die wir neue Signals deiner Monitore schicken. Jede Nachricht ist signiert, damit du prüfen kannst, dass sie von uns kommt.
Kosten und Limits
| Was | Credits |
|---|---|
| Webhook-Endpoints und Zustellungen verwalten | kostenlos |
Limit: 30 Anfragen pro Minute. Anlegen, Ändern und Löschen: 10 pro Minute. Prüfen, Testen und neues Secret: 6 pro Minute.
Pro Konto sind bis zu 10 Webhook-Endpoints möglich, archivierte zählen nicht mit. Jeder Webhook-Endpoint kann bis zu 10 eigene Header mitbekommen.
Admin-Token oder API-Schlüssel
Das Admin-Token brauchst du, um ein Signatur-Secret zu erzeugen und um Webhook-Endpoints zu aktivieren, zu deaktivieren oder zu archivieren. Du erstellst es unter Konto › API-Schlüssel im Abschnitt „Für Entwickler: Schlüssel per API verwalten“. Es gilt ein Jahr, ein neues ersetzt das alte.
Das Admin-Token schickst du als Authorization: Bearer DEIN_ADMIN_TOKEN. Alle anderen Aufrufe gehen auch mit dem API-Schlüssel.
Alle schreibenden Aufrufe brauchen wie bei account/monitors den Header Idempotency-Key.
Endpunkte
| Methode | Pfad | Zweck | Zugang |
|---|---|---|---|
| GET |
/v1/account/webhook-endpoints |
Alle Webhook-Endpoints, ohne archivierte. | API-Schlüssel oder Admin-Token |
| POST |
/v1/account/webhook-endpoints |
Webhook-Endpoint anlegen. | Admin-Token |
| POST |
/v1/account/webhook-endpoints/{id}/verify |
Verifizieren. | API-Schlüssel oder Admin-Token |
| POST |
/v1/account/webhook-endpoints/{id}/test |
Testnachricht senden. | API-Schlüssel oder Admin-Token |
| POST |
/v1/account/webhook-endpoints/{id}/rotate-secret |
Neues Signatur-Secret. | Admin-Token |
| POST |
/v1/account/webhook-endpoints/{id}/enable |
Aktivieren. | Admin-Token |
| POST |
/v1/account/webhook-endpoints/{id}/disable |
Deaktivieren. | Admin-Token |
| DELETE |
/v1/account/webhook-endpoints/{id} |
Archivieren. | Admin-Token |
| GET |
/v1/account/webhook-deliveries |
Die letzten 50 Zustellungen. | API-Schlüssel oder Admin-Token |
| POST |
/v1/account/webhook-deliveries/{id}/retry |
Fehlgeschlagene Zustellung erneut senden. | API-Schlüssel oder Admin-Token |
| GET |
/v1/account/webhook-events |
Die letzten 50 Ereignisse. | API-Schlüssel oder Admin-Token |
Webhook-Endpoint anlegen
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints" \ -H "Authorization: Bearer DEIN_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": "DEIN_EMPFAENGER_TOKEN" } }'
import uuid import requests response = requests.post( "https://handelsregister.ai/api/v1/account/webhook-endpoints", headers={ "Authorization": "Bearer DEIN_ADMIN_TOKEN", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "CRM-Sync", "url": "https://hooks.example.com/handelsregister", "headers": { "X-Receiver-Token": "DEIN_EMPFAENGER_TOKEN" } }, ) data = response.json()
const response = await fetch("https://handelsregister.ai/api/v1/account/webhook-endpoints", { method: "POST", headers: { "Authorization": "Bearer DEIN_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": "DEIN_EMPFAENGER_TOKEN" } }), }); const data = await response.json();
| Name | Typ | Beschreibung | Beispiel |
|---|---|---|---|
| Idempotency-KeyHeader | stringPflicht | Ein eindeutiger Wert pro Vorgang, am besten eine UUID. Siehe Idempotency-Key.Grenzen: 1 bis 128 Zeichen |
req-20260802-01 |
| nameBody | stringPflicht | Ein Name für dich.Grenzen: 1 bis 120 Zeichen |
CRM-Sync |
| urlBody | string (uri)Pflicht |
Die Adresse deines Servers: öffentlich erreichbar, https auf Port 443, mit Hostnamen statt IP-Adresse.Grenzen: 1 bis 2.048 Zeichen |
https://hooks.example.com/handelsregister |
| headersBody | Objektoptional | Eigene Header, die bei jeder Nachricht mitgehen, etwa ein Token für deinen Server.Grenzen: höchstens 10 Einträge |
{"X-Receiver-Token":"DEIN_EMPFAENGER_TOKEN"} |
Header-Namen dürfen nicht mit webhook- beginnen und keine Standard-Header wie Content-Type oder Host überschreiben. Die Werte sind nur schreibbar, die API zeigt später nur die Namen.
Die Antwort enthält das Signatur-Secret whsec_…. Wir zeigen es nur dieses eine Mal. Speichere es sofort.
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}
Ein neuer Webhook-Endpoint hat den Status pending_verification und bekommt erst nach dem Verifizieren Nachrichten. Name, URL und Header lassen sich später nicht ändern. Für eine neue Adresse legst du einen neuen Webhook-Endpoint an.
In Antworten zeigt url nur den Host, den Pfad kürzen wir zu /….
Verifizieren
Mit POST …/{id}/verify schicken wir eine signierte Nachricht vom Typ endpoint.verification an deine URL. In data.challenge steht ein Zufallswert.
Dein Server antwortet innerhalb von 15 Sekunden mit einem 2xx-Status und gibt den Wert im Antwort-Header webhook-verification zurück. Dann wird der Webhook-Endpoint 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: DEIN_API_SCHLUESSEL" \ -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": "DEIN_API_SCHLUESSEL", "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": "DEIN_API_SCHLUESSEL", "Idempotency-Key": crypto.randomUUID(), }, }); const data = await response.json();
Klappt es, antwortet die API mit "verified": true, sonst mit 422 und "verified": false. Für einen neuen Versuch brauchst du einen neuen Idempotency-Key.
Pro Webhook-Endpoint sind 6 Versuche pro Stunde möglich.
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}
Mit POST …/{id}/test schickst du danach jederzeit eine signierte Testnachricht vom Typ endpoint.test. Die Antwort zeigt das Ergebnis der Zustellung.
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}
Signatur prüfen
Jede Nachricht ist nach dem offenen Standard Standard Webhooks signiert. Prüfe die Signatur, bevor du eine Nachricht verarbeitest.
| Header | Bedeutung |
|---|---|
| webhook-id |
ID der Nachricht (msg_…). Bleibt bei Wiederholungen gleich. Daran erkennst du Duplikate.
|
| webhook-timestamp | Zeitpunkt dieses Zustellversuchs in Unix-Sekunden. |
| webhook-signature |
Eine oder mehrere Signaturen der Form v1,<Base64>, durch Leerzeichen getrennt.
|
So prüfst du eine Nachricht:
- Nimm den Teil des Secrets nach
whsec_und dekodiere ihn aus Base64. Das ist der Schlüssel. - Bilde den Text
webhook-id.webhook-timestamp.Bodyaus den beiden Headern und dem unveränderten Body. - Berechne daraus HMAC-SHA256 mit dem Schlüssel und kodiere das Ergebnis in Base64.
- Vergleiche das Ergebnis in konstanter Zeit mit jeder
v1-Signatur im Header. Passt eine, ist die Nachricht echt.
Verwende den Body genau so, wie er ankommt, nicht neu serialisiertes JSON. Lehne Nachrichten ab, deren Zeitstempel mehr als fünf Minuten abweicht.
import base64, hashlib, hmac, time def verify_webhook(secret: str, headers: dict, body: bytes) -> bool: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] # Nachrichten, die älter als fünf Minuten sind, ablehnen 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() # Während einer Secret-Rotation stehen zwei Signaturen im Header 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"]; // Nachrichten, die älter als fünf Minuten sind, ablehnen 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(); // Während einer Secret-Rotation stehen zwei Signaturen im Header 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); }); }
Statt eigenen Code zu schreiben, kannst du die offiziellen Bibliotheken von Standard Webhooks nutzen, etwa das Paket standardwebhooks für Python und JavaScript.
Mit POST …/{id}/rotate-secret bekommst du ein neues Secret, einmalig in der Antwort. Für 7 Tage tragen Nachrichten zwei Signaturen, mit dem neuen und dem alten Secret. So stellst du ohne Ausfall um.
Payload
Jede Nachricht ist ein JSON-Objekt mit derselben Hülle. Was das Ereignis ausmacht, steht in data.
| Feld | Bedeutung |
|---|---|
| id |
ID der Nachricht, gleich dem Header webhook-id.
|
| event_id |
ID des Ereignisses (evt_…).
|
| type | Art des Ereignisses, siehe unten. |
| timestamp | Wann wir das Ereignis festgestellt haben, in UTC. |
| schema_version |
Version des Aufbaus, derzeit 1.
|
| data | Die Daten des Ereignisses. |
| Ereignistyp | Bedeutung |
|---|---|
| organization.signal.detected |
Ein Monitor hat ein neues Signal gefunden. data.signal hat denselben Aufbau wie ein Signal in signals, dazu kommen data.monitor und unter data.links.signal der Link zum Signal in der API.
|
| organization.signal.sample |
Ein Beispiel aus der echten Historie der Firma, gesendet unter Konto › Monitoring. Aufbau wie oben, dazu data.sample: true.
|
| endpoint.test |
Testnachricht über /test.
|
| endpoint.verification | Challenge beim Verifizieren. |
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}
Eine Nachricht ist höchstens 20 KB groß. Wird ein Signal größer, fehlen details und register_entry, und data.truncated ist true. Das ganze Signal holst du dann über den Link.
Ignoriere Ereignistypen, die du nicht kennst, und antworte trotzdem mit 2xx. So bleibt dein Server stabil, wenn neue Typen dazukommen.
Zustellungen und Wiederholungen
Eine Zustellung gilt als erfolgreich, wenn dein Server innerhalb von 15 Sekunden mit einem 2xx-Status antwortet. Weiterleitungen folgen wir nicht.
Andernfalls versuchen wir es erneut: bis zu 10 Versuche, mit wachsendem Abstand über etwa drei Tage. Schickt dein Server Retry-After, richten wir uns danach.
Antwortet dein Server mit 410, beenden wir die Zustellung und deaktivieren den Webhook-Endpoint.
Scheitern 5 Zustellungen hintereinander endgültig, wird der Webhook-Endpoint mit disabled_failures deaktiviert. Mit POST …/{id}/enable schaltest du ihn wieder ein.
webhook-id. Antworte schnell und verarbeite die Nachricht erst danach.
GET /v1/account/webhook-deliveries zeigt die letzten 50 Zustellungen, mit ?endpoint=wep_… für einen Webhook-Endpoint. GET /v1/account/webhook-events zeigt die letzten 50 Ereignisse.
Eine endgültig fehlgeschlagene Zustellung schickst du mit POST /v1/account/webhook-deliveries/{id}/retry erneut, mit derselben webhook-id und demselben Body. Das geht 30 Tage lang, so lange bewahren wir die Nachrichten auf.
| Status | Bedeutung |
|---|---|
| withheld | Wartet, bis die Prüfung abgerechnet ist. |
| pending | Wartet auf den Versand. |
| in_flight | Wird gerade gesendet. |
| retry_wait | Wartet auf den nächsten Versuch. |
| succeeded | Zugestellt. |
| exhausted | Alle Versuche gescheitert. |
| blocked_endpoint | Der Webhook-Endpoint kann gerade nichts empfangen. |
| cancelled | Abgebrochen, etwa weil die Nachricht abgelaufen ist. |
Aktivieren, deaktivieren, archivieren
POST …/{id}/disable stoppt die Zustellung vorübergehend. Monitore ohne weiteren Webhook-Endpoint pausieren dann mit paused_configuration.
POST …/{id}/enable schaltet einen verifizierten Webhook-Endpoint wieder ein. Pausierte Monitore setzt du danach mit resume fort.
DELETE …/{id} archiviert den Webhook-Endpoint dauerhaft. Zum vorübergehenden Anhalten deaktivierst du ihn besser.
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/disable" \ -H "Authorization: Bearer DEIN_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 DEIN_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 DEIN_ADMIN_TOKEN", "Idempotency-Key": crypto.randomUUID(), }, }); const data = await response.json();
Fehler
Fehler haben denselben Aufbau wie bei account/monitors: ein Code in error, die Erklärung in meta.message. Fehler kosten nichts.
| Status | Ursache | Was du tun kannst |
|---|---|---|
| 400 |
api_key steht in der URL. |
Den Schlüssel im Header x-api-key schicken. |
| 400 |
Die URL ist als Ziel nicht erlaubt, ein Header ist unzulässig, die Höchstzahl ist erreicht oder der Webhook-Endpoint ist nicht aktiv. meta.message nennt den Grund. |
Angabe korrigieren oder zuerst verifizieren. |
| 400 |
Der Idempotency-Key hat ein ungültiges Format. |
Format korrigieren, etwa eine UUID. |
| 401 | Kein oder ein ungültiger API-Schlüssel oder Token. |
Schlüssel im Header x-api-key mitschicken. |
| 403 |
Dem Bearer-Token fehlt die Berechtigung account:read. |
Den API-Schlüssel oder ein Token mit account:read verwenden. |
| 403 | Die E-Mail-Adresse deines Kontos ist noch nicht bestätigt, oder das Konto ist gesperrt. | Den Link aus der Bestätigungsmail öffnen. Ist das Konto gesperrt, hilft der Support. |
| 403 | Der Aufruf braucht das Admin-Token. Ein API-Schlüssel reicht nicht, ein anderes Token auch nicht. |
Mit Authorization: Bearer und dem Admin-Token senden. |
| 403 |
Dem Bearer-Token fehlt die Berechtigung monitoring:manage. |
API-Schlüssel oder Admin-Token verwenden. |
| 404 | Kein Webhook-Endpoint oder keine Zustellung mit dieser ID. | ID prüfen. |
| 409 |
Der Idempotency-Key wurde schon mit anderen Daten verwendet, ist älter als 24 Stunden oder noch in Bearbeitung. |
Neuen Key verwenden oder kurz warten. |
| 422 |
Eine Angabe fehlt oder ist ungültig, etwa name oder url. errors nennt das Feld. |
Angabe korrigieren und mit neuem Key wiederholen. |
| 422 | Das Verifizieren ist gescheitert. | Challenge-Header und 2xx prüfen, dann mit neuem Key wiederholen. |
| 428 |
Der Header Idempotency-Key fehlt. |
Header mitschicken. |
| 429 | Zu viele Anfragen. |
Nach Retry-After wiederholen. |
| 503 | Verifizieren, Testen und erneutes Senden sind vorübergehend nicht verfügbar. | Etwas später wiederholen. |
1{2 "error": "invalid_request",3 "meta": {4 "message": "The endpoint is not active.",5 "request_credit_cost": 0,6 "credits_remaining": 1457 }8}