Webhook Integration Guide
등록된 계좌에 입금 또는 출금이 발생하면, 지정하신 URL로 거래 내역을 담은 JSON을 실시간 POST로 전송합니다. 본 문서는 수신 서버를 구현하실 개발자를 위한 규격입니다.
https://your-server.com/hooks/deposit)2xx로 응답하시면 전송 완료로 처리합니다.POST <등록하신 수신 URL>
| 헤더 | 설명 |
|---|---|
Content-Type | application/json |
X-Deposit-Timestamp | 전송 시각(Unix epoch 초, 10자리). 매 시도마다 새로 발급됩니다. |
X-Deposit-Signature | 본문 위·변조 검증용 HMAC-SHA256 서명(hex). §3에서 반드시 검증하세요. |
본문(body)은 §4의 JSON입니다.
저희가 보낸 요청인지 확인하려면 모든 요청에서 서명을 검증하세요. 서명 대상 문자열은 {X-Deposit-Timestamp}.{요청 본문 원문} 이며, 발급받은 secret으로 HMAC-SHA256을 계산해 hex로 인코딩한 값입니다.
반드시 "원문(raw) 본문"으로 계산하세요. JSON을 파싱한 뒤 다시 직렬화한 문자열로 계산하면 공백·키 순서가 달라져 서명이 어긋납니다.
재생성 공격 방지(권장): X-Deposit-Timestamp가 현재 시각과 크게 벌어지면(예: ±5분 초과) 거부하세요. 타임스탬프는 재시도마다 새로 발급되므로 정상 재시도는 통과합니다.
Node.js (Express) 검증 예시
// 원문 본문이 필요하므로 raw body를 보존한다. const express = require('express'); const crypto = require('crypto'); const app = express(); const SECRET = process.env.DEPOSIT_WEBHOOK_SECRET; // 발급받은 secret app.post('/hooks/deposit', express.raw({ type: 'application/json' }), // raw Buffer 보존 (req, res) => { const ts = req.get('X-Deposit-Timestamp'); const sig = req.get('X-Deposit-Signature'); const raw = req.body.toString('utf8'); // 1) 타임스탬프 신선도 (재생성 방지) if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return res.sendStatus(401); // 2) 서명 = HMAC_SHA256(secret, ts + "." + raw) const expected = crypto.createHmac('sha256', SECRET) .update(ts + '.' + raw).digest('hex'); const ok = sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); if (!ok) return res.sendStatus(401); const payload = JSON.parse(raw); // 3) 멱등 처리 후(§6) 2xx 응답 — upsert by payload.delivery_id ... res.sendStatus(200); });
예시
{
"event": "transaction.created",
"transaction": {
"id": 10294,
"direction": "deposit",
"amount": 500000,
"balance_after": 654371,
"counterparty": "곽지우",
"occurred_at": "2026-07-18T16:28:00+09:00"
},
"account": {
"id": 2,
"bank_code": "011",
"bank_name": "NH농협은행",
"account_number": "3010202198441",
"account_label": "운영1"
},
"device": { "id": 2, "phone_number": "01012345678" },
"raw_event_id": 1041,
"delivery_id": 3387
}
| 필드 | 타입 | 설명 |
|---|---|---|
event | string | 항상 "transaction.created" (거래 생성). |
transaction.id | number | 거래 고유 ID. 중복 판별 키로 사용하세요. |
transaction.direction | string | deposit 입금 · withdrawal 출금 |
transaction.amount | number | 거래 금액(원, 정수). |
transaction.balance_after | number | null | 거래 후 잔액(원). 문자에 잔액 표기가 없으면 null. |
transaction.counterparty | string | null | 입금자/출금처 이름. 없으면 null. |
transaction.occurred_at | string | 거래 발생 시각. KST ISO-8601(+09:00). 은행 문자 표기 시각 기준. |
account.id | number | 수신 계좌 ID(내부). |
account.bank_code | string | 금융결제원 표준 기관코드 3자리(예: "004" 국민, "011" 농협, "081" 하나). |
account.bank_name | string | 은행명. |
account.account_number | string | 등록된 계좌번호(대시 제외). 은행 마스킹(*)이 포함될 수 있음. |
account.account_label | string | 계좌 별칭(관리자 지정). |
device.id | number | 문자를 포착한 기기 ID(내부). |
device.phone_number | string | 문자를 수신한 휴대폰 번호. |
raw_event_id | number | 원본 문자 이벤트 ID(내부·문의 시 참조). |
delivery_id | number | 이 전송 건의 고유 ID. 재시도 시에도 동일하므로 멱등 처리 키로 적합. |
| 수신 서버 응답 | 처리 |
|---|---|
| 2xx (200~299) | 성공. 더 이상 재전송하지 않습니다. |
| 그 외 상태코드 / 타임아웃 / 연결오류 | 실패로 간주하고 재시도합니다(§6). |
가능한 한 빨리 2xx를 반환하고, 무거운 후처리는 응답 이후 비동기로 수행하시길 권장합니다. 응답 본문은 저희가 사용하지 않습니다(상태코드만 판단).
delivery_id(권장) 또는 transaction.id로 이미 처리한 건은 무시하세요. 재도착에도 2xx를 반환하면 됩니다.60초 → 4분 → 16분 → 1시간(상한), 이후 1시간 간격.수신 서버 점검 체크리스트
① HTTPS · 공인 인증서 ② 서명 검증(§3) ③ delivery_id 멱등 저장 ④ 빠른 2xx 응답 ⑤ 재시도 대비 중복 무시
서명 검증(§3)과 멱등 처리(§6)를 포함한 최소 예제입니다. Node.js(Express) 예제는 §3을 참고하세요. 공통 핵심은 파싱 전 "원문 본문(raw body)"으로 서명을 계산하는 것입니다.
Python (Flask)
import hmac, hashlib, time, os from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["DEPOSIT_WEBHOOK_SECRET"].encode() # 발급받은 secret @app.post("/hooks/deposit") def deposit_hook(): raw = request.get_data() # 원문 bytes 보존 ts = request.headers.get("X-Deposit-Timestamp", "") sig = request.headers.get("X-Deposit-Signature", "") # 1) 타임스탬프 신선도 (재생성 방지, ±5분) if not ts.isdigit() or abs(time.time() - int(ts)) > 300: abort(401) # 2) 서명 = HMAC_SHA256(secret, ts + "." + raw) expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, sig): # 상수시간 비교 abort(401) payload = request.get_json() # 3) payload["delivery_id"] 로 멱등 처리 후 2xx 응답 return "", 200
Java (Spring Boot)
import org.springframework.web.bind.annotation.*; import org.springframework.http.ResponseEntity; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.security.MessageDigest; import java.nio.charset.StandardCharsets; @RestController public class DepositWebhookController { private static final byte[] SECRET = System.getenv("DEPOSIT_WEBHOOK_SECRET").getBytes(StandardCharsets.UTF_8); @PostMapping("/hooks/deposit") public ResponseEntity<Void> receive( @RequestBody byte[] raw, // 원문 bytes 보존 @RequestHeader("X-Deposit-Timestamp") String ts, @RequestHeader("X-Deposit-Signature") String sig) throws Exception { // 1) 타임스탬프 신선도 (±5분) long now = System.currentTimeMillis() / 1000L; if (Math.abs(now - Long.parseLong(ts)) > 300) return ResponseEntity.status(401).build(); // 2) 서명 = HMAC_SHA256(secret, ts + "." + raw) Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(SECRET, "HmacSHA256")); mac.update(ts.getBytes(StandardCharsets.UTF_8)); mac.update((byte) '.'); StringBuilder hex = new StringBuilder(); for (byte x : mac.doFinal(raw)) hex.append(String.format("%02x", x)); if (!MessageDigest.isEqual(hex.toString().getBytes(), sig.getBytes())) return ResponseEntity.status(401).build(); // 3) delivery_id 로 멱등 처리 후 2xx 응답 return ResponseEntity.ok().build(); } }
PHP
<?php // 원문 본문을 그대로 읽는다 (파싱 후 재직렬화 금지). $raw = file_get_contents('php://input'); $ts = $_SERVER['HTTP_X_DEPOSIT_TIMESTAMP'] ?? ''; $sig = $_SERVER['HTTP_X_DEPOSIT_SIGNATURE'] ?? ''; $secret = getenv('DEPOSIT_WEBHOOK_SECRET'); // 발급받은 secret // 1) 타임스탬프 신선도 (±5분) if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) { http_response_code(401); exit; } // 2) 서명 = HMAC_SHA256(secret, ts + "." + raw) $expected = hash_hmac('sha256', $ts . '.' . $raw, $secret); if (!hash_equals($expected, $sig)) { // 상수시간 비교 http_response_code(401); exit; } $payload = json_decode($raw, true); // 3) $payload['delivery_id'] 로 멱등 처리 후 2xx 응답 http_response_code(200);
수신 서버를 AI 코딩 에이전트(Claude·ChatGPT·Cursor 등)로 구현하신다면, 아래 프롬프트를 그대로 복사해 붙여넣으세요. 규격이 모두 담겨 있어 스택만 지정하면 됩니다. secret 값 자체는 프롬프트에 넣지 말고 환경변수로 주입하세요.
복사해서 사용하는 프롬프트
아래 규격에 맞는 "입금·출금 알림 Webhook 수신 엔드포인트"를 구현해 주세요.
구현 스택: (여기에 언어/프레임워크를 적으세요. 예: Node.js Express, Python FastAPI, Spring Boot, Laravel)
[전송 방식]
- 외부 서버가 내 HTTPS URL 로 POST 요청을 보냅니다. Content-Type: application/json.
- 요청 헤더:
- X-Deposit-Timestamp : 전송 시각(Unix epoch 초, 10자리 문자열)
- X-Deposit-Signature : HMAC-SHA256(secret, "{timestamp}.{요청 본문 원문}") 을 hex 로 인코딩한 값
- secret 은 환경변수 DEPOSIT_WEBHOOK_SECRET 로 주입됩니다. 코드에 하드코딩하지 마세요.
[서명 검증 — 필수]
1. 프레임워크가 본문을 파싱하기 전에 "원문 바이트(raw body)"를 확보해 서명을 계산합니다. 파싱 후 재직렬화한 문자열로 계산하면 서명이 어긋납니다.
2. 계산한 서명과 X-Deposit-Signature 를 상수시간 비교(timing-safe compare)로 대조하고, 다르면 401 을 반환합니다.
3. X-Deposit-Timestamp 가 현재 시각과 ±300초를 벗어나면 401 을 반환합니다(재생성 공격 방지).
4. 검증을 통과한 뒤에만 본문 JSON 을 파싱합니다.
[본문 JSON 스키마]
{ "event": "transaction.created",
"transaction": { "id": 정수, "direction": "deposit" | "withdrawal", "amount": 정수(원),
"balance_after": 정수 또는 null, "counterparty": 문자열 또는 null,
"occurred_at": "KST ISO-8601, 예 2026-07-18T16:28:00+09:00" },
"account": { "id": 정수, "bank_code": "금융결제원 표준 3자리", "bank_name": 문자열,
"account_number": 문자열, "account_label": 문자열 },
"device": { "id": 정수, "phone_number": 문자열 },
"raw_event_id": 정수, "delivery_id": 정수 }
[처리 규칙]
1. delivery_id (없으면 transaction.id) 를 멱등 키로 사용합니다. 이미 처리한 건이면 다시 저장하지 말고 그대로 2xx 를 반환합니다.
2. 검증·처리·중복 도착 모두 최대한 빨리 2xx 로 응답하고, 무거운 후처리는 응답 이후 비동기로 수행합니다.
3. 동일 건이 재시도로 두 번 이상 도착할 수 있습니다(at-least-once). 2xx 가 아니면 외부 서버가 60초→4분→16분→1시간 간격으로 계속 재시도합니다.
[산출물]
- 위 규격을 만족하는 수신 엔드포인트 코드(선택한 스택 기준).
- 서명 불일치 · 타임스탬프 만료 · 중복 도착(delivery_id 중복) 에 대한 테스트.