/v1/account/webhook-endpoints
account/webhook-endpoints
Webhooks: hvor Monitoring sender nye Signals hen, og hvordan du verificerer dem.
Et webhook-endpoint er din servers HTTPS-adresse, som vi sender dine monitors’ nye signaler til. Hver besked er signeret, så du kan tjekke, at den kommer fra os.
Omkostninger og grænser
| Hvad | Kreditter |
|---|---|
| Administrer webhook-endpoints og leveringer | gratis |
Grænse: 30 forespørgsler pr. minut. Opret, opdater og slet: 10 pr. minut. Tjek, test og ny secret: 6 pr. minut.
Hver konto kan have op til 10 webhook-endpoints; arkiverede tæller ikke med. Hvert webhook-endpoint kan bære op til 10 egne headers.
Admin-token eller API-nøgle
Du skal bruge admin-tokenet til at oprette en signeringssecret og til at aktivere, deaktivere eller arkivere webhook-endpoints. Du opretter det under Konto › API-nøgler i afsnittet «For udviklere: administrer nøgler via API’et». Det er gyldigt i ét år, og et nyt erstatter det gamle.
Send admin-tokenet som Authorization: Bearer DIN_ADMIN_TOKEN. Alle andre kald virker også med API-nøglen.
Som ved account/monitors kræver alle skrivekald headeren Idempotency-Key.
Endpoints
| Metode | Sti | Formål | Adgang |
|---|---|---|---|
| GET |
/v1/account/webhook-endpoints |
Alle webhook-endpoints, uden arkiverede. | API-nøgle eller admin-token |
| POST |
/v1/account/webhook-endpoints |
Opret et webhook-endpoint. | Admin-token |
| POST |
/v1/account/webhook-endpoints/{id}/verify |
Verificér. | API-nøgle eller admin-token |
| POST |
/v1/account/webhook-endpoints/{id}/test |
Send en testbesked. | API-nøgle eller admin-token |
| POST |
/v1/account/webhook-endpoints/{id}/rotate-secret |
Ny signeringssecret. | Admin-token |
| POST |
/v1/account/webhook-endpoints/{id}/enable |
Aktivér. | Admin-token |
| POST |
/v1/account/webhook-endpoints/{id}/disable |
Deaktivér. | Admin-token |
| DELETE |
/v1/account/webhook-endpoints/{id} |
Arkivér. | Admin-token |
| GET |
/v1/account/webhook-deliveries |
De seneste 50 leveringer. | API-nøgle eller admin-token |
| POST |
/v1/account/webhook-deliveries/{id}/retry |
Send en mislykket levering igen. | API-nøgle eller admin-token |
| GET |
/v1/account/webhook-events |
De seneste 50 hændelser. | API-nøgle eller admin-token |
Opret et webhook-endpoint
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints" \ -H "Authorization: Bearer DIN_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": "DIN_MODTAGER_TOKEN" } }'
import uuid import requests response = requests.post( "https://handelsregister.ai/api/v1/account/webhook-endpoints", headers={ "Authorization": "Bearer DIN_ADMIN_TOKEN", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "CRM-Sync", "url": "https://hooks.example.com/handelsregister", "headers": { "X-Receiver-Token": "DIN_MODTAGER_TOKEN" } }, ) data = response.json()
const response = await fetch("https://handelsregister.ai/api/v1/account/webhook-endpoints", { method: "POST", headers: { "Authorization": "Bearer DIN_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": "DIN_MODTAGER_TOKEN" } }), }); const data = await response.json();
| Navn | Type | Beskrivelse | Eksempel |
|---|---|---|---|
| Idempotency-KeyHeader | strengpåkrævet | En entydig værdi pr. handling, helst et UUID. Se Idempotency-Key.Grænser: 1 til 128 tegn |
req-20260802-01 |
| nameBody | strengpåkrævet | Et navn til din egen reference.Grænser: 1 til 120 tegn |
CRM-Sync |
| urlBody | streng (uri)påkrævet |
Din servers adresse: offentligt tilgængelig, https på port 443, med et værtsnavn i stedet for en IP-adresse.Grænser: 1 til 2.048 tegn |
https://hooks.example.com/handelsregister |
| headersBody | objektvalgfri | Egne headers, der sendes med hver besked, f.eks. et token til din server.Grænser: højst 10 poster |
{"X-Receiver-Token":"DIN_MODTAGER_TOKEN"} |
Headernavne må ikke starte med webhook- og må ikke overskrive standardheaders som Content-Type eller Host. Værdierne kan kun skrives; senere viser API’et kun navnene.
Svaret indeholder signeringssecret whsec_…. Vi viser den kun denne ene gang. Gem den med det samme.
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}
Et nyt webhook-endpoint har status pending_verification og modtager først beskeder, når det er verificeret. Navn, URL og headers kan ikke ændres senere. Til en ny adresse opretter du et nyt webhook-endpoint.
I svar viser url kun hosten; stien forkorter vi til /….
Verificér
Med POST …/{id}/verify sender vi en signeret besked af typen endpoint.verification til din URL. data.challenge indeholder en tilfældig værdi.
Din server svarer inden for 15 sekunder med en 2xx-status og returnerer værdien i svarheaderen webhook-verification. Webhook-endpointet bliver så 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: DIN_API_NOEGLE" \ -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": "DIN_API_NOEGLE", "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": "DIN_API_NOEGLE", "Idempotency-Key": crypto.randomUUID(), }, }); const data = await response.json();
Lykkes det, svarer API’et med "verified": true, ellers med 422 og "verified": false. Til et nyt forsøg skal du bruge en ny Idempotency-Key.
Hvert webhook-endpoint tillader 6 forsøg pr. time.
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}
Derefter sender POST …/{id}/test en signeret testbesked af typen endpoint.test, når du vil. Svaret viser resultatet af leveringen.
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}
Verificér signaturen
Hver besked er signeret efter den åbne standard Standard Webhooks. Verificér signaturen, før du behandler en besked.
| Header | Betydning |
|---|---|
| webhook-id |
Beskedens ID (msg_…). Det forbliver det samme ved genforsøg. Brug det til at finde dubletter.
|
| webhook-timestamp | Tidspunkt for dette leveringsforsøg i Unix-sekunder. |
| webhook-signature |
En eller flere signaturer på formen v1,<Base64>, adskilt af mellemrum.
|
Sådan verificerer du en besked:
- Tag den del af secret, der står efter
whsec_, og dekodér den fra Base64. Det er nøglen. - Byg teksten
webhook-id.webhook-timestamp.Bodyud fra de to headers og den uændrede body. - Beregn HMAC-SHA256 af den med nøglen, og kodér resultatet i Base64.
- Sammenlign resultatet i konstant tid med hver
v1-signatur i headeren. Matcher én, er beskeden ægte.
Brug body præcis, som den ankommer, ikke genserialiseret JSON. Afvis beskeder, hvis tidsstempel afviger med mere end fem minutter.
import base64, hashlib, hmac, time def verify_webhook(secret: str, headers: dict, body: bytes) -> bool: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] # Afvis beskeder, der er ældre end fem minutter 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() # Under en rotation af secret bærer headeren to signaturer 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"]; // Afvis beskeder, der er ældre end fem minutter 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(); // Under en rotation af secret bærer headeren to signaturer 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); }); }
I stedet for at skrive din egen kode kan du bruge de officielle Standard Webhooks-biblioteker, f.eks. pakken standardwebhooks til Python og JavaScript.
Med POST …/{id}/rotate-secret får du en ny secret, én gang, i svaret. I 7 dage bærer beskeder to signaturer, én med den nye og én med den gamle secret. Sådan skifter du over uden nedetid.
Payload
Hver besked er et JSON-objekt med samme kuvert. Det, der udgør hændelsen, står i data.
| Felt | Betydning |
|---|---|
| id |
Beskedens ID, lig med headeren webhook-id.
|
| event_id |
Hændelsens ID (evt_…).
|
| type | Hændelsens type, se nedenfor. |
| timestamp | Hvornår vi opdagede hændelsen, i UTC. |
| schema_version |
Opbygningens version, i øjeblikket 1.
|
| data | Hændelsens data. |
| Hændelsestype | Betydning |
|---|---|
| organization.signal.detected |
En monitor har fundet et nyt signal. data.signal har samme opbygning som et signal i signals, plus data.monitor og, under data.links.signal, linket til signalet i API’et.
|
| organization.signal.sample |
Et eksempel fra virksomhedens rigtige historik, sendt under Konto › Monitoring. Samme opbygning som ovenfor, plus data.sample: true.
|
| endpoint.test |
Testbesked via /test.
|
| endpoint.verification | Challenge under verificeringen. |
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}
En besked er højst 20 KB. Bliver et signal større, udelades details og register_entry, og data.truncated er true. Du henter så hele signalet via linket.
Ignorer hændelsestyper, du ikke kender, og svar alligevel med 2xx. Sådan forbliver din server stabil, når nye typer kommer til.
Leveringer og genforsøg
En levering regnes som vellykket, når din server svarer inden for 15 sekunder med en 2xx-status. Vi følger ikke redirects.
Ellers prøver vi igen: op til 10 forsøg, med voksende mellemrum over ca. tre dage. Sender din server Retry-After, følger vi det.
Svarer din server med 410, stopper vi leveringen og deaktiverer webhook-endpointet.
Mislykkes 5 leveringer i træk endeligt, deaktiveres webhook-endpointet med disabled_failures. Med POST …/{id}/enable slår du det til igen.
webhook-id. Svar hurtigt, og behandl først beskeden derefter.
GET /v1/account/webhook-deliveries viser de seneste 50 leveringer, med ?endpoint=wep_… for ét webhook-endpoint. GET /v1/account/webhook-events viser de seneste 50 hændelser.
En endeligt mislykket levering sender du igen med POST /v1/account/webhook-deliveries/{id}/retry, med samme webhook-id og samme body. Det virker i 30 dage; så længe gemmer vi beskederne.
| Status | Betydning |
|---|---|
| withheld | Venter, til tjekket er afregnet. |
| pending | Venter på at blive sendt. |
| in_flight | Sendes lige nu. |
| retry_wait | Venter på næste forsøg. |
| succeeded | Leveret. |
| exhausted | Alle forsøg mislykkedes. |
| blocked_endpoint | Webhook-endpointet kan ikke modtage noget lige nu. |
| cancelled | Annulleret, f.eks. fordi beskeden er udløbet. |
Aktivér, deaktivér, arkivér
POST …/{id}/disable stopper leveringen indtil videre. Monitors uden et andet webhook-endpoint sættes så på pause med paused_configuration.
POST …/{id}/enable slår et verificeret webhook-endpoint til igen. Genoptag derefter monitors på pause med resume.
DELETE …/{id} arkiverer webhook-endpointet for altid. Vil du stoppe det et stykke tid, så deaktivér det i stedet.
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/disable" \ -H "Authorization: Bearer DIN_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 DIN_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 DIN_ADMIN_TOKEN", "Idempotency-Key": crypto.randomUUID(), }, }); const data = await response.json();
Fejl
Fejl har samme opbygning som for account/monitors: en kode i error, forklaringen i meta.message. Fejl koster intet.
| Status | Årsag | Hvad du kan gøre |
|---|---|---|
| 400 |
api_key står i URL’en. |
Send nøglen i headeren x-api-key. |
| 400 |
URL’en er ikke tilladt som destination, en header er ikke tilladt, det maksimale antal er nået, eller webhook-endpointet er ikke aktivt. meta.message angiver årsagen. |
Ret værdien, eller verificér først. |
| 400 |
Idempotency-Key har et ugyldigt format. |
Ret formatet, brug f.eks. et UUID. |
| 401 | Ingen eller en ugyldig API-nøgle eller token. |
Send nøglen i headeren x-api-key. |
| 403 |
Bearer-tokenet mangler rettigheden account:read. |
Brug API-nøglen eller et token med account:read. |
| 403 | Din kontos e-mailadresse er endnu ikke bekræftet, eller kontoen er spærret. | Åbn linket fra bekræftelsesmailen. Er kontoen spærret, kan support hjælpe. |
| 403 | Dette kald kræver admin-tokenet. En API-nøgle er ikke nok, og det er et andet token heller ikke. |
Send det med Authorization: Bearer og admin-tokenet. |
| 403 |
Bearer-tokenet mangler rettigheden monitoring:manage. |
Brug API-nøglen eller admin-tokenet. |
| 404 | Intet webhook-endpoint eller nogen levering med dette ID. | Tjek ID’et. |
| 409 |
Idempotency-Key blev allerede brugt med andre data, er ældre end 24 timer eller behandles stadig. |
Brug en ny nøgle, eller vent kort. |
| 422 |
En værdi mangler eller er ugyldig, f.eks. name eller url. errors angiver feltet. |
Ret værdien, og prøv igen med en ny nøgle. |
| 422 | Verificeringen mislykkedes. | Tjek challenge-headeren og 2xx-status, og prøv så igen med en ny nøgle. |
| 428 |
Headeren Idempotency-Key mangler. |
Send headeren. |
| 429 | For mange forespørgsler. |
Prøv igen efter Retry-After. |
| 503 | Verificering, test og gensending er midlertidigt utilgængelige. | Prøv igen lidt senere. |
1{2 "error": "invalid_request",3 "meta": {4 "message": "The endpoint is not active.",5 "request_credit_cost": 0,6 "credits_remaining": 1457 }8}