1 개요
Webhook 이벤트 시스템에 대한 소개입니다.
CRYPTOMENTS는 입금 확정, 출금 상태 변경 등 주요 이벤트가 발생할 때 파트너에게 설정된 Webhook URL로 알림을 전송합니다. 이를 통해 파트너는 자신의 시스템과 CRYPTOMENTS를 동기화할 수 있습니다.
핵심 특징
- 평면(flat) JSON: 모든 필드는 최상위에 위치합니다.
data래퍼가 없습니다. - HMAC-SHA256 서명: 페이로드 안의
signature필드(hex 소문자 64자)로 검증합니다. - 자동 재시도: 전송 실패 시 최대 5회까지 자동 재시도됩니다. (5장 참조)
- 필드 표기: 모든 키는 camelCase 입니다. (
snake_case아님)
요청 모델
CRYPTOMENTS의 Webhook 요청은 다음과 같은 구조를 따릅니다:
- 방법: HTTP POST
- Content-Type: application/json
- 본문: 평면 JSON 오브젝트 1개
- 커스텀 헤더: 없음 — 서명·타임스탬프·이벤트 타입은 모두 본문 필드로 전달됩니다.
- 성공 응답: HTTP 2xx (본문은 무시됩니다)
event_id / event_type / data 중첩 봉투와
X-Signature·X-Timestamp·X-Event-Id 헤더는 실제 구현에 존재하지 않습니다.
현재 구현은 평면 JSON + 본문 signature 필드입니다.
2 설정
Webhook URL 설정 및 관리 방법입니다.
Webhook URL 설정
파트너 콘솔에서 Webhook 설정을 할 수 있습니다.
파트너 콘솔에 로그인합니다.
로 이동합니다.
Webhook 탭에서 콜백 URL을 입력합니다.
"테스트 전송" 버튼을 클릭하여 정상 수신 여부를 확인합니다. 테스트 페이로드는 {"event":"TEST","message":"Webhook test from Cryptoments"} 이며 서명이 포함되지 않습니다 — 연결성 확인 전용입니다.
IP 화이트리스트 (선택사항)
추가 보안을 위해 CRYPTOMENTS의 Webhook 발신 서버 IP를 방화벽 화이트리스트에 등록할 수 있습니다. 발신 IP 목록은 지원팀에 문의하세요.
3 인증
Webhook 요청 서명 검증 방법입니다.
서명 검증 (HMAC-SHA256)
CRYPTOMENTS는 모든 Webhook 페이로드에 HMAC-SHA256 서명을 포함합니다. 서명은 HTTP 헤더가 아니라 본문(JSON)의 signature 필드로 전달됩니다. 수신 서버는 반드시 서명을 검증하여 요청의 출처와 무결성을 확인해야 합니다.
검증 알고리즘
signatureData = partnerId + "|" + transactionHash + "|" + amount + "|" + timestamp signature = HMAC-SHA256(apiSecret, signatureData) → hex 소문자 64자
| 구성 요소 | 값 |
|---|---|
| 서명 대상 문자열 | partnerId|transactionHash|amount|timestamp (파이프 | 구분자, 4개 값 모두 수신한 페이로드의 값 그대로) |
| 키 | 파트너 API Secret (파트너 콘솔에서 발급받은 값) |
| 알고리즘 / 인코딩 | HMAC-SHA256 / hex (소문자, 64자) — Base64 아님 |
| 전달 위치 | 페이로드 최상위 signature 필드 |
transactionHash 가 없을 때: 온체인 TX가 아직 없는 이벤트(출금 요청/승인, 정산 입금 등)에서는
transactionHash 가 빈 문자열 "" 로 실립니다. 서명 계산에도 동일하게 "" 가 사용되므로,
파트너는 수신한 필드를 그대로 이어붙이면 항상 서명을 재현할 수 있습니다. (null 로 바꾸거나 생략하면 안 됩니다.)
서명 관련 페이로드 필드
| 필드 | 설명 | 예시 |
|---|---|---|
partnerId |
파트너 ID (문자열) | "7" |
transactionHash |
온체인 TX 해시. 없으면 빈 문자열 | "0xabc..." / "" |
amount |
거래 수량 (문자열, plain 표기) | "1000.000000" |
timestamp |
발송 시각 Unix 타임스탬프(초, 문자열) | "1754800000" |
signature |
위 4개로 계산한 HMAC-SHA256 hex | "9f2c...e41a" |
검증 구현 (JavaScript)
const crypto = require('crypto'); app.post('/webhook', (req, res) => { const p = req.body; // 평면 JSON (data 래퍼 없음) const secret = process.env.CRYPTOMENTS_API_SECRET; // 서명 대상: partnerId|transactionHash|amount|timestamp const data = [p.partnerId, p.transactionHash, p.amount, p.timestamp].join('|'); const computed = crypto .createHmac('sha256', secret) .update(data) .digest('hex'); // ← hex (Base64 아님) // 타이밍 공격 방지 비교 const ok = computed.length === (p.signature || '').length && crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(p.signature)); if (!ok) { return res.status(401).json({ error: 'Signature mismatch' }); } // eventType 으로 분기 (DEPOSIT_CONFIRMED, WITHDRAWAL_* ...) enqueue(p); res.status(200).json({ success: true }); });
검증 구현 (Python)
import hmac import hashlib from flask import Flask, request app = Flask(__name__) API_SECRET = 'your_partner_api_secret' @app.route('/webhook', methods=['POST']) def webhook(): p = request.get_json() # 평면 JSON # transactionHash 는 없을 때 "" 로 오므로 그대로 사용한다 data = '|'.join([ p['partnerId'], p['transactionHash'], p['amount'], p['timestamp'] ]) computed = hmac.new( API_SECRET.encode(), data.encode(), hashlib.sha256 ).hexdigest() # ← hex if not hmac.compare_digest(computed, p.get('signature', '')): return {'error': 'Signature mismatch'}, 401 return {'success': True}, 200
검증 구현 (Java)
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Map; @PostMapping("/webhook") public ResponseEntity<?> receiveWebhook(@RequestBody Map<String, Object> p) { try { String secret = System.getenv("CRYPTOMENTS_API_SECRET"); String data = p.get("partnerId") + "|" + p.get("transactionHash") + "|" + p.get("amount") + "|" + p.get("timestamp"); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); // hex 소문자 인코딩 — Base64 아님 StringBuilder hex = new StringBuilder(); for (byte b : mac.doFinal(data.getBytes(StandardCharsets.UTF_8))) { hex.append(String.format("%02x", b)); } if (!hex.toString().equals(p.get("signature"))) { return ResponseEntity.status(401).build(); } return ResponseEntity.ok().build(); } catch (Exception e) { return ResponseEntity.status(500).build(); } }
4 이벤트 타입
Webhook으로 전송되는 이벤트는 입금 1종 + 출금 9종입니다.
이벤트 목록
| eventType | 분류 | 발생 시점 |
|---|---|---|
DEPOSIT_CONFIRMED |
입금 | 입금이 확정되었을 때 (HD/외부지갑/소수점 매칭, 원화결제 정산 입금 포함) |
WITHDRAWAL_REQUESTED |
출금 | 출금 요청이 접수되었을 때 |
WITHDRAWAL_APPROVED |
출금 | 출금이 승인되었을 때 (자동/수동) |
WITHDRAWAL_REJECTED |
출금 | 관리자가 출금을 거부했을 때 |
WITHDRAWAL_CANCELLED |
출금 | 출금이 취소되었을 때 (파트너 취소 / 관리자 취소) |
WITHDRAWAL_P2P_PENDING |
출금 | 출금이 P2P(원화 매칭) 경로로 전환되어 매칭 대기에 들어갔을 때 |
WITHDRAWAL_CONFIRMED |
출금 | 출금 TX가 블록체인에서 확인되었을 때 |
WITHDRAWAL_FAILED |
출금 | 출금 처리가 실패했을 때 |
WITHDRAWAL_EXHAUSTED |
출금 | 재시도 상한을 초과하여 더 이상 진행하지 않고 종결됐을 때 |
WITHDRAWAL_COMPLETED |
출금 | P2P 전환 출금의 잔여 처리까지 끝나 최종 종결됐을 때 |
WITHDRAWAL_CONFIRM(끝에 D 없음)은 내부 분류기용 타입이며 파트너 이벤트가 아닙니다.
파트너가 수신하는 출금 이벤트는 위 9종뿐입니다.
페이로드 샘플
아래 이벤트 카드를 클릭하여 JSON 페이로드를 확인하세요. 모든 페이로드는 평면 구조이며 키는 camelCase 입니다.
{{ JSON.stringify(events.deposit, null, 2) }}transactionId— CRYPTOMENTS 내부 입금 ID (숫자)partnerId— 파트너 ID (문자열)userId— 파트너 유저 IDorderId— 파트너 주문 ID. 입금 예약/원화결제/Axim/결제 세션에서 파트너가 넘긴 값. 없으면nullorderCode— CRYPTOMENTS 내부 주문 코드. 없으면nulltransactionHash— 온체인 TX 해시. 온체인 TX가 없는 정산 입금은 빈 문자열""reservedAmount/reservedAmountKrw— 입금 예약 금액(예상). 실제amount와 비교해 부분/초과 입금을 판별할 수 있습니다. 예약이 없으면nulldepositMethod— 입금 방식 (아래 표 참조)tokenKrwPrice/tokenUsdPrice— 적용 단가. 거래 성립 시점의 확정 환율이 우선 사용됩니다krwAmount/usdAmount— 입금액의 KRW/USD 환산 금액timestamp— 발송 시각 Unix 타임스탬프(초, 문자열) — 서명 대상signature— HMAC-SHA256 서명 hex (3장 참조)
HD_WALLET— HD 파생 지갑 주소로 입금EXTERNAL_WALLET— Axim 등 외부 연결 지갑에서 입금DECIMAL_MATCH— 소수점 매칭 방식 입금DIRECT— 파트너 직접 충전MANUAL— 관리자 수동 처리
event, withdrawalId, metadata, feeAmount 키가 없습니다.
{{ JSON.stringify(events.withdrawal, null, 2) }}eventType/event— 같은 값입니다.event는 하위 호환용 중복 키transactionId/withdrawalId— 같은 값(내부 출금 ID)입니다.withdrawalId는 하위 호환용 중복 키fromAddress— 출금 원천인 파트너 MASTER 지갑 주소. MASTER 가 없는 파트너는nullfeeAmount— 현재 항상"0"(실제 수수료가 아직 반영되지 않음)status— 출금 상태값 (예:CONFIRMED)
{{ JSON.stringify(events.requested, null, 2) }}transactionHash가""— 서명 계산에도 동일하게""가 쓰입니다confirmedAt은 아직nullorderId/metadata는 출금 요청 시 파트너가 보냈을 때만 실립니다. 보내지 않으면 값이null입니다
WITHDRAWAL_APPROVED·WITHDRAWAL_REJECTED·WITHDRAWAL_CANCELLED·WITHDRAWAL_P2P_PENDING·WITHDRAWAL_EXHAUSTED·WITHDRAWAL_COMPLETED 에도 동일하게 적용됩니다
— 키 구성은 같고 eventType / event / status 값만 달라집니다.
{{ JSON.stringify(events.failed, null, 2) }}- 페이로드 키 구성은 다른 출금 이벤트와 동일합니다.
eventType/event가WITHDRAWAL_FAILED,status가FAILED로 옵니다 - 실패 사유(reason) 필드는 페이로드에 포함되지 않습니다. 상세 사유는 파트너 콘솔에서 확인하세요
- 실패 후 재시도로 상태가 다시 진행될 수 있으며, 재시도 상한을 넘기면
WITHDRAWAL_EXHAUSTED가 발송됩니다
출금 페이로드 필드 (공통)
모든 출금 이벤트는 아래 키를 이 순서로 담은 평면 JSON 입니다.
| 필드 | 타입 | 설명 |
|---|---|---|
eventType | string | 이벤트 타입 (9종) |
event | string | 하위호환 eventType 과 같은 값 |
withdrawalId | number | 하위호환 transactionId 와 같은 값 |
transactionId | number | 내부 출금 ID |
partnerId | string | 파트너 ID |
userId | string | 파트너 유저 ID |
orderId | string | null | 출금 요청 시 파트너가 보낸 주문 ID. 보내지 않았으면 null |
metadata | string | null | 파트너가 보낸 JSON 문자열을 그대로 반환(pass-through). 서명 대상 아님 |
transactionHash | string | 온체인 TX 해시. 없으면 "" |
fromAddress | string | null | 파트너 MASTER 지갑 주소. MASTER 가 없으면 null |
toAddress | string | null | 수신 주소 |
amount | string | 출금 수량 — 서명 대상 |
currencyType | string | USDT, USDC 등 |
chainType | string | BSC, ETHEREUM, POLYGON, TRON 등 |
status | string | 출금 상태값 |
confirmedAt | string | null | yyyy-MM-dd'T'HH:mm:ss. 미확정이면 null |
feeAmount | string | 현재 항상 "0" |
timestamp | string | 발송 시각 Unix 타임스탬프(초) — 서명 대상 |
signature | string | HMAC-SHA256 hex (소문자 64자) |
tokenKrwPrice / tokenUsdPrice | string | 적용 단가 |
krwAmount / usdAmount | string | KRW/USD 환산 금액 |
withdrawalCode, withdrawal_id, withdrawal_code,
chain_type, failure_reason 등 snake_case 필드는 존재하지 않습니다.
출금 건 식별은 transactionId(내부 ID) 또는 파트너가 보낸 orderId 로 하세요.
5 재시도 정책
Webhook 전송 실패 시 자동 재시도 방식입니다.
이벤트가 발생하면 전송 큐에 적재되고, 전송 배치가 10초 주기로 이를 발송합니다. 2xx 응답을 받지 못하면(타임아웃·연결 실패·4xx/5xx) 최대 5회까지 재시도하며, 재시도 간격은 아래와 같이 증가합니다.
재시도 일정
재시도 대기 건은 1분 주기 배치가 전송 대상으로 되돌리므로, 실제 발송 시각은 위 간격 + 최대 1분 정도 늦어질 수 있습니다.
timestamp 와 signature 도 바뀌지 않습니다. 즉 같은 본문이 여러 번 도착할 수 있습니다 — 6장 멱등 처리를 반드시 구현하세요.
재시도 후 실패 처리
재시도를 모두 소진하면 해당 전송 건은 FAILED 상태로 기록됩니다. 파트너 콘솔에서 실패 건을 확인하고 재전송을 요청할 수 있습니다.
6 멱등성
중복 처리 방지 방법입니다.
재시도나 네트워크 지연으로 동일한 이벤트가 여러 번 전달될 수 있습니다. 페이로드에는 event_id 같은 전용 이벤트 식별자가 없으므로, 수신 서버는 아래 조합을 멱등 키로 사용하세요.
- 기본:
eventType+transactionId— 같은 거래의 같은 이벤트는 1회만 처리 - 더 엄격하게:
eventType+transactionId+timestamp— 재시도 본문은timestamp·signature까지 동일하므로 완전 중복을 잡아냅니다 - 파트너 주문 기준 대사:
orderId(파트너가 요청 시 보낸 경우에만 존재)
멱등성 구현
수신 서버는 다음과 같이 멱등성을 보장해야 합니다:
처리한 모든 이벤트의 (eventType, transactionId)를 UNIQUE 제약과 함께 저장합니다.
Webhook 수신 시 먼저 해당 키가 이미 처리된 것인지 확인합니다.
이미 처리된 이벤트라면 즉시 HTTP 200으로 응답합니다. (에러가 아님 — 에러로 응답하면 재시도가 계속됩니다)
멱등성 구현 예시 (JavaScript)
app.post('/webhook', async (req, res) => { const p = req.body; // 평면 JSON const key = `${p.eventType}:${p.transactionId}`; try { // 1. 서명 검증 (3장 참조) if (!verifySignature(p)) { return res.status(401).json({ error: 'Signature mismatch' }); } // 2. 멱등 키 중복 확인 const existing = await db.webhookEvents.findOne({ event_key: key }); if (existing) { return res.status(200).json({ success: true, message: 'Event already processed' }); } // 3. 이벤트 처리 — 페이로드 최상위 필드를 그대로 사용 await processEvent(p); // 4. 멱등 키 저장 await db.webhookEvents.insertOne({ event_key: key, event_type: p.eventType, transaction_id: p.transactionId, processed_at: new Date() }); res.status(200).json({ success: true }); } catch (error) { console.error(error); res.status(500).json({ error: 'Internal error' }); } });
CREATE UNIQUE INDEX idx_event_key ON webhook_events(event_type, transaction_id);
7 수신 서버 구현
Webhook 수신 서버 구현 가이드입니다.
모범 사례
- 빠른 응답: Webhook 요청을 받으면 즉시 HTTP 200으로 응답하고, 실제 처리는 백그라운드 작업(큐, 메시지 브로커)으로 수행합니다.
- 서명 검증: 모든 요청의 서명을 검증하여 출처를 확인합니다.
- 멱등성:
event_id를 사용하여 중복 처리를 방지합니다. - 로깅: 모든 Webhook 수신, 처리 결과를 기록합니다.
- 에러 처리: 처리 실패 시 재시도 가능하도록 설계합니다.
Node.js (Express) 예시
const express = require('express'); const crypto = require('crypto'); const Bull = require('bull'); const app = express(); const webhookQueue = new Bull('webhooks'); app.use(express.json()); // Webhook 핸들러 app.post('/webhook', async (req, res) => { const signature = req.headers['x-signature']; const timestamp = req.headers['x-timestamp']; const eventId = req.headers['x-event-id']; const body = JSON.stringify(req.body); // 1. 서명 검증 const secret = process.env.WEBHOOK_SECRET; const data = timestamp + '.' + body; const computed = crypto .createHmac('sha256', secret) .update(data) .digest('hex'); if (computed !== signature) { return res.status(401).json({ error: 'Unauthorized' }); } // 2. 중복 확인 const exists = await db.webhookEvents.findOne({ event_id: eventId }); if (exists) { return res.status(200).json({ success: true }); } // 3. 큐에 추가 await webhookQueue.add(req.body, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } }); // 4. 즉시 응답 res.status(200).json({ success: true }); }); // 백그라운드 작업 webhookQueue.process(async (job) => { const { data } = job; const eventId = data.event_id; try { // 이벤트 타입별 처리 switch (data.event_type) { case 'DEPOSIT_CONFIRMED': await handleDepositConfirmed(data.data); break; case 'WITHDRAWAL_CONFIRMED': await handleWithdrawalConfirmed(data.data); break; // ... 다른 이벤트 타입 } // 이벤트 ID 저장 await db.webhookEvents.insertOne({ event_id: eventId, event_type: data.event_type, processed_at: new Date() }); } catch (error) { console.error('Webhook processing error:', error); throw error; // 재시도 트리거 } }); app.listen(3000);
Python (Flask) 예시
from flask import Flask, request, jsonify from celery import Celery import hmac import hashlib import json app = Flask(__name__) celery = Celery(app.name, broker='redis://localhost:6379') WEBHOOK_SECRET = 'your_webhook_secret' @app.route('/webhook', methods=['POST']) def receive_webhook(): signature = request.headers.get('X-Signature') timestamp = request.headers.get('X-Timestamp') event_id = request.headers.get('X-Event-Id') body = request.get_data(as_text=True) # 1. 서명 검증 data = timestamp + '.' + body computed = hmac.new( WEBHOOK_SECRET.encode(), data.encode(), hashlib.sha256 ).hexdigest() if computed != signature: return jsonify({'error': 'Unauthorized'}), 401 # 2. 중복 확인 if db.webhook_events.find_one({'event_id': event_id}): return jsonify({'success': True}), 200 # 3. 비동기 처리 webhook_data = json.loads(body) process_webhook.delay(webhook_data) # 4. 즉시 응답 return jsonify({'success': True}), 200 @celery.task def process_webhook(webhook_data): event_type = webhook_data['event_type'] event_id = webhook_data['event_id'] try: if event_type == 'DEPOSIT_CONFIRMED': handle_deposit_confirmed(webhook_data['data']) elif event_type == 'WITHDRAWAL_CONFIRMED': handle_withdrawal_confirmed(webhook_data['data']) # 이벤트 ID 저장 db.webhook_events.insert_one({ 'event_id': event_id, 'event_type': event_type, 'processed_at': datetime.utcnow() }) except Exception as e: print(f'Error processing webhook: {e}') raise if __name__ == '__main__': app.run()
8 트러블슈팅
일반적인 문제와 해결 방법입니다.
자주 발생하는 문제
| 문제 | 원인 | 해결 방법 |
|---|---|---|
| Webhook 수신 안 됨 | URL 설정 오류, 네트워크 방화벽 | 1. URL이 HTTPS인지 확인 2. 콘솔의 "테스트 전송"으로 연결성 확인 3. 방화벽 설정 검토 |
| 서명 검증 실패 | 잘못된 Secret, 바뀐 타임스탬프 | 1. Webhook Secret 재확인 2. 서명 생성 시 정확한 timestamp 사용 3. request body 인코딩 확인 |
| 타임아웃 (5초 초과) | 서버 응답 지연, 무거운 처리 | 1. 즉시 HTTP 200 응답 후 비동기 처리 2. 동기 처리 로직 제거 3. 데이터베이스 쿼리 최적화 |
| SSL/TLS 오류 | 인증서 만료, 자체 서명 인증서 | 1. SSL 인증서 유효성 확인 2. 인증서 갱신 3. 신뢰할 수 있는 CA 사용 |
| 중복 처리 | 멱등성 미구현 | 1. event_id 저장 로직 추가 2. 중복 이벤트 ID 확인 로직 구현 3. 데이터베이스 트랜잭션 사용 |
| 콘솔에 실패 표시 | 6회 재시도 모두 실패 | 1. 서버 상태 확인 2. 콘솔에서 "수동 재전송" 클릭 3. 로그 분석 |
디버깅 팁
모니터링
Webhook 안정성을 위해 다음을 모니터링하세요:
- 응답 시간: 평균 응답 시간이 5초 이상인 경우 조사
- 에러율: HTTP 5xx 에러 증가 추적
- 재시도 횟수: 재시도 비율이 높으면 서버 점검
- 이벤트 지연: 이벤트 생성 시간과 처리 시간의 차이 모니터링
문제가 있으신가요? 지원팀에 문의하세요.
CRYPTOMENTS Webhook v2.0 © 2026