/v1/account/webhook-endpoints
account/webhook-endpoints
Webhooks: adónde envía Monitoring las nuevas Signals y cómo las verificas.
Un endpoint de webhook es la dirección HTTPS de tu servidor a la que enviamos las nuevas señales de tus monitores. Cada mensaje va firmado, para que puedas comprobar que viene de nosotros.
Costes y límites
| Qué | Créditos |
|---|---|
| Gestionar endpoints de webhook y entregas | gratis |
Límite: 30 solicitudes por minuto. Crear, modificar y eliminar: 10 por minuto. Comprobar, probar y nuevo secreto: 6 por minuto.
Cada cuenta puede tener hasta 10 endpoints de webhook; los archivados no cuentan. Cada endpoint de webhook puede llevar hasta 10 headers propios.
Admin token o clave API
Necesitas el admin token para crear un secreto de firma y para activar, desactivar o archivar endpoints de webhook. Lo creas en Cuenta › Claves API, en el apartado “Para desarrolladores: gestionar claves a través de la API”. Es válido durante un año, y uno nuevo sustituye al anterior.
Envía el admin token como Authorization: Bearer TU_ADMIN_TOKEN. Todas las demás llamadas funcionan también con la clave API.
Como en account/monitors, todas las llamadas de escritura necesitan el header Idempotency-Key.
Endpoints
| Método | Ruta | Para qué | Acceso |
|---|---|---|---|
| GET |
/v1/account/webhook-endpoints |
Todos los endpoints de webhook, sin los archivados. | Clave API o admin token |
| POST |
/v1/account/webhook-endpoints |
Crear un endpoint de webhook. | Admin token |
| POST |
/v1/account/webhook-endpoints/{id}/verify |
Verificar. | Clave API o admin token |
| POST |
/v1/account/webhook-endpoints/{id}/test |
Enviar un mensaje de prueba. | Clave API o admin token |
| POST |
/v1/account/webhook-endpoints/{id}/rotate-secret |
Nuevo secreto de firma. | Admin token |
| POST |
/v1/account/webhook-endpoints/{id}/enable |
Activar. | Admin token |
| POST |
/v1/account/webhook-endpoints/{id}/disable |
Desactivar. | Admin token |
| DELETE |
/v1/account/webhook-endpoints/{id} |
Archivar. | Admin token |
| GET |
/v1/account/webhook-deliveries |
Las últimas 50 entregas. | Clave API o admin token |
| POST |
/v1/account/webhook-deliveries/{id}/retry |
Volver a enviar una entrega fallida. | Clave API o admin token |
| GET |
/v1/account/webhook-events |
Los últimos 50 eventos. | Clave API o admin token |
Crear un endpoint de webhook
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints" \ -H "Authorization: Bearer TU_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": "TU_RECEIVER_TOKEN" } }'
import uuid import requests response = requests.post( "https://handelsregister.ai/api/v1/account/webhook-endpoints", headers={ "Authorization": "Bearer TU_ADMIN_TOKEN", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "CRM-Sync", "url": "https://hooks.example.com/handelsregister", "headers": { "X-Receiver-Token": "TU_RECEIVER_TOKEN" } }, ) data = response.json()
const response = await fetch("https://handelsregister.ai/api/v1/account/webhook-endpoints", { method: "POST", headers: { "Authorization": "Bearer TU_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": "TU_RECEIVER_TOKEN" } }), }); const data = await response.json();
| Nombre | Tipo | Descripción | Ejemplo |
|---|---|---|---|
| Idempotency-KeyHeader | stringobligatorio | Un valor único por operación, idealmente un UUID. Consulta Idempotency-Key.Límites: 1 a 128 caracteres |
req-20260802-01 |
| nameBody | stringobligatorio | Un nombre para tu propia referencia.Límites: 1 a 120 caracteres |
CRM-Sync |
| urlBody | string (uri)obligatorio |
La dirección de tu servidor: accesible públicamente, https en el puerto 443, con un nombre de host en lugar de una dirección IP.Límites: 1 a 2.048 caracteres |
https://hooks.example.com/handelsregister |
| headersBody | objetoopcional | Headers propios que se envían con cada mensaje, como un token para tu servidor.Límites: como máximo 10 entradas |
{"X-Receiver-Token":"TU_RECEIVER_TOKEN"} |
Los nombres de header no pueden empezar por webhook- ni sobrescribir headers estándar como Content-Type o Host. Los valores son de solo escritura; después la API solo muestra los nombres.
La respuesta contiene el secreto de firma whsec_…. Solo lo mostramos esta vez. Guárdalo enseguida.
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}
Un endpoint de webhook nuevo tiene el estado pending_verification y solo recibe mensajes después de verificarse. El nombre, la URL y los headers no se pueden cambiar después. Para una dirección nueva, crea un endpoint de webhook nuevo.
En las respuestas, url solo muestra el host; abreviamos la ruta a /….
Verificar
Con POST …/{id}/verify enviamos a tu URL un mensaje firmado de tipo endpoint.verification. data.challenge contiene un valor aleatorio.
Tu servidor responde en 15 segundos con un estado 2xx y devuelve el valor en el header de respuesta webhook-verification. El endpoint de webhook pasa entonces a 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: TU_CLAVE_API" \ -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": "TU_CLAVE_API", "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": "TU_CLAVE_API", "Idempotency-Key": crypto.randomUUID(), }, }); const data = await response.json();
Si funciona, la API responde con "verified": true; si no, con 422 y "verified": false. Para un nuevo intento necesitas un Idempotency-Key nuevo.
Cada endpoint de webhook permite 6 intentos por hora.
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}
Después, POST …/{id}/test envía cuando quieras un mensaje de prueba firmado de tipo endpoint.test. La respuesta muestra el resultado de la entrega.
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}
Verificar la firma
Cada mensaje va firmado según el estándar abierto Standard Webhooks. Verifica la firma antes de procesar un mensaje.
| Header | Significado |
|---|---|
| webhook-id |
ID del mensaje (msg_…). Se mantiene igual en los reintentos. Úsalo para detectar duplicados.
|
| webhook-timestamp | Momento de este intento de entrega en segundos Unix. |
| webhook-signature |
Una o varias firmas de la forma v1,<Base64>, separadas por espacios.
|
Cómo verificar un mensaje:
- Toma la parte del secreto tras
whsec_y decodifícala de Base64. Esa es la clave. - Construye el texto
webhook-id.webhook-timestamp.Bodya partir de los dos headers y el body sin modificar. - Calcula sobre él HMAC-SHA256 con la clave y codifica el resultado en Base64.
- Compara el resultado en tiempo constante con cada firma
v1del header. Si una coincide, el mensaje es auténtico.
Usa el body exactamente como llega, no un JSON vuelto a serializar. Rechaza los mensajes cuya marca de tiempo se desvíe más de cinco minutos.
import base64, hashlib, hmac, time def verify_webhook(secret: str, headers: dict, body: bytes) -> bool: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] # Rechaza mensajes de más de cinco minutos 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() # Durante una rotación del secreto, el header lleva dos firmas 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"]; // Rechaza mensajes de más de cinco minutos 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(); // Durante una rotación del secreto, el header lleva dos firmas 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); }); }
En lugar de escribir tu propio código, puedes usar las bibliotecas oficiales de Standard Webhooks, como el paquete standardwebhooks para Python y JavaScript.
Con POST …/{id}/rotate-secret obtienes un secreto nuevo, una sola vez, en la respuesta. Durante 7 días los mensajes llevan dos firmas, una con el secreto nuevo y otra con el antiguo. Así cambias sin interrupciones.
Payload
Cada mensaje es un objeto JSON con el mismo sobre. Lo que constituye el evento está en data.
| Campo | Significado |
|---|---|
| id |
ID del mensaje, igual al header webhook-id.
|
| event_id |
ID del evento (evt_…).
|
| type | Tipo del evento, consulta abajo. |
| timestamp | Cuándo detectamos el evento, en UTC. |
| schema_version |
Versión de la estructura, actualmente 1.
|
| data | Los datos del evento. |
| Tipo de evento | Significado |
|---|---|
| organization.signal.detected |
Un monitor ha encontrado una señal nueva. data.signal tiene la misma estructura que una señal en signals, más data.monitor y, en data.links.signal, el enlace a la señal en la API.
|
| organization.signal.sample |
Un ejemplo del historial real de la empresa, enviado desde Cuenta › Monitoring. Misma estructura que arriba, más data.sample: true.
|
| endpoint.test |
Mensaje de prueba mediante /test.
|
| endpoint.verification | Challenge durante la verificación. |
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}
Un mensaje ocupa como máximo 20 KB. Si una señal es más grande, se omiten details y register_entry y data.truncated es true. Obtienes entonces la señal completa mediante el enlace.
Ignora los tipos de evento que no conozcas y responde igualmente con 2xx. Así tu servidor sigue estable cuando se añaden tipos nuevos.
Entregas y reintentos
Una entrega se considera correcta cuando tu servidor responde en 15 segundos con un estado 2xx. No seguimos redirecciones.
Si no, lo volvemos a intentar: hasta 10 intentos, con intervalos crecientes durante unos tres días. Si tu servidor envía Retry-After, lo respetamos.
Si tu servidor responde con 410, dejamos de entregar y desactivamos el endpoint de webhook.
Si fallan definitivamente 5 entregas seguidas, el endpoint de webhook se desactiva con disabled_failures. Con POST …/{id}/enable lo vuelves a activar.
webhook-id. Responde rápido y procesa el mensaje solo después.
GET /v1/account/webhook-deliveries muestra las últimas 50 entregas, con ?endpoint=wep_… para un solo endpoint de webhook. GET /v1/account/webhook-events muestra los últimos 50 eventos.
Una entrega que falló definitivamente la vuelves a enviar con POST /v1/account/webhook-deliveries/{id}/retry, con el mismo webhook-id y el mismo body. Funciona durante 30 días; ese es el tiempo que guardamos los mensajes.
| Estado | Significado |
|---|---|
| withheld | Espera hasta que se haya cobrado la comprobación. |
| pending | Espera a ser enviada. |
| in_flight | Enviándose ahora mismo. |
| retry_wait | Espera el siguiente intento. |
| succeeded | Entregada. |
| exhausted | Todos los intentos fallaron. |
| blocked_endpoint | El endpoint de webhook no puede recibir nada ahora mismo. |
| cancelled | Cancelada, por ejemplo porque el mensaje caducó. |
Activar, desactivar, archivar
POST …/{id}/disable detiene la entrega por ahora. Los monitores sin otro endpoint de webhook se pausan entonces con paused_configuration.
POST …/{id}/enable vuelve a activar un endpoint de webhook verificado. Después reanuda los monitores pausados con resume.
DELETE …/{id} archiva el endpoint de webhook definitivamente. Para detenerlo un tiempo, desactívalo en su lugar.
curl -X POST "https://handelsregister.ai/api/v1/account/webhook-endpoints/wep_01m6b3j1g0cdz98hw8zdefbf52/disable" \ -H "Authorization: Bearer TU_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 TU_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 TU_ADMIN_TOKEN", "Idempotency-Key": crypto.randomUUID(), }, }); const data = await response.json();
Errores
Los errores tienen la misma estructura que en account/monitors: un código en error, la explicación en meta.message. Los errores no cuestan nada.
| Estado | Causa | Qué puedes hacer |
|---|---|---|
| 400 |
api_key está en la URL. |
Envía la clave en el header x-api-key. |
| 400 |
La URL no está permitida como destino, un header no está permitido, se ha alcanzado el número máximo o el endpoint de webhook no está activo. meta.message indica el motivo. |
Corrige el valor o verifica primero. |
| 400 |
El Idempotency-Key tiene un formato no válido. |
Corrige el formato, por ejemplo usa un UUID. |
| 401 | Ninguna clave API ni token, o uno no válido. |
Envía la clave en el header x-api-key. |
| 403 |
Al bearer token le falta el permiso account:read. |
Usa la clave API o un token con account:read. |
| 403 | La dirección de email de tu cuenta aún no está confirmada, o la cuenta está bloqueada. | Abre el enlace del email de confirmación. Si la cuenta está bloqueada, el soporte puede ayudarte. |
| 403 | Esta llamada necesita el admin token. No basta una clave API, ni tampoco otro token. |
Envíalo con Authorization: Bearer y el admin token. |
| 403 |
Al bearer token le falta el permiso monitoring:manage. |
Usa la clave API o el admin token. |
| 404 | Ningún endpoint de webhook o entrega con este ID. | Revisa el ID. |
| 409 |
El Idempotency-Key ya se usó con datos distintos, tiene más de 24 horas o aún se está procesando. |
Usa una clave nueva o espera un momento. |
| 422 |
Falta un valor o no es válido, como name o url. errors nombra el campo. |
Corrige el valor y reintenta con una clave nueva. |
| 422 | La verificación falló. | Revisa el header del challenge y el estado 2xx, y reintenta con una clave nueva. |
| 428 |
Falta el header Idempotency-Key. |
Envía el header. |
| 429 | Demasiadas solicitudes. |
Reintenta tras Retry-After. |
| 503 | Verificar, probar y reenviar no están disponibles temporalmente. | Reintenta un poco más tarde. |
1{2 "error": "invalid_request",3 "meta": {4 "message": "The endpoint is not active.",5 "request_credit_cost": 0,6 "credits_remaining": 1457 }8}