CCOINPG /API Reference
API v1OpenAPI ↓HTML 저장 ↓

가맹점 API 명세

가맹점 → COINPG: 회원·자금·거래·지급·웹훅 설정 API
COINPG → 가맹점: 입금·지급 결과 웹훅

접속·공통 인증

운영 Base URL: https://<운영 도메인>/api/v1. 예제의 pay.example.com은 자리표시자입니다. 현재 로컬 주소는 http://127.0.0.1:3000/api/v1이며 외부 가맹점용 운영 주소는 아직 없습니다.

첫 호출: 접속과 인증 확인

가맹점 서버의 Bash 터미널에서 실행합니다. 아래 URL과 API 키를 발급받은 값으로 교체하면 운영자금 조회 결과를 받습니다. 응답 전체는 운영자금 조회에서 확인할 수 있습니다.

cURL · GET /balance
curl --request GET 'https://pay.example.com/api/v1/balance' \
  --header 'x-api-key: <가맹점 API 키>'
필드타입필수설명예시
x-api-keystring필수가맹점 서버에서 전송합니다. 관리자 키·포털 쿠키·Bearer 토큰은 사용하지 않습니다.<가맹점 API 키>
Content-Typestring선택JSON 본문이 있을 때 사용합니다. 웹훅 설정 PUT에서는 필수입니다.application/json
  • 지원 자산은 TRON Mainnet USDT(TRC-20)입니다. 다른 네트워크나 자산은 지원하지 않습니다.
  • 요청 금액은 소수 문자열을 권장합니다. 최대 6자리이며 응답 금액도 문자열입니다. 날짜는 UTC ISO 8601, 값이 없을 때는 null입니다.
  • 가맹점 서버가 회원 잔액·지급 권한을 관리합니다. COINPG는 가맹점 전체 운영자금으로 지급합니다.
  • 아래 성공 JSON은 실제 응답 필드를 모두 포함한 형식 예시입니다. 키·주소·ID·시간은 예시 값으로 바꿔 표시했습니다.

받은 값을 다음 작업에 연결하기

작업호출·확인저장하거나 처리할 값
회원 입금주소 발급PUT /users/{externalUserId}externalUserId는 가맹점 회원 ID입니다. 응답의 deposit_address를 해당 회원과 연결해 저장하고 회원에게 안내합니다.
회원 입금 반영GET /transactions회원이 입금한 뒤 type=deposit, status=confirmed 거래의 external_user_id로 회원을 찾고 id로 중복을 막아 amount를 장부에 한 번 반영합니다.
회원에게 지급 요청POST /payouts주문·요청 본문·Idempotency-Key를 먼저 저장하고 호출합니다. 받은 지급 id를 주문에 저장합니다. HTTP 202는 접수이며 지급 완료가 아닙니다.
지급 결과 확정GET /payouts/{id}저장한 지급 id로 조회합니다. status=confirmed면 완료, failed면 실패로 처리합니다. 결과가 불명확한 요청은 같은 본문·멱등 키로 재확인합니다.

결과 알림을 받으려면 웹훅을 등록합니다. 수신한 입금을 회원 장부에 연결하는 과정은 입금 1건 처리 예제를 참고하세요.

오류 응답 형식

필드타입설명
errorstring사람이 읽을 오류 설명. 문구 대신 HTTP 상태와 code로 분기합니다.
codestring오류 식별자. 각 API의 오류 표를 참고합니다.
예: 회원 조회 인증 누락 · HTTP 401
{
  "error": "B2B 업체 인증이 필요합니다.",
  "code": "USER_REQUEST_FAILED"
}
Node.js 공통 클라이언트 코드

아래 코드를 merchant-api-client.mjs로 저장합니다. Node.js 22.18 이상에서 추가 패키지 없이 사용합니다.

merchant-api-client.mjs
// Node.js 22.18+. Importing this file makes no requests and starts no payouts.
export class CoinpgApiError extends Error {
  constructor(status, code, message) {
    super(message);
    this.name = "CoinpgApiError";
    this.status = status;
    this.code = code;
  }
}

export class CoinpgClient {
  constructor({ baseUrl, apiKey, timeoutMs = 15_000 }) {
    if (!baseUrl || !apiKey) throw new Error("COINPG Base URL and merchant API key are required.");
    const url = new URL(baseUrl);
    const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
    if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) ||
        url.username || url.password || url.search || url.hash ||
        url.pathname.replace(/\/$/u, "") !== "/api/v1") {
      throw new Error("Use an HTTPS Base URL ending in /api/v1 (HTTP is allowed on loopback).");
    }
    this.baseUrl = url.href.replace(/\/$/u, "");
    this.apiKey = apiKey;
    this.timeoutMs = timeoutMs;
  }

  async request(path, { method = "GET", body, idempotencyKey } = {}) {
    if (!path.startsWith("/") || path.startsWith("//")) throw new Error("Use a relative API path.");
    const response = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        "x-api-key": this.apiKey,
        ...(body !== undefined ? { "content-type": "application/json" } : {}),
        ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
      },
      ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
      redirect: "error",
      signal: AbortSignal.timeout(this.timeoutMs),
    });
    let payload;
    try { payload = await response.json(); }
    catch {
      throw new CoinpgApiError(response.status, "INVALID_RESPONSE", "API response is not JSON; reconcile the original request.");
    }
    if (!response.ok) {
      throw new CoinpgApiError(response.status, payload?.code ?? "HTTP_ERROR", payload?.error ?? "API request failed.");
    }
    return payload;
  }

  getBalance() { return this.request("/balance"); }
  getUser(externalUserId) { return this.request(`/users/${encodeURIComponent(externalUserId)}`); }
  createOrGetUser(externalUserId, displayName) {
    return this.request(`/users/${encodeURIComponent(externalUserId)}`, {
      method: "PUT", body: displayName === undefined ? {} : { display_name: displayName },
    });
  }
  listTransactions({ userId, limit, cursor } = {}) {
    const query = new URLSearchParams();
    if (userId !== undefined) query.set("user_id", userId);
    if (limit !== undefined) query.set("limit", String(limit));
    if (cursor !== undefined && cursor !== null) query.set("cursor", cursor);
    return this.request(`/transactions${query.size ? `?${query}` : ""}`);
  }
  requestPayout(body, idempotencyKey) {
    // Reuse the persisted body and key after any ambiguous response.
    if (!idempotencyKey) throw new Error("Persist the payout idempotency key before calling requestPayout.");
    return this.request("/payouts", { method: "POST", body, idempotencyKey });
  }
  getPayout(payoutId) { return this.request(`/payouts/${encodeURIComponent(payoutId)}`); }
}
환경 변수 · 실제 운영 주소와 키로 교체
export COINPG_API_BASE_URL='https://pay.example.com/api/v1'
export COINPG_API_KEY='<가맹점 API 키>'

각 항목의 Node.js 호출 예제는 아래 coinpg 인스턴스를 사용합니다.

공통 초기화
import { CoinpgClient } from './merchant-api-client.mjs';
const coinpg = new CoinpgClient({
  baseUrl: process.env.COINPG_API_BASE_URL,
  apiKey: process.env.COINPG_API_KEY,
});
PUT/api/v1/users/{externalUserId}

회원 생성·입금주소 발급

가맹점의 회원 ID로 회원과 고정 입금주소를 만듭니다. 같은 가맹점에서 같은 ID로 다시 호출하면 저장된 회원과 동일 주소를 반환합니다.

요청 예제

cURL · Bash
curl --request PUT 'https://pay.example.com/api/v1/users/user-1001' \
  --header 'x-api-key: <가맹점 API 키>' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "display_name": "홍길동"
}'

성공 응답 예제

HTTP 200 · application/json
{
  "external_user_id": "user-1001",
  "display_name": "홍길동",
  "status": "active",
  "deposit_address": "<발급된 회원 TRON 입금주소>",
  "network": "tron-mainnet",
  "asset": "USDT",
  "deposited": "0.00",
  "created_at": "2026-09-11T01:00:00.000Z",
  "last_deposit_at": null
}
Node.js 호출 예제

공통 클라이언트의 coinpg 인스턴스를 사용합니다.

Node.js
const user = await coinpg.createOrGetUser('user-1001', '홍길동');
console.log(user.external_user_id, user.deposit_address);

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>
Content-Typestring선택JSON 본문 전송 시 application/json을 사용합니다.application/json

경로 파라미터

필드타입필수설명예시
externalUserIdstring필수가맹점 서비스의 회원 ID. 앞뒤 공백 제거 후 1~128자이며 제어문자는 금지합니다. 경로에 넣을 때 URL 인코딩하세요. 영문·숫자·하이픈·밑줄 사용을 권장합니다.user-1001

요청 본문 · JSON

필드타입필수설명예시
display_namestring선택표시 이름. 앞뒤 공백 제거 후 1~120자, 제어문자 금지. 생략하면 회원 ID의 앞 120자를 사용합니다. null이나 빈 문자열은 허용하지 않습니다.홍길동

응답 파라미터

성공 · HTTP 200

생성되거나 이미 존재하는 회원 객체를 직접 반환합니다. 신규·재요청 모두 HTTP 200이며 data 필드로 감싸지 않습니다. 아래 예제는 아직 입금이 없는 회원입니다.

필드타입설명
external_user_idstring가맹점에서 지정한 회원 ID. 앞뒤 공백이 제거된 값입니다.
display_namestring저장된 회원 표시 이름. 1~120자입니다.
statusstring회원 상태. active 또는 revoked입니다. 지급 요청에는 active 회원이 필요합니다.
deposit_addressstring이 회원에게 발급한 고정 TRON 입금주소입니다. null이 아닙니다.
networkstring고정값 tron-mainnet입니다.
assetstring고정값 USDT입니다.
depositedstring확인된 회원 입금 누계. USDT 단위, 소수점 2~6자리 문자열입니다. 회원의 현재 서비스 잔액이나 지급 가능 금액이 아닙니다.
created_atstring (date-time)회원 생성 시각. UTC ISO 8601 문자열입니다.
last_deposit_atstring (date-time) | null마지막 확인 입금 시각. UTC ISO 8601 문자열이며 확인된 입금이 없으면 null입니다.

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
400REQUEST_FIELD_INVALID회원 ID 또는 display_name의 자료형·길이·문자가 유효하지 않습니다.
400USER_BODY_INVALIDJSON이 잘못되었거나 객체·null 이외의 본문, display_name 이외의 필드를 보냈습니다.
401USER_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다. 폐기된 키와 다른 인증 방식도 허용하지 않습니다.
409CUSTOMER_CREATE_CONFLICT회원 생성 중 경합이 발생했습니다. 같은 회원 ID로 다시 요청하세요.
404CUSTOMER_NOT_FOUND회원 입금지갑을 발급할 활성 회원 또는 해당 가맹점의 대상을 찾지 못했습니다.
409CUSTOMER_WALLET_ALLOCATION_CONFLICT입금주소 할당 중 경합이 발생했습니다. 같은 회원 ID로 재조회하거나 재요청하세요.
500CUSTOMER_WALLET_INVALID저장된 회원지갑이나 주소 검증에 실패했습니다. 운영자에게 확인을 요청하세요.
500CUSTOMER_WALLET_INDEX_FAILED회원 입금주소 번호를 할당하지 못했습니다. 운영자에게 확인을 요청하세요.
500ATOMIC_AMOUNT_INVALID저장된 입금 금액이 유효한 범위를 벗어났습니다. 운영자에게 확인을 요청하세요.
500CUSTOMER_LAST_DEPOSIT_INVALID저장된 최근 입금 시각이 유효하지 않습니다. 운영자에게 확인을 요청하세요.
500 / 503USER_REQUEST_FAILED서버 오류 또는 운영자의 입금주소 발급 설정이 완료되지 않았습니다. 503이면 주소 발급 설정을 운영자에게 확인하세요.

