Webhook Integration Guide

입금·출금 알림 Webhook 연동 규격서

등록된 계좌에 입금 또는 출금이 발생하면, 지정하신 URL로 거래 내역을 담은 JSON을 실시간 POST로 전송합니다. 본 문서는 수신 서버를 구현하실 개발자를 위한 규격입니다.

버전 v1이벤트 transaction.created 인코딩 UTF-8 / application/json시간대 KST (UTC+9)

1 연동 흐름

  1. 수신 URL 전달 — 웹훅을 받을 HTTPS 주소를 알려주세요. (예: https://your-server.com/hooks/deposit)
  2. Secret 발급 — 엔드포인트별 서명 검증용 secret을 1회 전달드립니다. 안전하게 보관하세요(재조회 불가, 분실 시 재발급).
  3. 거래 발생 → 전송 — 입출금이 감지되면 서명을 붙여 해당 URL로 POST 합니다.
  4. 검증 후 2xx 응답 — 서명을 검증하고 2xx로 응답하시면 전송 완료로 처리합니다.

2 요청 형식

POST <등록하신 수신 URL>

헤더설명
Content-Typeapplication/json
X-Deposit-Timestamp전송 시각(Unix epoch 초, 10자리). 매 시도마다 새로 발급됩니다.
X-Deposit-Signature본문 위·변조 검증용 HMAC-SHA256 서명(hex). §3에서 반드시 검증하세요.

본문(body)은 §4의 JSON입니다.

3 서명 검증 보안 필수

저희가 보낸 요청인지 확인하려면 모든 요청에서 서명을 검증하세요. 서명 대상 문자열은 {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);
  });

4 페이로드(JSON)

예시

{
  "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
}

필드 상세

필드타입설명
eventstring항상 "transaction.created" (거래 생성).
transaction.idnumber거래 고유 ID. 중복 판별 키로 사용하세요.
transaction.directionstringdeposit 입금 · withdrawal 출금
transaction.amountnumber거래 금액(원, 정수).
transaction.balance_afternumber | null거래 후 잔액(원). 문자에 잔액 표기가 없으면 null.
transaction.counterpartystring | null입금자/출금처 이름. 없으면 null.
transaction.occurred_atstring거래 발생 시각. KST ISO-8601(+09:00). 은행 문자 표기 시각 기준.
account.idnumber수신 계좌 ID(내부).
account.bank_codestring금융결제원 표준 기관코드 3자리(예: "004" 국민, "011" 농협, "081" 하나).
account.bank_namestring은행명.
account.account_numberstring등록된 계좌번호(대시 제외). 은행 마스킹(*)이 포함될 수 있음.
account.account_labelstring계좌 별칭(관리자 지정).
device.idnumber문자를 포착한 기기 ID(내부).
device.phone_numberstring문자를 수신한 휴대폰 번호.
raw_event_idnumber원본 문자 이벤트 ID(내부·문의 시 참조).
delivery_idnumber이 전송 건의 고유 ID. 재시도 시에도 동일하므로 멱등 처리 키로 적합.

5 응답 규약

수신 서버 응답처리
2xx (200~299)성공. 더 이상 재전송하지 않습니다.
그 외 상태코드 / 타임아웃 / 연결오류실패로 간주하고 재시도합니다(§6).

가능한 한 빨리 2xx를 반환하고, 무거운 후처리는 응답 이후 비동기로 수행하시길 권장합니다. 응답 본문은 저희가 사용하지 않습니다(상태코드만 판단).

6 재시도 · 전달 보장

수신 서버 점검 체크리스트

① HTTPS · 공인 인증서   ② 서명 검증(§3)   ③ delivery_id 멱등 저장   ④ 빠른 2xx 응답   ⑤ 재시도 대비 중복 무시

7 언어별 수신 서버 예제

서명 검증(§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);

8 AI 에이전트 연동 프롬프트

수신 서버를 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 중복) 에 대한 테스트.