/v1/account/webhook-endpoints
account/webhook-endpoints
웹훅: Monitoring이 새 Signals를 보내는 곳과 이를 검증하는 방법.
웹훅 엔드포인트는 모니터의 새 Signal을 보낼 내 서버의 HTTPS 주소예요. 모든 메시지는 서명되어 있어 저희가 보낸 것인지 확인할 수 있어요.
비용과 한도
| 항목 | 크레딧 |
|---|---|
| 웹훅 엔드포인트와 전달 관리 | 무료 |
한도: 분당 30 요청. 생성, 수정, 삭제: 분당 10. 확인, 테스트, 새 시크릿: 분당 6.
계정마다 최대 10개의 웹훅 엔드포인트를 가질 수 있고, 보관된 것은 세지 않아요. 웹훅 엔드포인트마다 사용자 지정 헤더를 최대 10개 둘 수 있어요.
관리자 토큰 또는 API 키
서명 시크릿을 만들고 웹훅 엔드포인트를 활성화, 비활성화, 보관하려면 관리자 토큰이 필요해요. 계정 › API 키의 “개발자용: API로 키 관리” 영역에서 만들어요. 1년 동안 유효하며, 새 토큰이 이전 토큰을 대체해요.
관리자 토큰은 Authorization: Bearer YOUR_ADMIN_TOKEN으로 보내세요. 나머지 호출은 모두 API 키로도 돼요.
account/monitors와 마찬가지로 모든 쓰기 호출에는 헤더 Idempotency-Key가 필요해요.
엔드포인트
| 메서드 | 경로 | 용도 | 접근 |
|---|---|---|---|
| GET |
/v1/account/webhook-endpoints |
보관된 것을 제외한 모든 웹훅 엔드포인트. | API 키 또는 관리자 토큰 |
| POST |
/v1/account/webhook-endpoints |
웹훅 엔드포인트 생성. | 관리자 토큰 |
| 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 키 또는 관리자 토큰 |
웹훅 엔드포인트 생성
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}
새 웹훅 엔드포인트는 상태가 pending_verification이며, 확인을 마친 뒤에야 메시지를 받아요. 이름, URL, 헤더는 나중에 바꿀 수 없어요. 새 주소에는 새 웹훅 엔드포인트를 만드세요.
응답에서 url은 호스트만 보여 주고, 경로는 /…로 줄여요.
확인
POST …/{id}/verify를 호출하면 endpoint.verification 유형의 서명된 메시지를 URL로 보내요. data.challenge에는 임의의 값이 있어요.
서버는 15초 안에 2xx 상태로 응답하고, 응답 헤더 webhook-verification에 그 값을 돌려줘요. 그러면 웹훅 엔드포인트가 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가 필요해요.
웹훅 엔드포인트마다 시간당 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이 아니라 도착한 본문을 그대로 쓰세요. 타임스탬프가 5분 넘게 차이 나는 메시지는 거부하세요.
import base64, hashlib, hmac, time def verify_webhook(secret: str, headers: dict, body: bytes) -> bool: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] # 5분보다 오래된 메시지는 거부 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"]; // 5분보다 오래된 메시지는 거부 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); }); }
직접 코드를 쓰는 대신 Python과 JavaScript용 패키지 standardwebhooks 같은 공식 Standard Webhooks 라이브러리를 쓸 수 있어요.
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 |
모니터가 새 Signal을 찾았어요. data.signal은 signals의 Signal과 같은 구조이고, data.monitor와 data.links.signal 아래 API의 Signal 링크가 더해져요.
|
| organization.signal.sample |
계정 › Monitoring에서 보낸, 회사의 실제 이력에서 가져온 예시. 위와 같은 구조에 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예요. Signal이 더 크면 details와 register_entry가 빠지고 data.truncated가 true가 돼요. 그때는 링크로 전체 Signal을 가져오세요.
모르는 이벤트 유형은 무시하고 그래도 2xx로 응답하세요. 그래야 새 유형이 추가되어도 서버가 안정적으로 동작해요.
전달과 재시도
서버가 15초 안에 2xx 상태로 응답하면 전달이 성공한 것으로 봐요. 리디렉션은 따라가지 않아요.
그렇지 않으면 다시 시도해요: 약 3일에 걸쳐 간격을 늘려 가며 최대 10번. 서버가 Retry-After를 보내면 그에 따라요.
서버가 410으로 응답하면 전달을 멈추고 웹훅 엔드포인트를 비활성화해요.
전달이 5번 연속으로 최종 실패하면 웹훅 엔드포인트가 disabled_failures로 비활성화돼요. POST …/{id}/enable로 다시 켜세요.
webhook-id로 중복을 알아보세요. 빨리 응답하고, 메시지 처리는 그 뒤에 하세요.
GET /v1/account/webhook-deliveries는 최근 50건의 전달을, ?endpoint=wep_…를 붙이면 한 웹훅 엔드포인트의 전달을 보여 줘요. 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 | 웹훅 엔드포인트가 지금은 아무것도 받을 수 없어요. |
| cancelled | 취소됐어요. 예: 메시지가 만료된 경우. |
활성화, 비활성화, 보관
POST …/{id}/disable은 전달을 당분간 멈춰요. 다른 웹훅 엔드포인트가 없는 모니터는 paused_configuration으로 멈춰요.
POST …/{id}/enable은 인증된 웹훅 엔드포인트를 다시 켜요. 그다음 일시 중지된 모니터를 resume으로 재개하세요.
DELETE …/{id}는 웹훅 엔드포인트를 완전히 보관해요. 잠시 멈추려면 대신 비활성화하세요.
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 |
URL에 api_key가 있어요. |
키를 헤더 x-api-key로 보내세요. |
| 400 |
URL을 대상으로 쓸 수 없거나, 헤더가 허용되지 않거나, 최대 개수에 도달했거나, 웹훅 엔드포인트가 활성 상태가 아니에요. 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의 웹훅 엔드포인트나 전달이 없어요. | 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}