처리 규칙

  • 본문 전체는 선택 사항입니다. 본문 생략, null, 빈 객체 {} 모두 허용하며 display_name 기본값을 적용합니다.
  • 이 API는 기존 회원의 표시 이름을 변경하지 않습니다. 재요청의 display_name이 달라도 이미 저장된 값을 반환합니다.
  • 요청 본문에 merchant_id나 customer_id를 넣지 않습니다. API 키가 가맹점을 식별하며 응답에도 내부 customer_id는 포함되지 않습니다.
  • 응답을 받지 못하면 같은 회원 ID로 다시 호출하세요. 별도 Idempotency-Key 헤더는 필요하지 않습니다.
  • 주소 생성에는 운영자의 입금주소 발급 설정이 필요합니다. 현재 로컬 임시 계정의 빈 화면 확인만으로 실제 입금주소 발급 준비가 완료되지는 않습니다.
  • 입금주소에는 TRON Mainnet의 공식 USDT(TRC-20)를 안내하세요. 예제의 꺾쇠 안 주소는 실제 발급값으로 바꾸는 자리표시자입니다.
GET/api/v1/users/{externalUserId}

회원·입금 누계 조회

가맹점의 회원 ID로 고정 입금주소, 회원 상태, 확인된 누적 입금액을 조회합니다.

요청 예제

cURL · Bash
curl --request GET 'https://pay.example.com/api/v1/users/user-1001' \
  --header 'x-api-key: <가맹점 API 키>'

성공 응답 예제

HTTP 200 · application/json
{
  "external_user_id": "user-1001",
  "display_name": "홍길동",
  "status": "active",
  "deposit_address": "<발급된 회원 TRON 입금주소>",
  "network": "tron-mainnet",
  "asset": "USDT",
  "deposited": "25.00",
  "created_at": "2026-09-11T01:00:00.000Z",
  "last_deposit_at": "2026-09-11T01:30:00.000Z"
}
Node.js 호출 예제

공통 클라이언트의 coinpg 인스턴스를 사용합니다.

Node.js
const user = await coinpg.getUser('user-1001');
console.log(user.deposit_address, user.deposited);

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>

경로 파라미터

필드타입필수설명예시
externalUserIdstring필수가맹점 서비스의 회원 ID. 앞뒤 공백 제거 후 1~128자이며 제어문자는 금지합니다. 경로에 넣을 때 URL 인코딩하세요. 영문·숫자·하이픈·밑줄 사용을 권장합니다.user-1001

요청 본문: 없음.

응답 파라미터

성공 · HTTP 200

회원 객체를 직접 반환합니다. 아래는 25 USDT 입금이 확인된 회원의 전체 응답 예제입니다.

필드타입설명
external_user_idstring가맹점에서 지정한 회원 ID. 앞뒤 공백이 제거된 값입니다.
display_namestring저장된 회원 표시 이름. 1~120자입니다.
statusstring회원 상태. active 또는 revoked입니다. 지급 요청에는 active 회원이 필요합니다.
deposit_addressstring이 회원에게 발급한 고정 TRON 입금주소입니다. null이 아닙니다.
networkstring고정값 tron-mainnet입니다.
assetstring고정값 USDT입니다.
depositedstring확인된 회원 입금 누계. USDT 단위, 소수점 2~6자리 문자열입니다. 회원의 현재 서비스 잔액이나 지급 가능 금액이 아닙니다.
created_atstring (date-time)회원 생성 시각. UTC ISO 8601 문자열입니다.
last_deposit_atstring (date-time) | null마지막 확인 입금 시각. UTC ISO 8601 문자열이며 확인된 입금이 없으면 null입니다.

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
400REQUEST_FIELD_INVALID경로의 회원 ID가 1~128자 일반 문자열이 아닙니다.
401USER_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
404USER_NOT_FOUND현재 가맹점에 해당 회원 ID가 없습니다. 먼저 PUT 회원 생성 API를 호출하세요.
404CUSTOMER_NOT_FOUND회원 입금지갑을 발급할 활성 회원 또는 해당 가맹점의 대상을 찾지 못했습니다.
409CUSTOMER_WALLET_ALLOCATION_CONFLICT입금주소 할당 중 경합이 발생했습니다. 같은 회원 ID로 재조회하거나 재요청하세요.
500CUSTOMER_WALLET_INVALID저장된 회원지갑이나 주소 검증에 실패했습니다. 운영자에게 확인을 요청하세요.
500CUSTOMER_WALLET_INDEX_FAILED회원 입금주소 번호를 할당하지 못했습니다. 운영자에게 확인을 요청하세요.
500ATOMIC_AMOUNT_INVALID저장된 입금 금액이 유효한 범위를 벗어났습니다. 운영자에게 확인을 요청하세요.
500CUSTOMER_LAST_DEPOSIT_INVALID저장된 최근 입금 시각이 유효하지 않습니다. 운영자에게 확인을 요청하세요.
500 / 503USER_REQUEST_FAILED서버 오류 또는 운영자의 입금주소 발급 설정이 완료되지 않았습니다. 503이면 주소 발급 설정을 운영자에게 확인하세요.

처리 규칙

  • 요청 본문과 쿼리 파라미터는 필요하지 않습니다. 가맹점 API 키와 경로의 회원 ID만 보냅니다.
  • 다른 가맹점의 회원은 조회할 수 없습니다. 같은 외부 회원 ID도 API 키가 다른 가맹점이면 별개입니다.
  • deposited는 입금 누계입니다. 가맹점 서비스의 회원 잔액과 지급 허용 금액은 가맹점 장부에서 관리하세요.
  • 입금 건별 중복 반영을 막으려면 거래 목록의 거래 ID를 저장하세요. 누계 차이만으로 입금 건을 식별하지 마세요.
GET/api/v1/balance

가맹점 운영자금 조회

가맹점 운영지갑 주소, 확인된 누적 운영자금, 완료·처리 중 지급 금액과 현재 예약 가능한 금액을 조회합니다.

요청 예제

cURL · Bash
curl --request GET 'https://pay.example.com/api/v1/balance' \
  --header 'x-api-key: <가맹점 API 키>'

성공 응답 예제

HTTP 200 · application/json
{
  "merchant_id": "mer_example",
  "merchant_status": "active",
  "network": "TRON Mainnet",
  "asset": "USDT",
  "operating_wallet": {
    "address": "<가맹점 운영지갑 TRON 주소>",
    "created_at": "2026-09-11T00:00:00.000Z"
  },
  "funded": "100.00",
  "paid": "20.00",
  "reserved": "12.50",
  "unsettled": "5.00",
  "available": "67.50"
}
Node.js 호출 예제

공통 클라이언트의 coinpg 인스턴스를 사용합니다.

Node.js
const balance = await coinpg.getBalance();
console.log(balance.available);

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>

요청 본문: 없음.

응답 파라미터

성공 · HTTP 200

가맹점 전체 운영자금 객체를 직접 반환합니다. 금액은 모두 USDT 문자열이며 회원별 잔액이 아닙니다.

필드타입설명
merchant_idstringAPI 키에 연결된 COINPG 가맹점 ID입니다.
merchant_statusstring가맹점 상태 active 또는 revoked. 일반적으로 활성 가맹점만 인증에 성공하므로 active를 받습니다.
networkstring이 API의 고정값은 TRON Mainnet입니다. 회원·거래·지급의 tron-mainnet과 표기가 다릅니다.
assetstring고정값 USDT입니다.
operating_walletobject가맹점 운영지갑 정보. address와 created_at을 포함하며 null이 아닙니다.
operating_wallet.addressstring가맹점 운영자금을 받는 TRON 주소입니다. 회원별 입금주소와 별개입니다.
operating_wallet.created_atstring (date-time)운영지갑 생성 시각. UTC ISO 8601 문자열입니다.
fundedstring운영지갑 직접 입금 또는 회원주소 수금으로 확정 기록된 누적 운영자금. USDT 소수점 2~6자리 문자열입니다.
paidstringconfirmed 지급의 누적 금액. USDT 소수점 2~6자리 문자열입니다.
reservedstringdispatching, broadcasted, review_required 지급에 예약된 합계. USDT 소수점 2~6자리 문자열입니다.
unsettledstring확인된 회원 입금 합계에서 queued, signing, submitted, review_required, confirmed 수금액을 뺀 값과 0 중 큰 값. USDT 소수점 2~6자리 문자열입니다.
availablestringmax(0, funded - paid - reserved). 새 지급에 예약 가능한 운영자금. USDT 소수점 2~6자리 문자열입니다.

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
401BALANCE_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
404MERCHANT_NOT_FOUND인증된 가맹점의 운영자금 조회 대상을 찾지 못했습니다.
500MERCHANT_OPERATING_WALLET_INVALID운영지갑 주소·파생 정보·시각이 유효하지 않습니다. 운영자에게 확인을 요청하세요.
500MERCHANT_FUNDS_INVALID저장된 운영자금 금액이 유효한 범위를 벗어났습니다. 운영자에게 확인을 요청하세요.
500BALANCE_REQUEST_FAILED운영자금 조회 중 서버 오류가 발생했습니다. 지연 후 재조회하고 지속되면 운영자에게 문의하세요.

처리 규칙

  • 요청 본문·경로 변수·쿼리 파라미터가 없습니다. x-api-key 헤더만으로 가맹점을 식별합니다.
  • available은 조회 시점의 참고값입니다. 동시 요청이 있으므로 지급 접수 때 서버가 운영자금을 다시 검사하고 예약합니다.
  • unsettled는 회원주소의 실시간 체인 잔액이 아닙니다. 수금이 접수되면 감소할 수 있고, 운영자금으로 확정되기 전에는 available에 더해지지 않습니다.
  • 운영지갑 직접 입금은 특정 회원의 deposited나 회원 거래 목록에 추가되지 않습니다.
GET/api/v1/transactions

회원 입금·지급 거래 조회

확인된 회원 입금과 모든 상태의 회원 지급을 최신순으로 조회합니다. 특정 회원을 지정하거나 가맹점 전체 거래를 페이지 단위로 조회할 수 있습니다.

요청 예제

cURL · Bash
curl --request GET 'https://pay.example.com/api/v1/transactions?user_id=user-1001&limit=50' \
  --header 'x-api-key: <가맹점 API 키>'

성공 응답 예제

HTTP 200 · application/json
{
  "transactions": [
    {
      "id": "out_0123456789abcdef0123456789abcdef",
      "external_user_id": "user-1001",
      "type": "payout",
      "amount": "12.50",
      "asset": "USDT",
      "network": "tron-mainnet",
      "status": "dispatching",
      "tx_hash": null,
      "from_address": null,
      "to_address": "<회원이 받을 TRON 주소>",
      "note": "order-1001",
      "created_at": "2026-09-11T02:00:00.000Z",
      "confirmed_at": null
    },
    {
      "id": "dep_example",
      "external_user_id": "user-1001",
      "type": "deposit",
      "amount": "25.00",
      "asset": "USDT",
      "network": "tron-mainnet",
      "status": "confirmed",
      "tx_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
      "from_address": "<입금 송신 TRON 주소>",
      "to_address": "<발급된 회원 TRON 입금주소>",
      "note": "",
      "created_at": "2026-09-11T01:30:02.000Z",
      "confirmed_at": "2026-09-11T01:30:00.000Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}
Node.js 호출 예제

공통 클라이언트의 coinpg 인스턴스를 사용합니다.

Node.js
let cursor;
do {
  const page = await coinpg.listTransactions({ userId: 'user-1001', limit: 50, cursor });
  const deposits = page.transactions.filter(tx => tx.type === 'deposit' && tx.status === 'confirmed');
  console.log(deposits); // 조회만 수행. 장부 반영은 거래 id로 중복 방지.
  cursor = page.has_more ? page.next_cursor : null;
} while (cursor);

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>

쿼리 파라미터

필드타입필수설명예시
user_idstring선택가맹점의 external_user_id 필터. 생략하면 전체 회원을 조회합니다. 지정하면 앞뒤 공백 제거 후 1~128자, 제어문자 금지이며 빈 문자열은 오류입니다.user-1001
limitinteger선택페이지 크기. 1~100의 정수 문자열로 보내며 생략하거나 빈 문자열이면 50입니다. 소수나 지수 표기는 허용하지 않습니다.50
cursorstring선택이전 응답의 next_cursor를 그대로 URL 인코딩하여 전송합니다. 최대 2,048자이며 생략하거나 빈 문자열이면 첫 페이지입니다. API 키의 가맹점과 user_id 필터가 같아야 합니다.<이전 응답의 next_cursor>

요청 본문: 없음.

응답 파라미터

성공 · HTTP 200

transactions 배열과 페이지 정보를 반환합니다. 아래는 지급 1건과 입금 1건이 있고 다음 페이지가 없는 전체 응답 예제입니다. 거래가 없으면 transactions는 빈 배열입니다.

필드타입설명
transactionsarray<object>조회된 거래 목록. 없으면 빈 배열 []입니다. 서버 기록 시각 내림차순, 같은 시각이면 id 내림차순입니다.
transactions[].idstringCOINPG 거래 ID. 회원 원장에 반영한 ID를 저장하여 중복 처리를 막으세요. 지급 거래는 지급 ID를 사용합니다.
transactions[].external_user_idstring이 거래와 연결된 가맹점 회원 ID입니다.
transactions[].typestringdeposit은 회원 입금, payout은 회원 지급입니다.
transactions[].amountstringUSDT 단위 금액. 소수점 2~6자리 문자열입니다.
transactions[].assetstring고정값 USDT입니다.
transactions[].networkstring고정값 tron-mainnet입니다.
transactions[].statusstring입금은 항상 confirmed. 지급은 dispatching, broadcasted, review_required, confirmed, failed 중 하나입니다.
transactions[].tx_hashstring | null블록체인 거래 해시. 지급 해시가 아직 저장되지 않았다면 null입니다.
transactions[].from_addressstring | null입금의 송신 TRON 주소입니다. 현재 지급 항목에서는 항상 null입니다.
transactions[].to_addressstring입금은 회원의 고정 입금주소, 지급은 요청한 수신 TRON 주소입니다.
transactions[].notestring지급 요청의 메모. 입금 또는 메모 없는 지급은 빈 문자열입니다.
transactions[].created_atstring (date-time)서버 기록 시각. UTC ISO 8601 문자열이며 입금의 체인 시각보다 늦을 수 있습니다.
transactions[].confirmed_atstring (date-time) | null입금은 체인 입금 시각, 지급은 성공 확정 시각입니다. 성공 확정 전 지급은 null입니다.
next_cursorstring | null다음 페이지를 요청할 커서. 다음 페이지가 없으면 null입니다. 직접 해석하거나 만들지 마세요.
has_moreboolean다음 페이지가 존재하면 true, 마지막 페이지이면 false입니다.

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
400REQUEST_FIELD_INVALIDuser_id가 빈 값이거나 허용 길이·문자 규칙을 벗어났습니다.
400TRANSACTION_LIMIT_INVALIDlimit이 1~100의 정수 문자열이 아닙니다.
400TRANSACTION_CURSOR_INVALIDcursor가 잘못되었거나 다른 가맹점·다른 회원 필터의 커서를 사용했습니다. 조건을 확인하고 첫 페이지부터 다시 조회하세요.
401TRANSACTION_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
500STORED_AMOUNT_INVALID저장된 거래 금액이 유효하지 않습니다. 운영자에게 확인을 요청하세요.
500TRANSACTION_REQUEST_FAILED거래 조회 중 서버 오류가 발생했습니다. 지연 후 다시 조회하세요.

처리 규칙

  • 요청 본문이 없습니다. 첫 호출에서는 cursor를 생략하고, has_more가 true인 동안 next_cursor를 다음 요청에 전달하세요. 페이지 이동 중 user_id 필터를 바꾸지 마세요.
  • 형식이 유효하지만 존재하지 않는 user_id는 404 대신 빈 배열을 반환합니다.
  • 운영지갑 직접 입금과 회원주소 수금 작업은 이 거래 목록에 포함되지 않습니다.
  • 목록은 생성 시각 순서이며 지급 상태 변경 시각 순서가 아닙니다. 이전에 읽은 지급도 나중에 상태가 바뀌므로 미종료 지급 ID를 별도 조회하세요.
  • 웹훅을 계기로 입금을 확인할 때는 필요한 페이지를 끝까지 읽고 거래 ID·해시·주소와 external_user_id를 대조하세요. 거래 ID를 저장하여 같은 입금을 중복 적립하지 마세요.
  • 지급의 from_address는 현재 null입니다. 수신 주소를 송신 주소로 추정하거나 null을 오류로 취급하지 마세요.
POST/api/v1/payouts

회원 지급 요청

가맹점 운영자금을 예약하고 지정한 회원의 수신 주소로 USDT 지급을 접수합니다. 실제 서명·전송·확정은 비동기 작업으로 처리합니다.

요청 예제

cURL · Bash
curl --request POST 'https://pay.example.com/api/v1/payouts' \
  --header 'x-api-key: <가맹점 API 키>' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: payout-order-20260911-001' \
  --data-raw '{
  "external_user_id": "user-1001",
  "to_address": "<회원이 받을 TRON 주소>",
  "amount": "12.500000",
  "note": "order-1001"
}'

성공 응답 예제

HTTP 202 · application/json
{
  "id": "out_0123456789abcdef0123456789abcdef",
  "external_user_id": "user-1001",
  "to_address": "<회원이 받을 TRON 주소>",
  "amount": "12.50",
  "asset": "USDT",
  "network": "tron-mainnet",
  "status": "dispatching",
  "idempotency_key": "payout-order-20260911-001",
  "tx_hash": null,
  "note": "order-1001",
  "created_at": "2026-09-11T02:00:00.000Z",
  "confirmed_at": null
}
Node.js 호출 예제

공통 클라이언트의 coinpg 인스턴스를 사용합니다.

Node.js
// savedOrder: 호출 전에 가맹점 DB에 저장한 주문·요청 본문·멱등 키
// savePayoutId: 가맹점에서 구현하는 DB 저장 함수
const payout = await coinpg.requestPayout(savedOrder.body, savedOrder.idempotencyKey);
await savePayoutId(savedOrder.id, payout.id);

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>
Content-Typestring선택JSON 본문 전송 시 application/json을 사용합니다.application/json
Idempotency-Keystring필수지급 주문마다 저장하는 멱등 키. 앞뒤 공백 제거 후 8~128자, 첫 글자는 영문·숫자, 이후는 영문·숫자·점·밑줄·하이픈만 허용합니다. 같은 주문 재시도에는 같은 키를 사용합니다.payout-order-20260911-001

요청 본문 · JSON

필드타입필수설명예시
external_user_idstring필수사전에 생성된 해당 가맹점의 active 회원 ID. 앞뒤 공백 제거 후 1~128자, 제어문자 금지입니다.user-1001
to_addressstring필수USDT를 받을 TRON Base58Check 주소. 체크섬 검증을 통과해야 하며 앞뒤 공백은 제거합니다. 회원의 입금주소와 별도로 지정하는 지급 목적지입니다.<회원이 받을 TRON 주소>
amountstring | number필수USDT 금액. 0 초과 1,000,000 이하, 소수점 최대 6자리입니다. 정확한 계산을 위해 소수 문자열을 권장합니다. 음수·지수 표기 문자열은 허용하지 않습니다.12.500000
notestring선택메모. 지정하면 앞뒤 공백 제거 후 1~120자, 제어문자 금지입니다. 생략하면 빈 메모로 저장합니다. null이나 빈 문자열을 보내지 말고 필드를 생략하세요.order-1001

응답 파라미터

성공 · HTTP 202

지급 객체를 직접 반환합니다. 신규 요청의 기본 상태는 dispatching입니다. 같은 키·동일한 정규화 본문을 재요청하면 기존 지급의 현재 상태를 반환하며, 이미 성공·실패한 지급이어도 HTTP 202입니다.

필드타입설명
idstringCOINPG 지급 ID. 가맹점 지급 주문에 저장하고 상태 조회 경로에 사용합니다.
external_user_idstring지급 대상인 가맹점 회원 ID입니다.
to_addressstring지급 요청에서 지정한 수신 TRON 주소입니다.
amountstring지급 금액. USDT 단위 소수점 2~6자리 문자열입니다. 요청의 12.500000은 응답에서 12.50으로 표시될 수 있습니다.
assetstring고정값 USDT입니다.
networkstring고정값 tron-mainnet입니다.
statusstringdispatching: 접수·처리 중, broadcasted: 전파 후 확인 대기, review_required: 결과 확인 필요, confirmed: 성공 확정, failed: 실패 확정입니다.
idempotency_keystring접수 시 사용한 Idempotency-Key. 같은 가맹점의 원래 지급 요청을 식별합니다.
tx_hashstring | null블록체인 거래 해시. 아직 저장되지 않았으면 null입니다. 해시가 있어도 지급 성공이 확정된 것은 아닙니다.
notestring가맹점이 보낸 메모. 요청에서 생략했다면 빈 문자열입니다.
created_atstring (date-time)지급 접수 시각. UTC ISO 8601 문자열입니다.
confirmed_atstring (date-time) | null지급 성공이 확정된 시각. UTC ISO 8601 문자열이며 성공 확정 전에는 null입니다.

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
400PAYOUT_BODY_INVALID본문이 JSON 객체가 아니거나 허용된 4개 필드 이외의 항목을 보냈습니다.
400IDEMPOTENCY_KEY_REQUIREDIdempotency-Key가 없거나 길이·문자 규칙을 만족하지 않습니다.
400REQUEST_FIELD_INVALIDexternal_user_id 또는 note의 자료형·길이·문자가 올바르지 않습니다.
400PAYOUT_ADDRESS_INVALIDto_address가 유효한 TRON 주소가 아닙니다.
400PAYOUT_AMOUNT_INVALIDamount가 없거나 양수·최대 금액·소수점 6자리 제한을 만족하지 않습니다.
401PAYOUT_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
404USER_NOT_FOUND해당 가맹점의 활성 회원이 없습니다. 재시도할 때도 회원이 활성 상태여야 합니다.
404PAYOUT_CUSTOMER_NOT_FOUND접수 과정에서 활성 회원을 다시 확인하지 못했습니다.
404MERCHANT_NOT_FOUND운영지갑을 조회할 가맹점이 없습니다.
404PAYOUT_NOT_FOUND접수한 지급의 최종 조회 대상을 찾지 못했습니다. 새 키로 요청하지 말고 원래 주문을 확인하세요.
409IDEMPOTENCY_KEY_REUSED같은 키로 다른 회원·주소·금액·메모를 보냈습니다. 원래 주문의 본문을 복원하세요.
409MERCHANT_FUNDS_INSUFFICIENT새 지급을 예약할 운영자금이 부족합니다. 자금과 회원 상태를 확인한 뒤 원래 주문으로 재시도하세요.
409MERCHANT_INACTIVE가맹점이 중지되어 새 지급을 접수할 수 없습니다.
500MERCHANT_OPERATING_WALLET_INVALID가맹점 운영지갑 정보가 유효하지 않습니다. 운영자 확인이 필요합니다.
500PAYOUT_RELOAD_FAILED등록한 지급을 다시 읽지 못했습니다. 접수 여부가 불명확하므로 원래 키와 본문을 유지하세요.
500STORED_AMOUNT_INVALID저장된 지급 금액이 유효하지 않습니다. 운영자 확인이 필요합니다.
500PAYOUT_REQUEST_FAILED서버 오류 또는 현재 구현의 JSON 파싱 실패입니다. 빈 본문·잘못된 JSON도 이 코드를 반환합니다. 접수 여부가 불명확하면 원래 키와 본문을 유지하세요.

처리 규칙

  • API 호출 전에 가맹점 DB에 주문 ID, 멱등 키, 원래 본문을 먼저 저장하세요. 응답의 id를 같은 주문에 저장합니다.
  • HTTP 202는 접수 응답입니다. status가 confirmed가 되어야 지급을 성공 처리합니다. dispatching, broadcasted, review_required는 아직 성공·실패가 확정되지 않은 상태입니다.
  • 타임아웃·응답 유실·5xx가 나도 새 멱등 키를 만들지 마세요. 지급 ID가 있으면 조회하고, 없으면 원래 키와 본문으로 재요청합니다.
  • 같은 키는 같은 가맹점의 지급에 적용됩니다. 재시도도 회원의 활성 상태를 검사하므로 이미 중지한 회원의 재요청은 거절될 수 있습니다.
  • COINPG는 가맹점 전체 운영자금을 검사합니다. 회원에게 지급할 권한, 회원 서비스 잔액, 주문 중복 여부는 가맹점이 API 호출 전에 검증해야 합니다.
  • 지급 목적지 자리표시자는 실제 수신자 주소로 교체하세요. 요청에 merchant_id나 customer_id를 추가하면 오류입니다.
  • 실제 처리에는 운영자의 지급 작업과 signer 설정 및 별도 signer 한도가 적용됩니다. 접수 성공만으로 체인 전송까지 보장하지 않습니다.
GET/api/v1/payouts/{id}

지급 상태 조회

지급 요청에서 받은 COINPG 지급 ID로 최신 상태와 거래 해시를 조회합니다. 비동기 지급의 완료 여부는 이 응답으로 확인합니다.

요청 예제

cURL · Bash
curl --request GET 'https://pay.example.com/api/v1/payouts/out_0123456789abcdef0123456789abcdef' \
  --header 'x-api-key: <가맹점 API 키>'

성공 응답 예제

HTTP 200 · application/json
{
  "id": "out_0123456789abcdef0123456789abcdef",
  "external_user_id": "user-1001",
  "to_address": "<회원이 받을 TRON 주소>",
  "amount": "12.50",
  "asset": "USDT",
  "network": "tron-mainnet",
  "status": "confirmed",
  "idempotency_key": "payout-order-20260911-001",
  "tx_hash": "89abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567",
  "note": "order-1001",
  "created_at": "2026-09-11T02:00:00.000Z",
  "confirmed_at": "2026-09-11T02:01:00.000Z"
}
Node.js 호출 예제

공통 클라이언트의 coinpg 인스턴스를 사용합니다.

Node.js
// savedOrder: 지급 ID 저장 후 가맹점 DB에서 다시 읽은 주문
const payout = await coinpg.getPayout(savedOrder.payoutId);
console.log(payout.status); // confirmed 또는 failed일 때만 주문 종료

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>

경로 파라미터

필드타입필수설명예시
idstring필수지급 생성 응답의 id. 가맹점 주문 ID나 Idempotency-Key가 아닙니다. 앞뒤 공백 제거 후 1~180자, 제어문자 금지이며 URL 인코딩하여 사용합니다.out_0123456789abcdef0123456789abcdef

요청 본문: 없음.

응답 파라미터

성공 · HTTP 200

해당 가맹점의 지급 객체를 직접 반환합니다. 아래는 지급 성공이 확정된 전체 응답 예제이며 필드는 접수 응답과 같습니다.

필드타입설명
idstringCOINPG 지급 ID. 가맹점 지급 주문에 저장하고 상태 조회 경로에 사용합니다.
external_user_idstring지급 대상인 가맹점 회원 ID입니다.
to_addressstring지급 요청에서 지정한 수신 TRON 주소입니다.
amountstring지급 금액. USDT 단위 소수점 2~6자리 문자열입니다. 요청의 12.500000은 응답에서 12.50으로 표시될 수 있습니다.
assetstring고정값 USDT입니다.
networkstring고정값 tron-mainnet입니다.
statusstringdispatching: 접수·처리 중, broadcasted: 전파 후 확인 대기, review_required: 결과 확인 필요, confirmed: 성공 확정, failed: 실패 확정입니다.
idempotency_keystring접수 시 사용한 Idempotency-Key. 같은 가맹점의 원래 지급 요청을 식별합니다.
tx_hashstring | null블록체인 거래 해시. 아직 저장되지 않았으면 null입니다. 해시가 있어도 지급 성공이 확정된 것은 아닙니다.
notestring가맹점이 보낸 메모. 요청에서 생략했다면 빈 문자열입니다.
created_atstring (date-time)지급 접수 시각. UTC ISO 8601 문자열입니다.
confirmed_atstring (date-time) | null지급 성공이 확정된 시각. UTC ISO 8601 문자열이며 성공 확정 전에는 null입니다.

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
400REQUEST_FIELD_INVALID경로의 지급 ID가 1~180자 일반 문자열이 아닙니다.
401PAYOUT_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
404PAYOUT_NOT_FOUND지급 ID가 없거나 현재 가맹점의 지급이 아닙니다. 접수 응답의 ID와 API 키를 확인하세요.
500STORED_AMOUNT_INVALID저장된 지급 금액이 유효하지 않습니다. 운영자에게 확인을 요청하세요.
500PAYOUT_REQUEST_FAILED지급 조회 중 서버 오류가 발생했습니다. 원래 지급 ID로 지연 후 다시 조회하세요.

처리 규칙

  • 요청 본문과 쿼리 파라미터가 없습니다. Idempotency-Key 헤더도 상태 조회에는 필요하지 않습니다.
  • confirmed일 때 성공, failed일 때 실패를 가맹점 장부에 한 번만 반영하세요. 실패 상태가 확정되면 COINPG의 운영자금 예약은 해제됩니다.
  • review_required 상태는 예약을 유지한 채 결과를 확인하는 상태입니다. 별도의 지급을 만들어 다시 송금하지 말고 원래 ID로 조회하며, 장기 지연 시 운영자에게 문의하세요.
  • 거래 해시가 있거나 웹훅이 도착했다는 사실만으로 성공 처리하지 마세요. 지급 상태를 조회해 확인합니다.
  • 웹훅 누락에 대비해 아직 종료되지 않은 지급 ID를 주기적으로 재조회하세요. 조회 실패를 지급 실패로 간주하지 마세요.
GET/api/v1/webhook

웹훅 설정 조회

현재 가맹점의 수신 URL, 구독 이벤트, 설정 버전을 조회합니다. 미등록 상태도 HTTP 200을 반환합니다.

요청 예제

cURL · Bash
curl --request GET 'https://pay.example.com/api/v1/webhook' \
  --header 'x-api-key: <가맹점 API 키>'

성공 응답 예제

HTTP 200 · application/json
{
  "webhook": {
    "url": "https://merchant.example.com/webhooks/coinpg",
    "enabled": true,
    "event_types": [
      "deposit.confirmed",
      "payout.confirmed",
      "payout.failed"
    ],
    "key_id": "whk_1",
    "version": 1,
    "deliver_after": "2026-09-11T09:00:00.000Z",
    "created_at": "2026-09-11T09:00:00.000Z",
    "updated_at": "2026-09-11T09:00:00.000Z",
    "rotated_at": null,
    "disabled_at": null
  },
  "supported_event_types": [
    "deposit.confirmed",
    "payout.confirmed",
    "payout.failed"
  ]
}
Node.js 호출 예제

공통 클라이언트의 coinpg 인스턴스를 사용합니다.

Node.js
const result = await coinpg.request('/webhook');
console.log(result.webhook);

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>

요청 본문: 없음.

응답 파라미터

성공 · HTTP 200

등록된 설정은 webhook 객체로 반환합니다. 서명 비밀키는 조회 응답에 포함하지 않습니다.

필드타입설명
webhookobject | null현재 설정입니다. 미등록이면 null이며 아래 webhook 하위 필드는 없습니다.
webhook.urlstring등록된 웹훅 수신 URL입니다.
webhook.enabledboolean가맹점의 웹훅 사용 여부입니다. true여도 운영자의 발송 설정이 완료되어야 실제 전달됩니다.
webhook.event_typesstring[]구독 이벤트 목록입니다. deposit.confirmed, payout.confirmed, payout.failed 중 선택하며 중복은 제거됩니다.
webhook.key_idstring서명 검증 키 식별자입니다. whk_1처럼 표시되며 설정 version과 별개입니다.
webhook.versioninteger설정 버전입니다. 기존 설정을 수정할 때 expected_version에 전달합니다.
webhook.deliver_afterstring (date-time)현재 설정의 전달 대상 시작 시각입니다. 설정을 등록하거나 실제 변경하면 해당 시각으로 갱신됩니다.
webhook.created_atstring (date-time)최초 등록 시각입니다. UTC ISO 8601 형식입니다.
webhook.updated_atstring (date-time)마지막 설정 변경 또는 비밀키 교체 시각입니다.
webhook.rotated_atstring (date-time) | null마지막 비밀키 교체 시각입니다. 교체 전에는 null입니다.
webhook.disabled_atstring (date-time) | null비활성화 시각입니다. 활성 상태에서는 null입니다.
supported_event_typesstring[]서버가 지원하는 세 이벤트 전체입니다. 현재 구독 목록과는 별개입니다.

아직 등록하지 않은 경우 · HTTP 200

미등록은 오류가 아닙니다. PUT으로 최초 등록할 수 있습니다.

성공 응답 · JSON
{
  "webhook": null,
  "supported_event_types": [
    "deposit.confirmed",
    "payout.confirmed",
    "payout.failed"
  ]
}

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
401WEBHOOK_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
400WEBHOOK_URL_INVALID저장된 수신 URL이 현재 URL 검사 규칙에 맞지 않습니다.
400WEBHOOK_URL_UNSAFE수신 URL이 공개 HTTPS 주소 규칙에 맞지 않습니다.
400WEBHOOK_URL_LOOPBACK_DISABLED로컬 HTTP URL 사용이 허용되지 않은 환경입니다.
500WEBHOOK_CONFIG_CORRUPT저장된 설정 또는 이벤트 목록이 올바르지 않습니다. 운영자 확인이 필요합니다.
500WEBHOOK_REQUEST_FAILED설정을 조회하지 못했습니다. 잠시 후 다시 조회하고 지속되면 운영자에게 문의합니다.

처리 규칙

  • 기존 설정을 수정할 때는 webhook.version 값을 PUT 본문의 expected_version으로 보냅니다.
  • signing_secret은 최초 등록 또는 비밀키 교체 응답에서만 받을 수 있습니다.
PUT/api/v1/webhook

웹훅 등록·수정

가맹점당 하나의 수신 URL과 구독 이벤트를 등록합니다. 최초 등록은 201, 기존 설정 저장은 200을 반환합니다.

요청 예제

cURL · Bash
curl --request PUT 'https://pay.example.com/api/v1/webhook' \
  --header 'x-api-key: <가맹점 API 키>' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "url": "https://merchant.example.com/webhooks/coinpg",
  "event_types": [
    "deposit.confirmed",
    "payout.confirmed",
    "payout.failed"
  ],
  "enabled": true
}'

성공 응답 예제

HTTP 201 · application/json
{
  "created": true,
  "changed": true,
  "config": {
    "url": "https://merchant.example.com/webhooks/coinpg",
    "enabled": true,
    "event_types": [
      "deposit.confirmed",
      "payout.confirmed",
      "payout.failed"
    ],
    "key_id": "whk_1",
    "version": 1,
    "deliver_after": "2026-09-11T09:00:00.000Z",
    "created_at": "2026-09-11T09:00:00.000Z",
    "updated_at": "2026-09-11T09:00:00.000Z",
    "rotated_at": null,
    "disabled_at": null
  },
  "signing_secret": "<발급된 signing_secret>"
}

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>
Content-Typestring필수JSON 본문 전송 시 application/json을 사용합니다.application/json

요청 본문 · JSON

필드타입필수설명예시
urlstring필수매 요청에 필요한 공개 HTTPS 443 수신 URL입니다. 앞뒤 공백 제거 후 최대 2048자, 경로 최대 1024자이며 인증정보·쿼리·fragment는 허용하지 않습니다.https://merchant.example.com/webhooks/coinpg
event_typesstring[] | null선택deposit.confirmed, payout.confirmed, payout.failed 중 선택합니다. 빈 배열은 허용하지 않으며 생략 또는 null이면 deposit.confirmed만 구독합니다.["deposit.confirmed", "payout.confirmed", "payout.failed"]
enabledboolean선택웹훅 사용 여부입니다. 생략하면 true입니다.true
expected_versioninteger수정 시 필수최초 등록에서는 생략합니다. 기존 설정 저장에서는 GET으로 받은 현재 version을 반드시 보냅니다. 1 이상의 안전한 정수입니다.1

응답 파라미터

성공 · HTTP 201

최초 등록은 created=true, changed=true입니다. 이때 반환되는 signing_secret을 수신 서버에 안전하게 저장합니다.

필드타입설명
createdboolean최초 등록이면 true, 기존 설정 저장이면 false입니다.
changedboolean설정이 실제로 생성되거나 변경되면 true입니다. 기존 설정과 같은 값이면 false입니다.
configobject저장된 전체 설정입니다.
config.urlstring등록된 웹훅 수신 URL입니다.
config.enabledboolean가맹점의 웹훅 사용 여부입니다. true여도 운영자의 발송 설정이 완료되어야 실제 전달됩니다.
config.event_typesstring[]구독 이벤트 목록입니다. deposit.confirmed, payout.confirmed, payout.failed 중 선택하며 중복은 제거됩니다.
config.key_idstring서명 검증 키 식별자입니다. whk_1처럼 표시되며 설정 version과 별개입니다.
config.versioninteger설정 버전입니다. 기존 설정을 수정할 때 expected_version에 전달합니다.
config.deliver_afterstring (date-time)현재 설정의 전달 대상 시작 시각입니다. 설정을 등록하거나 실제 변경하면 해당 시각으로 갱신됩니다.
config.created_atstring (date-time)최초 등록 시각입니다. UTC ISO 8601 형식입니다.
config.updated_atstring (date-time)마지막 설정 변경 또는 비밀키 교체 시각입니다.
config.rotated_atstring (date-time) | null마지막 비밀키 교체 시각입니다. 교체 전에는 null입니다.
config.disabled_atstring (date-time) | null비활성화 시각입니다. 활성 상태에서는 null입니다.
signing_secretstring | null최초 등록에서만 반환하는 서명 비밀키입니다. 32바이트를 패딩 없는 base64url 문자열로 인코딩한 값이며, 기존 설정 저장에서는 null입니다.

기존 설정 수정 요청

요청 본문 · JSON
{
  "url": "https://merchant.example.com/webhooks/coinpg",
  "event_types": [
    "deposit.confirmed",
    "payout.confirmed"
  ],
  "enabled": true,
  "expected_version": 1
}

기존 설정을 변경한 경우 · HTTP 200

설정 version은 증가하지만 서명 key_id는 그대로입니다. 비밀키는 다시 반환하지 않습니다.

성공 응답 · JSON
{
  "created": false,
  "changed": true,
  "config": {
    "url": "https://merchant.example.com/webhooks/coinpg",
    "enabled": true,
    "event_types": [
      "deposit.confirmed",
      "payout.confirmed"
    ],
    "key_id": "whk_1",
    "version": 2,
    "deliver_after": "2026-09-11T10:00:00.000Z",
    "created_at": "2026-09-11T09:00:00.000Z",
    "updated_at": "2026-09-11T10:00:00.000Z",
    "rotated_at": null,
    "disabled_at": null
  },
  "signing_secret": null
}

현재 설정과 같은 값을 저장한 경우 · HTTP 200

현재 version을 올바르게 전달했고 내용도 같다면 변경 없이 반환합니다.

성공 응답 · JSON
{
  "created": false,
  "changed": false,
  "config": {
    "url": "https://merchant.example.com/webhooks/coinpg",
    "enabled": true,
    "event_types": [
      "deposit.confirmed",
      "payout.confirmed",
      "payout.failed"
    ],
    "key_id": "whk_1",
    "version": 1,
    "deliver_after": "2026-09-11T09:00:00.000Z",
    "created_at": "2026-09-11T09:00:00.000Z",
    "updated_at": "2026-09-11T09:00:00.000Z",
    "rotated_at": null,
    "disabled_at": null
  },
  "signing_secret": null
}

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
400WEBHOOK_BODY_INVALID올바른 JSON 객체가 아니거나 허용되지 않은 필드를 보냈거나 enabled가 boolean이 아닙니다.
400WEBHOOK_URL_INVALIDURL 누락, 문자열·길이·절대 URL 형식 오류 또는 금지된 인증정보·쿼리·fragment가 있습니다.
400WEBHOOK_URL_UNSAFE공개 HTTPS 443 수신 주소 규칙에 맞지 않습니다.
400WEBHOOK_URL_LOOPBACK_DISABLED개발용 로컬 HTTP 수신 주소가 허용되지 않았습니다.
400WEBHOOK_EVENT_TYPES_INVALID이벤트 배열이 비어 있거나 지원하지 않는 이벤트가 포함되었습니다.
400WEBHOOK_VERSION_INVALIDexpected_version은 1 이상의 안전한 정수여야 합니다.
401WEBHOOK_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
404WEBHOOK_MERCHANT_NOT_FOUND등록 대상인 활성 가맹점을 찾지 못했습니다.
409WEBHOOK_VERSION_CONFLICT최초 등록에 expected_version을 보냈거나, 기존 설정에 현재 version을 보내지 않았거나, 동시 변경이 발생했습니다.
415WEBHOOK_CONTENT_TYPE_INVALIDContent-Type: application/json 헤더가 필요합니다.
500WEBHOOK_CONFIG_CORRUPT저장된 설정 또는 이벤트 목록이 올바르지 않습니다. 운영자 확인이 필요합니다.
500WEBHOOK_CONFIG_RELOAD_FAILED저장 후 설정을 다시 읽지 못했습니다. GET으로 현재 결과를 확인합니다.
500WEBHOOK_REQUEST_FAILED설정을 저장하지 못했거나 결과가 불명확합니다. GET으로 현재 설정을 확인합니다.
503WEBHOOK_MASTER_SECRET_INVALID운영자의 웹훅 서명 설정이 준비되지 않았거나 올바르지 않습니다.
503WEBHOOK_MASTER_KEY_MISMATCH운영자의 서명 설정이 등록 당시 설정과 다릅니다. 운영자 확인이 필요합니다.

처리 규칙

  • 부분 수정이 아니라 전체 설정을 보내는 방식입니다. 기존 event_types와 enabled를 유지하려면 함께 보냅니다. 알 수 없는 본문 필드는 거부합니다.
  • 수신 도메인을 운영자가 허용하고 발송 기능을 켜야 실제 전달됩니다. 등록 성공만으로 발송 준비가 끝난 것은 아닙니다.
  • 최초 응답을 잃어 signing_secret을 저장하지 못했다면 GET으로 등록 여부를 확인하고 비밀키 교체 API로 새 값을 발급받습니다.
  • 실제 설정 변경은 전달 시작 시각을 변경 시점으로 옮기고 기존 대기·재시도 전달을 취소합니다. 이미 전송 중인 요청은 도착할 수 있으며 변경 전후 거래를 API로 대조해야 합니다.
DELETE/api/v1/webhook

웹훅 비활성화

웹훅을 비활성화합니다. 설정과 서명 키를 물리적으로 삭제하지 않으므로 이후 PUT으로 다시 활성화할 수 있습니다.

요청 예제

cURL · Bash
curl --request DELETE 'https://pay.example.com/api/v1/webhook' \
  --header 'x-api-key: <가맹점 API 키>'

성공 응답 예제

HTTP 200 · application/json
{
  "changed": true,
  "config": {
    "url": "https://merchant.example.com/webhooks/coinpg",
    "enabled": false,
    "event_types": [
      "deposit.confirmed",
      "payout.confirmed",
      "payout.failed"
    ],
    "key_id": "whk_1",
    "version": 2,
    "deliver_after": "2026-09-11T10:00:00.000Z",
    "created_at": "2026-09-11T09:00:00.000Z",
    "updated_at": "2026-09-11T10:00:00.000Z",
    "rotated_at": null,
    "disabled_at": "2026-09-11T10:00:00.000Z"
  }
}
Node.js 호출 예제

공통 클라이언트의 coinpg 인스턴스를 사용합니다.

Node.js
const result = await coinpg.request('/webhook', { method: 'DELETE' });
console.log(result.changed);

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>

요청 본문: 없음.

응답 파라미터

성공 · HTTP 200

활성 상태에서 비활성화되면 changed=true입니다. 이미 비활성이거나 미등록이면 changed=false입니다.

필드타입설명
changedboolean이번 요청에서 비활성화가 실제로 이루어졌는지 나타냅니다.
configobject | null비활성 설정입니다. 미등록이면 null이며 config 하위 필드는 없습니다.
config.urlstring등록된 웹훅 수신 URL입니다.
config.enabledboolean가맹점의 웹훅 사용 여부입니다. true여도 운영자의 발송 설정이 완료되어야 실제 전달됩니다.
config.event_typesstring[]구독 이벤트 목록입니다. deposit.confirmed, payout.confirmed, payout.failed 중 선택하며 중복은 제거됩니다.
config.key_idstring서명 검증 키 식별자입니다. whk_1처럼 표시되며 설정 version과 별개입니다.
config.versioninteger설정 버전입니다. 기존 설정을 수정할 때 expected_version에 전달합니다.
config.deliver_afterstring (date-time)현재 설정의 전달 대상 시작 시각입니다. 설정을 등록하거나 실제 변경하면 해당 시각으로 갱신됩니다.
config.created_atstring (date-time)최초 등록 시각입니다. UTC ISO 8601 형식입니다.
config.updated_atstring (date-time)마지막 설정 변경 또는 비밀키 교체 시각입니다.
config.rotated_atstring (date-time) | null마지막 비밀키 교체 시각입니다. 교체 전에는 null입니다.
config.disabled_atstring (date-time) | null비활성화 시각입니다. 활성 상태에서는 null입니다.

등록된 설정이 없는 경우 · HTTP 200

미등록이어도 정상 응답입니다.

성공 응답 · JSON
{
  "changed": false,
  "config": null
}

이미 비활성화한 경우 · HTTP 200

설정을 다시 변경하지 않습니다.

성공 응답 · JSON
{
  "changed": false,
  "config": {
    "url": "https://merchant.example.com/webhooks/coinpg",
    "enabled": false,
    "event_types": [
      "deposit.confirmed",
      "payout.confirmed",
      "payout.failed"
    ],
    "key_id": "whk_1",
    "version": 2,
    "deliver_after": "2026-09-11T10:00:00.000Z",
    "created_at": "2026-09-11T09:00:00.000Z",
    "updated_at": "2026-09-11T10:00:00.000Z",
    "rotated_at": null,
    "disabled_at": "2026-09-11T10:00:00.000Z"
  }
}

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
401WEBHOOK_REQUEST_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
400WEBHOOK_URL_INVALID저장된 수신 URL이 현재 URL 검사 규칙에 맞지 않습니다.
400WEBHOOK_URL_UNSAFE수신 URL이 공개 HTTPS 주소 규칙에 맞지 않습니다.
400WEBHOOK_URL_LOOPBACK_DISABLED로컬 HTTP URL 사용이 허용되지 않은 환경입니다.
409WEBHOOK_VERSION_CONFLICT설정 버전이 달라졌습니다. GET으로 현재 설정을 조회한 뒤 다시 요청합니다.
500WEBHOOK_CONFIG_CORRUPT저장된 설정 또는 이벤트 목록이 올바르지 않습니다. 운영자 확인이 필요합니다.
500WEBHOOK_REQUEST_FAILED비활성화하지 못했거나 결과가 불명확합니다. GET으로 enabled 값을 확인합니다.

처리 규칙

  • 요청 본문이나 expected_version은 필요하지 않습니다.
  • 실제 비활성화 시 version이 증가하며 대기·재시도 전달을 취소합니다. 이미 전송 중인 요청은 도착할 수 있습니다.
  • 다시 활성화하려면 GET의 현재 version을 expected_version으로 넣어 PUT합니다. 중지 기간이나 등록 이전 이벤트가 모두 자동 재발송되지는 않으므로 API 조회로 누락을 대조합니다.
POST/api/v1/webhook/rotate-secret

웹훅 서명 비밀키 교체

웹훅 검증용 signing_secret을 교체합니다. 가맹점 API 키를 변경하는 기능은 아닙니다.

요청 예제

cURL · Bash
curl --request POST 'https://pay.example.com/api/v1/webhook/rotate-secret' \
  --header 'x-api-key: <가맹점 API 키>' \
  --header 'Idempotency-Key: webhook-rotation-20260911-001'

성공 응답 예제

HTTP 200 · application/json
{
  "replayed": false,
  "key_id": "whk_2",
  "signing_secret": "<새 signing_secret>",
  "rotated_at": "2026-09-11T10:00:00.000Z",
  "previous_valid_until": "2026-09-12T10:00:00.000Z",
  "config": {
    "url": "https://merchant.example.com/webhooks/coinpg",
    "enabled": true,
    "event_types": [
      "deposit.confirmed",
      "payout.confirmed",
      "payout.failed"
    ],
    "key_id": "whk_2",
    "version": 2,
    "deliver_after": "2026-09-11T09:00:00.000Z",
    "created_at": "2026-09-11T09:00:00.000Z",
    "updated_at": "2026-09-11T10:00:00.000Z",
    "rotated_at": "2026-09-11T10:00:00.000Z",
    "disabled_at": null
  }
}

요청 파라미터

헤더

필드타입필수설명예시
x-api-keystring필수운영자가 발급한 해당 가맹점 API 키. 가맹점 백엔드에서만 사용합니다.<가맹점 API 키>
Idempotency-Keystring필수교체 작업별로 저장한 8~128자 키입니다. 영문·숫자로 시작하며 이후 영문·숫자·마침표·밑줄·하이픈만 허용합니다. 대소문자를 그대로 보존합니다.webhook-rotation-20260911-001

요청 본문: 없음.

응답 파라미터

성공 · HTTP 200

새 서명 비밀키와 이전 키의 유예 종료 시각을 반환합니다. 수신기에 새 키를 적용하고 직전 키도 유예 종료까지 보관합니다.

필드타입설명
replayedboolean직전 교체 요청을 같은 멱등 키로 다시 조회한 결과이면 true입니다.
key_idstring새 서명 키 식별자입니다. 수신 헤더의 X-CoinPG-Key-Id와 대응합니다.
signing_secretstring새 32바이트 HMAC 키를 패딩 없는 base64url로 인코딩한 비밀 값입니다.
rotated_atstring (date-time) | null키 교체 시각입니다. 정상적인 교체 결과에는 시각 문자열이 들어갑니다.
previous_valid_untilstring (date-time) | null직전 서명 키의 유예 종료 시각입니다. 정상적인 교체에서는 rotated_at부터 24시간 뒤입니다.
configobject교체 후 전체 설정입니다. key_id와 version이 증가하며 deliver_after는 그대로입니다.
config.urlstring등록된 웹훅 수신 URL입니다.
config.enabledboolean가맹점의 웹훅 사용 여부입니다. true여도 운영자의 발송 설정이 완료되어야 실제 전달됩니다.
config.event_typesstring[]구독 이벤트 목록입니다. deposit.confirmed, payout.confirmed, payout.failed 중 선택하며 중복은 제거됩니다.
config.key_idstring서명 검증 키 식별자입니다. whk_1처럼 표시되며 설정 version과 별개입니다.
config.versioninteger설정 버전입니다. 기존 설정을 수정할 때 expected_version에 전달합니다.
config.deliver_afterstring (date-time)현재 설정의 전달 대상 시작 시각입니다. 설정을 등록하거나 실제 변경하면 해당 시각으로 갱신됩니다.
config.created_atstring (date-time)최초 등록 시각입니다. UTC ISO 8601 형식입니다.
config.updated_atstring (date-time)마지막 설정 변경 또는 비밀키 교체 시각입니다.
config.rotated_atstring (date-time) | null마지막 비밀키 교체 시각입니다. 교체 전에는 null입니다.
config.disabled_atstring (date-time) | null비활성화 시각입니다. 활성 상태에서는 null입니다.

직전 교체 요청을 같은 키로 재요청한 경우 · HTTP 200

다른 교체가 이루어지기 전에는 같은 멱등 키로 같은 서명 키와 비밀 값을 다시 받을 수 있습니다.

성공 응답 · JSON
{
  "replayed": true,
  "key_id": "whk_2",
  "signing_secret": "<직전 교체에서 받은 동일한 signing_secret>",
  "rotated_at": "2026-09-11T10:00:00.000Z",
  "previous_valid_until": "2026-09-12T10:00:00.000Z",
  "config": {
    "url": "https://merchant.example.com/webhooks/coinpg",
    "enabled": true,
    "event_types": [
      "deposit.confirmed",
      "payout.confirmed",
      "payout.failed"
    ],
    "key_id": "whk_2",
    "version": 2,
    "deliver_after": "2026-09-11T09:00:00.000Z",
    "created_at": "2026-09-11T09:00:00.000Z",
    "updated_at": "2026-09-11T10:00:00.000Z",
    "rotated_at": "2026-09-11T10:00:00.000Z",
    "disabled_at": null
  }
}

오류 응답

{ "error": "오류 설명", "code": "오류 코드" } 형식입니다.

HTTP코드원인·처리
400WEBHOOK_IDEMPOTENCY_KEY_INVALIDIdempotency-Key가 없거나 형식이 올바르지 않습니다.
400WEBHOOK_URL_INVALID저장된 수신 URL이 현재 URL 검사 규칙에 맞지 않습니다.
400WEBHOOK_URL_UNSAFE수신 URL이 공개 HTTPS 주소 규칙에 맞지 않습니다.
400WEBHOOK_URL_LOOPBACK_DISABLED로컬 HTTP URL 사용이 허용되지 않은 환경입니다.
401WEBHOOK_ROTATION_FAILED가맹점 API 키가 없거나 유효하지 않습니다.
404WEBHOOK_CONFIG_NOT_FOUND웹훅 설정이 없습니다. 먼저 PUT으로 등록합니다.
409WEBHOOK_VERSION_CONFLICT설정 버전이 달라졌습니다. GET으로 현재 설정을 조회한 뒤 다시 요청합니다.
500WEBHOOK_CONFIG_CORRUPT저장된 설정 또는 이벤트 목록이 올바르지 않습니다. 운영자 확인이 필요합니다.
500WEBHOOK_VERSION_EXHAUSTED설정 또는 키 버전이 허용 범위를 벗어났습니다. 운영자 확인이 필요합니다.
500WEBHOOK_ROTATION_FAILED키를 교체하지 못했거나 결과가 불명확합니다. 해당 교체 작업의 같은 멱등 키를 유지합니다.
503WEBHOOK_MASTER_SECRET_INVALID운영자의 웹훅 서명 설정이 준비되지 않았거나 올바르지 않습니다.
503WEBHOOK_MASTER_KEY_MISMATCH운영자의 서명 설정이 등록 당시 설정과 다릅니다. 운영자 확인이 필요합니다.

처리 규칙

  • 요청 본문은 필요하지 않습니다. 교체 전에 멱등 키를 저장하고 응답 유실 시 그 작업의 같은 키로 재요청합니다.
  • 멱등 재조회는 가장 최근 교체에만 적용됩니다. 다른 교체가 끝난 뒤 과거 멱등 키를 재사용하면 다시 교체할 수 있으므로 재사용하지 않습니다.
  • 이전 키는 직전 한 버전만 24시간 유예됩니다. 연속 교체로 더 오래된 키를 사용하는 전달이 중단될 수 있습니다. 유예가 모든 과거 이벤트의 재발송을 보장하지는 않습니다.
  • 비밀키 교체 후에도 가맹점별 키를 분리하여 보관합니다. whk_1 같은 식별자 자체는 가맹점 간에 중복될 수 있습니다.
POST/webhooks/coinpg

입금·지급 결과 수신

COINPG가 가맹점 서버의 등록된 URL로 보내는 콜백입니다. 아래 경로는 제공된 Node.js 수신 예제의 경로이며, 가맹점의 실제 수신 URL을 PUT /webhook으로 등록합니다. x-api-key 대신 HMAC 서명을 검증합니다.

COINPG가 보내는 요청

HTTP 요청
POST /webhooks/coinpg HTTP/1.1
Host: merchant.example.com
Content-Type: application/json
X-CoinPG-Event-Id: evt_deposit_confirmed_cdep_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
X-CoinPG-Event-Type: deposit.confirmed
X-CoinPG-Timestamp: 1789117200
X-CoinPG-Key-Id: whk_1
X-CoinPG-Signature: v1=<계산된 base64url HMAC-SHA256>

{
  "id": "evt_deposit_confirmed_cdep_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "type": "deposit.confirmed",
  "created_at": "2026-09-11T09:00:00.000Z",
  "data": {
    "customer_id": "cus_0123456789abcdef0123456789abcdef",
    "status": "confirmed",
    "tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "from_address": "<입금자 TRON 주소>",
    "to_address": "<회원 입금 TRON 주소>",
    "amount_atomic": "1000000"
  }
}

가맹점이 반환할 응답

HTTP 200 · application/json
{
  "received": true,
  "duplicate": false
}

요청 파라미터

헤더

필드타입필수설명예시
Content-Typestring필수JSON 본문 전송 시 application/json을 사용합니다.application/json
X-CoinPG-Event-Idstring필수이벤트 식별자입니다. 같은 이벤트의 재시도에서도 유지되며 본문의 id와 같아야 합니다.evt_deposit_confirmed_cdep_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
X-CoinPG-Event-Typestring필수deposit.confirmed, payout.confirmed, payout.failed 중 하나이며 본문의 type과 같아야 합니다.deposit.confirmed
X-CoinPG-Timestampstring필수발송 시점 Unix 초 문자열입니다. 재시도 때 새로 생성되며 이벤트 생성 시각과 다를 수 있습니다.1789117200
X-CoinPG-Key-Idstring필수검증에 사용할 서명 비밀키의 식별자입니다.whk_1
X-CoinPG-Signaturestring필수v1= 뒤에 패딩 없는 base64url HMAC-SHA256 서명을 붙입니다. 아래 값은 실제 서명이 아닌 자리표시자입니다.v1=<계산된 base64url HMAC-SHA256>

요청 본문 · JSON

필드타입필수설명예시
idstring필수이벤트 ID입니다. 웹훅 중복 수신 제거에 사용합니다.evt_deposit_confirmed_cdep_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
typestring필수deposit.confirmed, payout.confirmed, payout.failed 중 하나입니다.deposit.confirmed
created_atstring (date-time)필수이벤트 생성 시각입니다. UTC ISO 8601이며 재시도에서 바뀌지 않습니다.2026-09-11T09:00:00.000Z
dataobject필수이벤트별 상세 데이터입니다. 입금과 지급의 필드 구성이 다릅니다.아래 세 이벤트 본문 예제 참조
data.customer_idstring | null필수COINPG 내부 회원 ID입니다. 입금에서는 string, 지급에서는 string 또는 null입니다. 가맹점의 external_user_id가 아닙니다.cus_0123456789abcdef0123456789abcdef
data.statusstring필수입금·지급 완료 이벤트는 confirmed, 지급 실패 이벤트는 failed입니다.confirmed
data.tx_hashstring | null필수체인 거래 해시입니다. 입금에서는 string, 지급에서는 string 또는 null입니다. 실패에도 해시가 있을 수 있습니다.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
data.from_addressstring | null필수입금에서는 송신 주소 문자열입니다. 지급에서는 가맹점 운영지갑 주소 또는 null입니다.<입금자 TRON 주소>
data.to_addressstring입금 시 필수deposit.confirmed에서만 필수인 회원 입금주소입니다. 지급 이벤트에는 이 필드가 없습니다.<회원 입금 TRON 주소>
data.amount_atomicstring입금 시 필수deposit.confirmed에서만 필수인 최소 단위 양의 정수 문자열입니다. 지급 이벤트에는 없습니다. 1000000은 1 USDT입니다.1000000

응답 파라미터

성공 · HTTP 200

가맹점 수신기가 응답합니다. 아래는 제공된 수신 예제의 실제 응답 형식이며, COINPG는 모든 2xx를 전달 성공으로 보고 응답 본문을 사용하지 않습니다.

필드타입설명
receivedboolean수신 예제에서 검증된 이벤트를 SQLite 수신함에 저장한 경우 true입니다. 회원 장부 반영 완료를 의미하지 않습니다.
duplicateboolean수신함에 같은 이벤트 ID가 이미 있으면 true입니다. 새 이벤트 저장이면 false입니다.

payout.confirmed · 지급 완료

요청 본문 · JSON
{
  "id": "evt_payout_confirmed_out_0123456789abcdef0123456789abcdef",
  "type": "payout.confirmed",
  "created_at": "2026-09-11T10:00:00.000Z",
  "data": {
    "customer_id": "cus_0123456789abcdef0123456789abcdef",
    "status": "confirmed",
    "tx_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "from_address": "<가맹점 운영 TRON 주소>"
  }
}

payout.failed · 지급 실패

요청 본문 · JSON
{
  "id": "evt_payout_failed_out_0123456789abcdef0123456789abcdef",
  "type": "payout.failed",
  "created_at": "2026-09-11T10:00:00.000Z",
  "data": {
    "customer_id": "cus_0123456789abcdef0123456789abcdef",
    "status": "failed",
    "tx_hash": null,
    "from_address": null
  }
}

같은 이벤트를 다시 받은 경우 · HTTP 200

이미 영속 저장한 이벤트에도 정상 응답하며 업무를 중복 반영하지 않습니다.

성공 응답 · JSON
{
  "received": true,
  "duplicate": true
}

오류 응답

아래 오류는 제공된 수신 서버 예제의 응답입니다. COINPG의 재전송 규칙은 아래 처리 규칙을 따릅니다.

HTTP코드원인·처리
400없음수신 예제에서 JSON·이벤트 형식·본문과 헤더의 ID/종류가 잘못되었거나 요청이 중단되었습니다. 오류 본문은 error 문자열만 포함합니다.
401없음수신 예제에서 서명 헤더, 검증 키, 유예 만료, 발송 시각 또는 HMAC 검증이 실패했습니다.
404없음수신 예제의 /webhooks/coinpg가 아닌 경로입니다.
405없음수신 예제는 이 경로에서 POST만 받습니다.
413없음수신 예제의 본문 크기 제한 64 KiB를 초과했습니다.
415없음수신 예제에는 Content-Type: application/json이 필요합니다.
503없음수신 예제에서 수신함을 사용할 수 없습니다. 아직 수신 완료로 처리하지 않으며 COINPG가 재시도합니다.

처리 규칙

  • 이 콜백의 Content-Type은 application/json입니다. 헤더 이름은 대소문자를 구분하지 않습니다. 각 본문 예제에는 그 이벤트의 id와 type에 맞는 헤더와 실제 계산한 서명을 사용해야 합니다.
  • 검증 키는 signing_secret을 base64url 디코딩한 32바이트입니다. 비밀 문자열 자체를 UTF-8 키로 사용하지 않습니다. X-CoinPG-Key-Id에 맞는 해당 가맹점의 키를 선택합니다.
  • 서명 입력은 UTF-8(timestamp + '.' + event_id + '.') 뒤에 수신한 원문 body 바이트를 이어 붙인 값입니다. HMAC-SHA256 결과를 패딩 없는 base64url로 인코딩하고 앞에 v1=을 붙입니다. JSON 파싱 후 재직렬화한 문자열로 검증하지 않습니다.
  • 서명은 길이를 확인한 뒤 상수 시간 비교합니다. 수신 예제는 현재 서버 시각과 발송 타임스탬프의 차이를 앞뒤 300초로 제한합니다. 서버 시계를 동기화하며, 오래된 이벤트의 created_at을 발송 시각 대신 사용하지 않습니다.
  • 서명 검증 후 JSON을 파싱하고 body.id와 X-CoinPG-Event-Id, body.type과 X-CoinPG-Event-Type이 같은지 확인합니다.
  • 중복 이벤트 ID를 영속 DB에서 제거하고 저장 커밋 후 2xx를 응답합니다. 저장과 실제 장부 반영은 같은 트랜잭션으로 처리하거나, 저장한 미처리 이벤트를 별도 작업이 계속 처리하도록 연결합니다. 메모리 목록만으로 중복을 막지 않습니다.
  • 공개 회원 API는 내부 customer_id를 반환하지 않습니다. 콜백에도 external_user_id, network, asset, payout_id가 없고 지급 data에는 금액·수취 주소·실패 사유가 없습니다. 입금은 거래 목록을 페이지 조회하여 거래 ID·해시·주소를 대조한 뒤 external_user_id로 연결하고, 지급은 저장한 지급 ID로 조회합니다.
  • 현재 지급 이벤트 ID는 evt_payout_confirmed_<지급 ID> 또는 evt_payout_failed_<지급 ID> 형태입니다. 형식에 의존하지 않으려면 알림을 계기로 저장한 미완료 지급을 조회합니다. 실제 장부 반영도 거래 ID 또는 지급 ID로 한 번만 수행합니다.
  • 입금 amount_atomic은 정수 문자열이며 10의 6제곱 단위가 1 USDT입니다. BigInt 또는 정확한 소수 연산을 사용합니다. 웹훅 도착 순서를 보장하지 않으므로 API 조회와 주기적인 누락 대조를 유지합니다.
  • 2xx는 전달 성공입니다. 408·425·429·5xx와 연결 실패·8초 타임아웃은 재시도하며, 3xx 리다이렉트는 따라가지 않고 그 외 오류와 함께 전달을 종료합니다. 수신 예제의 400·401 응답도 자동 재시도되지 않습니다.
  • 전달 시도 예산은 최대 12회입니다. 재시도 간격은 30초부터 증가하고 최대 6시간이며 실제 실행 시각은 발송 작업 주기에 따릅니다. 재시도가 종료되거나 설정 변경으로 취소된 이벤트는 API 조회로 누락을 복구합니다.

입금 알림 1건을 회원 장부에 연결하기

위 deposit.confirmed 예제의 amount_atomic=1000000은 1 USDT입니다. 알림에는 external_user_id가 없으므로, 다음 순서로 API에서 확인한 거래를 가맹점 회원에게 연결합니다.

  1. 검증한 알림을 영속 저장하고 2xx 응답

    원문 서명·시각·본문과 헤더의 ID/종류를 검증합니다. 해당 가맹점과 이벤트 id를 중복 키로 원문을 영속 수신함에 저장하고 커밋한 뒤 2xx를 응답합니다. 이미 저장한 이벤트에도 2xx로 응답하며, 아직 장부 반영이 끝나지 않은 항목은 작업 대상에 남깁니다.

    연결에 사용하는 값 · 발췌
    {
      "received": true,
      "duplicate": false
    }
  2. 저장한 알림을 작업자가 읽고 거래 조회

    같은 가맹점의 x-api-key로 GET /api/v1/transactions?limit=100을 호출합니다. 회원 ID를 아직 모르므로 user_id를 보내지 않습니다. has_more=true이면 next_cursor를 URL 인코딩해 같은 조건으로 다음 페이지를 조회합니다. 수신 요청 안에서 모든 조회를 기다리지 않고 저장한 미처리 이벤트를 별도 작업자가 처리합니다.

  3. 같은 확정 입금 한 건인지 대조

    type=deposit, status=confirmed, asset=USDT, network=tron-mainnet인 항목에서 tx_hash·from_address·to_address를 알림과 대조합니다. amount는 정확한 소수 연산 또는 문자열을 소수 6자리 정수로 바꿔 amount_atomic과 비교합니다. 예제의 1.00은 1000000과 같습니다. 아래는 일치한 거래에서 비교·회원 연결에 쓰는 필드만 발췌한 것으로 전체 API 응답은 아닙니다. 필요한 페이지를 확인해도 없거나 후보가 여러 개이면 적립하지 않고 미처리로 유지하여 재조회·운영자 확인으로 해결합니다.

    연결에 사용하는 값 · 발췌
    {
      "id": "cdep_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "external_user_id": "user-1001",
      "type": "deposit",
      "status": "confirmed",
      "amount": "1.00",
      "asset": "USDT",
      "network": "tron-mainnet",
      "tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "from_address": "<입금자 TRON 주소>",
      "to_address": "<회원 입금 TRON 주소>"
    }
  4. 거래 ID로 한 번만 1 USDT 반영

    조회 결과의 external_user_id=user-1001을 가맹점 회원과 연결합니다. 해당 가맹점과 거래 id를 장부의 고유 키로 저장하고 최초 삽입일 때만 검증한 1 USDT를 적립합니다. 장부 변경과 처리 기록을 같은 DB 트랜잭션으로 커밋하세요. 수신함이 다른 DB라면 이 장부 고유 키로 재실행도 중복 적립되지 않게 한 뒤 수신함을 완료 표시합니다. 이미 반영된 거래는 추가 적립 없이 완료 처리합니다. 이벤트 ID나 거래 해시만을 장부의 중복 방지 키로 사용하지 않습니다.

Node.js 웹훅 수신 서버 전체 코드

Node.js 22.18 이상. 아래 코드를 merchant-webhook-server.mjs로 저장합니다. 서명 검증과 SQLite 수신함 저장을 처리하며, 회원 장부 반영은 가맹점에서 연결합니다.

merchant-webhook-server.mjs
// Node.js >= 22.18.0. See docs/WEBHOOK_SIGNATURE_VERIFICATION.md.
// This receiver durably queues notifications; it does not credit balances.
import { createHmac, timingSafeEqual } from "node:crypto";
import { mkdirSync } from "node:fs";
import { createServer } from "node:http";
import { dirname, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";

const MAX_BODY_BYTES = 64 * 1024;
const EVENT_TYPES = new Set(["deposit.confirmed", "payout.confirmed", "payout.failed"]);

class RequestError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

export function parseSigningKeys(json) {
  const source = JSON.parse(json);
  if (!source || typeof source !== "object" || Array.isArray(source)) {
    throw new Error("WEBHOOK_KEYS_JSON must be an object.");
  }
  const keys = new Map();
  for (const [id, entry] of Object.entries(source)) {
    if (!/^whk_[1-9][0-9]*$/u.test(id) || !entry || typeof entry !== "object" || Array.isArray(entry) ||
        typeof entry.secret !== "string" || !/^[A-Za-z0-9_-]{43}$/u.test(entry.secret) ||
        (entry.valid_until !== undefined && typeof entry.valid_until !== "string")) {
      throw new Error("Invalid webhook key configuration.");
    }
    const bytes = Buffer.from(entry.secret, "base64url");
    const validUntilMs = entry.valid_until === undefined ? Infinity : Date.parse(entry.valid_until);
    if (bytes.length !== 32 || bytes.toString("base64url") !== entry.secret ||
        Number.isNaN(validUntilMs)) {
      throw new Error("Invalid webhook secret or valid_until.");
    }
    keys.set(id, { bytes, validUntilMs });
  }
  if (keys.size === 0) throw new Error("At least one webhook signing key is required.");
  return keys;
}

export function verifyWebhook(rawBody, headers, keys, nowMs = Date.now()) {
  const eventId = headers["x-coinpg-event-id"];
  const eventType = headers["x-coinpg-event-type"];
  const timestamp = headers["x-coinpg-timestamp"];
  const keyId = headers["x-coinpg-key-id"];
  const signature = headers["x-coinpg-signature"];
  if (typeof eventId !== "string" || !eventId || eventId.length > 512 ||
      typeof eventType !== "string" || !EVENT_TYPES.has(eventType) ||
      typeof timestamp !== "string" || !/^[0-9]{1,13}$/u.test(timestamp) ||
      typeof keyId !== "string" || typeof signature !== "string" ||
      !/^v1=[A-Za-z0-9_-]{43}$/u.test(signature)) {
    throw new RequestError(401, "Invalid webhook headers.");
  }
  const key = keys.get(keyId);
  if (!key || key.validUntilMs < nowMs ||
      Math.abs(Math.floor(nowMs / 1000) - Number(timestamp)) > 300) {
    throw new RequestError(401, "Unknown/expired key or timestamp outside tolerance.");
  }
  const expected = createHmac("sha256", key.bytes)
    .update(`${timestamp}.${eventId}.`, "utf8")
    .update(rawBody)
    .digest();
  const actual = Buffer.from(signature.slice(3), "base64url");
  if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
    throw new RequestError(401, "Invalid webhook signature.");
  }
  let event;
  try {
    event = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(rawBody));
  } catch {
    throw new RequestError(400, "Invalid JSON body.");
  }
  if (!event || typeof event !== "object" || Array.isArray(event) ||
      event.id !== eventId || event.type !== eventType ||
      typeof event.created_at !== "string" || !Number.isFinite(Date.parse(event.created_at)) ||
      !event.data || typeof event.data !== "object" || Array.isArray(event.data)) {
    throw new RequestError(400, "Invalid event envelope.");
  }
  return event;
}

export function createWebhookReceiver({ keys, databasePath }) {
  mkdirSync(dirname(databasePath), { recursive: true });
  const db = new DatabaseSync(databasePath);
  db.exec(`
    PRAGMA journal_mode = WAL;
    PRAGMA synchronous = FULL;
    PRAGMA busy_timeout = 1000;
    CREATE TABLE IF NOT EXISTS coinpg_webhook_inbox (
      event_id TEXT PRIMARY KEY,
      event_type TEXT NOT NULL,
      raw_body BLOB NOT NULL,
      received_at TEXT NOT NULL,
      processed_at TEXT
    );
  `);
  const insert = db.prepare(`
    INSERT INTO coinpg_webhook_inbox (event_id, event_type, raw_body, received_at)
    VALUES (?, ?, ?, ?) ON CONFLICT(event_id) DO NOTHING
  `);
  const respond = (res, status, body) => {
    if (res.writableEnded || res.destroyed) return;
    res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
    res.end(JSON.stringify(body));
  };
  const server = createServer((req, res) => {
    if (req.url === "/healthz" && req.method === "GET") {
      respond(res, 200, { ok: true });
      return;
    }
    if (req.url !== "/webhooks/coinpg") {
      respond(res, 404, { error: "Not found." });
      req.resume();
      return;
    }
    if (req.method !== "POST") {
      res.setHeader("allow", "POST");
      respond(res, 405, { error: "Use POST." });
      req.resume();
      return;
    }
    if (req.headers["content-type"]?.split(";", 1)[0].trim() !== "application/json") {
      respond(res, 415, { error: "Content-Type: application/json is required." });
      req.resume();
      return;
    }
    const chunks = [];
    let size = 0;
    let rejected = false;
    req.on("data", (chunk) => {
      if (rejected) return;
      size += chunk.length;
      if (size > MAX_BODY_BYTES) {
        rejected = true;
        chunks.length = 0;
        respond(res, 413, { error: "Body exceeds 64 KiB." });
        return;
      }
      chunks.push(chunk);
    });
    req.on("error", () => respond(res, 400, { error: "Request interrupted." }));
    req.on("end", () => {
      if (rejected) return;
      try {
        const rawBody = Buffer.concat(chunks);
        const event = verifyWebhook(rawBody, req.headers, keys);
        // SQLite commits this single INSERT before replying. A worker must later
        // process pending inbox rows; HTTP retries are not that worker.
        const result = insert.run(event.id, event.type, rawBody, new Date().toISOString());
        respond(res, 200, { received: true, duplicate: Number(result.changes) === 0 });
      } catch (error) {
        respond(res, error instanceof RequestError ? error.status : 503, {
          error: error instanceof RequestError ? error.message : "Inbox unavailable; retry later.",
        });
      }
    });
  });
  server.requestTimeout = 10_000;
  server.headersTimeout = 10_000;
  server.on("close", () => db.close());
  return server;
}

if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
  const keys = parseSigningKeys(process.env.WEBHOOK_KEYS_JSON ?? "{}");
  const port = Number(process.env.PORT ?? 4000);
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid PORT.");
  const server = createWebhookReceiver({
    keys,
    databasePath: resolve(process.env.WEBHOOK_INBOX_PATH ?? ".coinpg/merchant-webhook-inbox.sqlite"),
  });
  server.listen(port, "127.0.0.1", () => {
    console.log(`Webhook receiver: http://127.0.0.1:${port}/webhooks/coinpg`);
  });
  const stop = () => server.close();
  process.once("SIGINT", stop);
  process.once("SIGTERM", stop);
}
실행 · Bash
export WEBHOOK_KEYS_JSON='{"whk_1":{"secret":"<발급받은 signing_secret>"}}'
export WEBHOOK_INBOX_PATH='./.coinpg/merchant-webhook-inbox.sqlite'
PORT=4000 node merchant-webhook-server.mjs

127.0.0.1:4000/webhooks/coinpg에서 수신합니다. 가맹점 HTTPS 주소를 이 경로로 연결하고 수신함 DB를 영속 보관합니다.