# Cryptoments API 연동 안내서 (파트너 온보딩 표준문)

> **문서 목적**: 파트너사가 보내온 "API 연동 요청서"의 각 항목에 Cryptoments가 회신하는 표준 안내문.
> 1부(기획·운영)와 2부(개발)로 나뉘며, 파트너사 내부에서 기획자와 개발자가 같은 문서를 보고 진행할 수 있도록 구성.
>
> - 버전: v1.0 / 작성일: 2026-08-12
> - 근거: 운영 코드 기준(문서가 아닌 실제 구현). 코드와 어긋나는 구 문서는 무효.
> - 회신 담당: Cryptoments 기술지원

---

## 0. 요청서 항목별 요약 회신

| 요청서 항목 | 회신 위치 |
|---|---|
| API 연동의 목적 | [1.1](#11-연동의-목적) |
| API 연동 문서 및 관련 자료 | [1.2](#12-제공-자료-첨부) |
| 사이트/어드민 내 연동 방식 | [1.3](#13-연동-방식-3가지-중-선택) ~ [1.6](#16-정산과-수수료-업무-흐름) |
| 접목되는 개발 요청 내용 (추가될 버튼·메뉴) | [1.7](#17-파트너-사이트에-추가되는-버튼메뉴) |
| 상세 연동 내용 (API 작동 방식) | [2부 전체](#2부-개발자용--api-작동-방식) |
| **API Key 발급 및 관리** | [2.8](#28-api-key-발급재발급회전) |
| **Axim Pay 연동 설정** | [2.9](#29-axim-pay-연동-설정) |
| **원화(KRW) 입금·출금** | 기획 [1.4.1](#141-원화krw-입금--3가지-경로) · [1.5.1](#151-원화krw-출금--p2p) / 기술 [2.10](#210-원화krw-입출금--기술-상세) |
| 파트너사가 회신해야 할 정보 | [부록 A](#부록-a-파트너사-회신-양식) |

---

# 1부. 기획·운영자용 — 무엇이 어떻게 바뀌는가

## 1.1 연동의 목적

Cryptoments는 **멀티체인 스테이블코인 결제 게이트웨이**입니다. 파트너 사이트에 연동하면 다음이 가능해집니다.

| 목적 | 파트너 사이트에서 일어나는 일 |
|---|---|
| **회원 충전(입금)** | 회원이 USDT를 직접 보내거나, 원화로 결제하면 파트너 잔고에 USDT가 적립 |
| **회원 출금(지급)** | 회원 지갑 주소로 USDT 전송. 가스비는 Cryptoments가 부담 |
| **원화 온·오프램프** | 코인이 없는 회원도 은행 이체만으로 충전 가능 (P2P 매칭 / TORQ / 폰페이) |
| **자동 원장 반영** | 입금·출금 상태 변화를 Webhook으로 실시간 통지 → 파트너 시스템 잔고 자동 반영 |
| **운영 가시성** | 파트너 콘솔에서 입출금·잔액·정산·수수료를 조회 |

연동 후에는 파트너사가 **지갑을 직접 운영하지 않습니다.** 주소 생성, 개인키 보관, 가스비, 집금(sweep), 온체인 모니터링을 Cryptoments가 대행합니다.

---

## 1.2 제공 자료 (첨부)

| 자료 | 위치 | 용도 |
|---|---|---|
| 개발자 가이드 사이트 | `https://docs.cryptoments.cc` — 위젯 SDK(`/widget.html`), Webhook(`/webhook.html`), 파트너 콘솔(`/partner-guide.html`) | 개발자 1차 참조 |
| API 스펙 (자동생성) | `https://api.cryptoments.cc/docs/` | 엔드포인트·필드 최신본 |
| 위젯 SDK | `https://widget.cryptoments.cc/sdk/widget.cryptoments.js` | 프론트 삽입 스크립트 |
| 본 안내서 | 이 문서 | 연동 범위 확정 및 합의용 |
| 테스트 계정 / API Key·Secret | 계약 후 개별 발급 (절차는 [§2.8](#28-api-key-발급재발급회전)) | 파트너 콘솔 로그인 + API 인증 |

> **API Secret은 발급 시 1회만 표시됩니다.** 분실 시 재발급이 필요하며, 재발급하면 기존 키는 즉시 무효화됩니다.

---

## 1.3 연동 방식 3가지 중 선택

파트너 사이트의 개발 여력과 UX 요구에 따라 셋 중 하나(또는 혼합)를 고릅니다. **가장 먼저 결정해야 할 항목입니다.**

### 방식 A — 위젯 SDK 삽입 (권장)

파트너 사이트에 스크립트 한 줄을 넣고, 충전 버튼에서 함수를 호출하면 Cryptoments 결제 화면이 모달 또는 팝업으로 뜹니다.

- **장점**: 결제 화면(금액 입력, 네트워크 선택, 주소 표시, 원화 결제)을 Cryptoments가 전부 책임. 파트너는 버튼만 붙임
- **필요 개발**: 프론트 스크립트 + 파트너 서버에 위젯 토큰 발급 엔드포인트 1개
- **적합**: 회원이 사이트 안에서 충전을 끝내야 하는 일반적인 경우

### 방식 B — 링크 방식 (개발 최소)

파트너 콘솔 또는 API로 결제 링크를 만들어 회원에게 URL(문자·카톡·메일)로 전달합니다.

- **장점**: 프론트 개발이 사실상 없음. 링크만 열면 됨
- **단점**: 사이트 내부 UX 통합이 약함. 완료 통지는 Webhook에 의존
- **적합**: 소량·수동 운영, PoC, 앱 내 웹뷰가 어려운 환경

### 방식 C — 서버 API 직접 연동

파트너 서버가 REST API를 직접 호출해 입금 주소를 발급하고 출금을 요청합니다. 화면은 파트너가 직접 만듭니다.

- **장점**: UI 완전 통제
- **단점**: 원화 결제(P2P/TORQ/폰페이)는 위젯 전용이라 이 방식으로 대체 불가 → **USDT 직접 입출금만 가능**
- **적합**: 자체 지갑 UI를 이미 갖춘 사이트

> **혼합이 일반적입니다.** 충전은 방식 A(위젯), 출금 승인·조회는 방식 C(서버 API), 예외 상황은 방식 B(링크).

---

## 1.4 입금(충전) 업무 흐름

```
[회원] 파트너 사이트에서 "충전" 클릭
   → [파트너 서버] 위젯 토큰 발급 (회원 ID 지정)
   → [위젯] 결제수단 선택 화면
        ├─ 암호화폐 직접 결제 : 전용 입금 주소 표시 → 회원이 USDT 전송
        ├─ 원화로 충전       : P2P 매칭 또는 TORQ → 회원이 은행 이체
        └─ Axim Pay          : Axim 앱 딥링크 → 앱에서 승인
   → [Cryptoments] 온체인/이체 확인 → 입금 확정
   → [Webhook] DEPOSIT_CONFIRMED 발송
   → [파트너 서버] 회원 잔고 증액
```

체크 포인트:

- **입금 주소는 회원별로 1개씩 영구 발급**됩니다. 같은 회원·같은 체인으로 재요청해도 같은 주소가 반환됩니다(멱등).
- **주소는 체인 단위**입니다. 같은 체인의 USDT와 USDC는 동일 주소를 씁니다.
- 회원 잔고 증액은 **반드시 Webhook 또는 조회 API 기준**으로 하십시오. 위젯 완료 콜백만 믿으면 회원이 창을 먼저 닫았을 때 누락됩니다.
- **원화 충전 중 P2P로 파트너가 원화를 직접 수령하는 경우는 현재 Webhook이 발송되지 않습니다.** 이 경로를 쓴다면 조회 API 기반 대사 폴링이 필수입니다(§2.5.4).

### 1.4.1 원화(KRW) 입금 — 3가지 경로

코인이 없는 회원도 **은행 이체만으로 충전**할 수 있는 경로입니다. 회원이 원화를 보내면 파트너 잔고에는 USDT가 들어옵니다(폰페이만 예외 — 아래 표).

| | **P2P 매칭** | **원화 충전(LP)** | **폰페이** |
|---|---|---|---|
| 회원이 돈 보내는 곳 | 출금을 원하는 **다른 회원의 개인 계좌** | **LP(유동성 공급자) 계좌** | **지정 수취인**(휴대폰 기반 이체) |
| 파트너 잔고 반영 통화 | **USDT** | **USDT** | **KRW** ⚠️ |
| 속도 | 자동 확인 시 이체 후 수 초 ~ 수 분 | LP 확인 후 즉시 | 콜백 수신 즉시 |
| 최소 금액 | 10,000원(기본값 · 계약별 설정 가능) | LP 한도에 따름 | 별도 제한 없음 |
| 거래 제한시간 | 매칭 후 **30분** | LP가 지정 | 세션 만료 시각(기본 1시간) |

> ⚠️ **폰페이는 파트너 잔고에 원화(KRW)로 적립**됩니다. USDT 잔고와는 별도 통화 원장이므로 정산·회계 처리 시 구분이 필요합니다.
>
> 대외 문구에서는 LP 온램프를 **"원화 충전"** 으로 표기합니다(내부 벤더명 노출 금지).

**회원 단계 (P2P / 통합 매칭 기준)**

```
[1] 위젯 또는 매칭 링크 진입
[2] Axim 지갑 연결 + eKYC 인증          ← 미인증이면 진행 불가 (자동 게이트)
[3] 금액 입력 (링크에 금액이 고정돼 있으면 생략)
[4] 매칭 실행 (자동)
      ├─ 매칭 실패 → "거래 가능 물량 부족" 안내로 종료
      └─ 매칭 성공 → 입금 계좌 카드 표시 (최대 3장으로 분할될 수 있음)
[5] 회원이 은행 앱에서 이체            ← 반드시 eKYC 실명 계좌에서 송금
[6] 위젯에서 [이체 완료] 클릭
[7] 입금 확인
      ├─ 자동: 상대 회원 계좌를 5초 주기로 조회해 자동 확인
      └─ 수동: 상대 회원이 직접 [입금 확인] (빠른조회 미지원 은행 계좌는 항상 수동 — 인터넷전문은행 등)
[8] USDT 지급 → 파트너 잔고 반영 → DEPOSIT_CONFIRMED Webhook
```

**중요한 제약**

- **매칭은 최초 1회만** 시도합니다. 실패한 건은 자동으로 다시 매칭되지 않습니다.
- 잔여 금액은 **P2P → 원화 충전(LP) → 파트너 서비스 계좌** 순으로 자동 보충되며, 한 주문은 **기본 최대 3개 계좌**(P2P 2 + 잔여 1)로 분할될 수 있습니다. 회원은 각 계좌에 **개별 송금**해야 합니다. 분할 상한과 최소 금액은 시스템 설정값이라 계약에 따라 달라질 수 있습니다.
- **입금자명이 eKYC 실명과 다르면 자동으로 분쟁 처리**됩니다. 회원 안내 문구에 반드시 포함하십시오.
- **P2P 매칭 상대(판매 대기 물량)가 없으면 매칭이 실패**합니다. 이는 장애가 아니라 유동성 상태입니다.

**파트너가 준비해야 할 것**

1. 원화 기능 활성화 (계약 시 Cryptoments가 설정)
2. **Axim 연동 활성화** — eKYC 인증이 Axim 기반이라 필수입니다 ([§2.9](#29-axim-pay-연동-설정))
3. **MASTER 지갑** — 원화 충전(LP) 수취처 및 정산 수취처
4. (파트너 서비스 계좌 fallback 사용 시) 파트너 명의 은행계좌 등록

## 1.5 출금(지급) 업무 흐름

```
[회원] 출금 신청 (파트너 사이트 내 자체 화면)
   → [파트너 서버] 자체 심사·차감 후 Cryptoments 출금 API 호출 (orderId 필수 권장)
   → [Cryptoments] 정책 검증(최소금액·한도·화이트리스트) → 승인 대기 또는 자동승인
   → [승인] 파트너 콘솔에서 승인 (또는 자동승인 임계값 이하면 즉시 진행)
   → [온체인] 전송 → 컨펌
   → [Webhook] WITHDRAWAL_REQUESTED → APPROVED → CONFIRMED 순차 발송
```

체크 포인트:

- **출금 수수료는 차감되지 않습니다.** 요청한 금액 그대로 수신 주소에 도착합니다. 가스비는 Cryptoments 부담이며 별도 청구서로 정산됩니다.
- **최소 출금 금액은 10 USD 상당**입니다.
- 승인 방식(수동 승인 / 임계값 이하 자동승인)과 일일·건당 한도, 화이트리스트 사용 여부는 **계약 시 정책으로 설정**합니다.
- 출금은 하나의 건이 생애주기 동안 **여러 번 Webhook을 보냅니다**(요청→승인→확정). 매번 잔고를 차감하지 않도록 상태별 처리 분기가 필요합니다.

### 1.5.1 원화(KRW) 출금 — P2P

회원에게 USDT를 보내는 대신, **현금으로 USDT를 사려는 구매자와 매칭해 구매자가 회원 계좌로 원화를 직접 송금**하는 방식입니다.

```
[일반 출금]  파트너 MASTER ──온체인 USDT──▶ 회원 개인지갑

[원화 출금]  구매자 ──은행 원화 송금──▶ 회원 은행계좌      ← 회원이 받는 것은 원화
             출금 파트너 MASTER ──USDT──▶ 입금 파트너 MASTER  ← 코인은 파트너끼리만 이동
                                          (같은 파트너면 온체인 전송 없이 원장 처리)
```

**파트너 이점**: 회원 출금을 온체인 전송 없이 처리할 수 있고(가스비·전송 지연 없음), 매칭 성사 시 상대 파트너의 리베이트를 수취합니다.

**흐름**

```
[1] 출금 주문 등록                     ← 파트너가 등록 (회원 위젯에는 주문 생성 기능 없음)
      · 파트너 API 또는 파트너 콘솔 P2P 회원 상세 > [P2P 출금 요청]
      · 기존 일반 출금을 P2P로 전환하는 것도 가능
      · 원화 액면은 등록 시점에 고정, 적용 환율은 매칭 체결 시점의 시스템 시세
        (미체결 주문의 표시 환율·USDT 환산액은 2시간마다 갱신)
[2] 회원이 출금 페이지에서 은행계좌 등록 + 계좌 인증
      · 회원당 계좌 1개만 등록 가능
      · 계좌 없이도 주문 생성 가능(콘솔 [P2P 출금 요청] 경로). 이때는 매칭 후보에서 제외되며,
        회원이 계좌를 등록하는 순간 대기 주문에 자동 반영되어 매칭 대상이 됨
[3] 매칭 대기                          ← ⚠️ 자동 만료 없음. 취소는 파트너만 가능
[4] 매칭 성립 → 구매자가 회원 계좌로 이체 (30분 제한)
[5] 입금 확인 (자동 조회 또는 회원이 직접 확인)
[6] 정산 — 출금 파트너 USDT가 입금 파트너로 이동, 회원은 원화 수령 완료
[7] 전액 정산되면 출금 주문 COMPLETED
```

**정책 · 제약**

- **출금 주문은 자동 만료되지 않습니다.** 판매 대기는 회원 자산에 대한 의사이므로, 종료는 회원의 USDT 전환 요청 또는 파트너의 취소 결정으로만 이뤄집니다.
- **매칭 단위(leg)는 30분** 후 만료됩니다. 만료되면 그 건만 실패하고 주문은 대기 상태로 되돌아갑니다.
- **구매자가 [이체 완료]를 신고한 건이 수동 확인 대상이면, 30분이 지나도 자동 실패하지 않습니다.** 회원이 방치하면 무기한 대기하며, 10분·30분 시점에 재안내와 관리자 에스컬레이션만 발생합니다. (구매자가 송금 자체를 하지 않은 건은 수동/자동과 무관하게 30분에 실패 처리됩니다.)
- **회원 수령 원화 금액은 액면 그대로**입니다. 출금자 수수료는 회원 수령액에서 차감되지 않고 출금 파트너가 USDT로 부담합니다.
- 매칭이 취소·실패하면 수수료도 발생하지 않습니다.
- 매칭은 **네트워크 무관**입니다. 정산 체인은 출금 주문에 지정된 네트워크를 따릅니다.
- 분쟁 중인 매칭이 있으면 **주문 취소·강제 정산·USDT 전환이 모두 차단**됩니다(이중 지급 방지).

**파트너 콘솔에서 할 수 있는 것**

| 화면 | 기능 |
|---|---|
| P2P > 회원 관리 | 회원 등록, 계좌 등록 여부, 출금 대기 금액, 상태 |
| P2P > 회원 상세 | 출금 페이지 링크 발급, **[P2P 출금 요청]**, 핀코드 리셋, 접근 차단/해제 |
| P2P > 출금 주문 | 주문 목록·매칭 진행률·상태 필터 |
| P2P > 출금 주문 상세 | 매칭 내역, **강제 정산 / 잔여 취소 / USDT 전환 승인·거부**(모두 OTP 필요), 입금 확인증 PDF |
| P2P > 출금 풀 현황 | 전체 대기 물량(익명 집계) + 내 주문 — 매칭 가능성 판단용 |

**회원이 스스로 설정하는 것** (파트너는 조회만 가능)

- **거래 중지** — 신규 매칭만 차단. 진행 중인 매칭·정산은 계속됩니다.
- **입금 확인 방식(자동/수동)** — 자동 전환은 계좌 인증이 완료된 경우에만 가능하며, 신규 회원 기본값은 수동입니다.
- 텔레그램 알림 연동 — 수동 확인을 쓴다면 사실상 필수입니다.

## 1.6 정산과 수수료 업무 흐름

| 항목 | 규칙 |
|---|---|
| 입금 수수료 | 계약 요율(%)만큼 입금액에서 차감. `총액 − 수수료 = 순입금액` |
| 출금 수수료 | 없음 |
| 가스비 | Cryptoments 선부담 후 가스 청구서로 정산 |
| 수수료 실현 | 매일 새벽 집계·실현되어 파트너 잔액에서 인출됨 → **입금액만큼 가용잔액이 늘지 않는 것은 정상** |
| 조회 | 파트너 콘솔 > 정산 (일별 정산, 수수료 요약, 인출 이력) |

> ⚠️ **회원에게 반영할 금액은 순입금액(총액 − 수수료)입니다.** 현재 v1 조회 API와 Webhook의 `amount`는 **총액(gross)** 이므로, 파트너가 요율을 적용해 순액을 계산하거나 파트너 콘솔에서 대사해야 합니다. 순액 필드 제공은 [부록 B](#부록-b-cryptoments-내부-확인-항목-파트너-배포-전-정리) 참조.

## 1.7 파트너 사이트에 추가되는 버튼·메뉴

### 회원 화면 (프론트)

| # | 위치 | 추가 요소 | 동작 |
|---|---|---|---|
| 1 | 마이페이지 / 충전 | **[충전하기] 버튼** | 위젯 SDK `openDeposit(회원ID)` 호출 → 결제 모달 |
| 2 | 마이페이지 / 출금 | **[출금 신청] 버튼 + 입력 폼**(수량, 네트워크, 받는 주소) | 파트너 서버 경유로 출금 API 호출 |
| 3 | 마이페이지 / 내역 | **[입출금 내역] 탭** | 파트너 DB 기준 표시(Webhook으로 동기화된 값) |
| 4 | (선택) 충전 완료 | **완료 토스트/모달** | 위젯 성공 콜백 수신 시 표시. 잔고 반영은 Webhook 기준 |
| 5 | (선택) 출금 상태 | **상태 배지**(신청/승인대기/처리중/완료/실패) | Webhook 상태값 매핑 |

### 운영자 어드민 화면 (파트너사 자체 어드민)

| # | 메뉴 | 추가 요소 | 목적 |
|---|---|---|---|
| 1 | 회원 상세 | **입금 주소 표시란** | CS 문의 대응("어디로 보내야 하나요") |
| 2 | 출금 관리 | **출금 요청 목록 + 상태 컬럼** | 회원 출금 진행 상황 파악 |
| 3 | 정산 | **Cryptoments 잔액 / 수수료 요약** | 자체 원장과 대사 |
| 4 | 시스템 | **Webhook 수신 로그** | 장애 시 유실·중복 판별 |
| 5 | 시스템 | **수동 대사(재조회) 버튼** | Webhook 유실 시 조회 API로 복구 |

> 5번은 선택이 아니라 **권장 필수**입니다. Webhook은 총 5회 시도 후 종료되며 자동 복구·수동 재발송 수단이 없으므로, 파트너 측 조회 기반 대사 수단이 있어야 장애 시 데이터가 맞습니다.

### Cryptoments가 제공하는 화면 (파트너사가 개발하지 않음)

- **파트너 콘솔** `https://partner.cryptoments.cc` — 입금/출금/지갑/정산/수수료/Webhook 설정/API Key 관리
- **결제 위젯** `https://widget.cryptoments.cc` — 회원용 결제 화면 전체

## 1.8 진행 일정 (표준 4단계)

| 단계 | 내용 | 담당 | 기간(표준) |
|---|---|---|---|
| 1. 범위 확정 | 방식 A/B/C 선택, 결제수단 선택, 정책(한도·승인) 합의 | 양사 | 1~2일 |
| 2. 계정 발급 | 파트너 등록, API Key·Secret, 콘솔 계정, 테스트 환경 | Cryptoments | 1일 |
| 3. 개발·연동 | 위젯 삽입 / 서버 API / Webhook 수신 구현 | 파트너사 | 3~10일 |
| 4. 검수·오픈 | 소액 실거래 테스트, Webhook 재현 테스트, 대사 확인 | 양사 | 2~3일 |

---

# 2부. 개발자용 — API 작동 방식

## 2.0 환경

| 구분 | 값 |
|---|---|
| Open API Base URL | `https://api.cryptoments.cc` |
| 위젯 SDK | `https://widget.cryptoments.cc/sdk/widget.cryptoments.js` |
| 위젯 앱 Base | `https://widget.cryptoments.cc` |
| 파트너 콘솔 | `https://partner.cryptoments.cc` |
| 헬스체크 | `GET /ping` → `pong` |
| 시각 형식 | 타임존 오프셋 없는 로컬 시각 문자열 `yyyy-MM-dd HH:mm:ss` (단, **Webhook의 `confirmedAt`만 `yyyy-MM-dd'T'HH:mm:ss`** — 구분자 `T`) |
| 기준 타임존 | **계약 시 별도 고지.** 시각 기반 대사에는 Webhook의 `timestamp`(Unix epoch 초, 타임존 무관)를 쓰십시오 |
| 인코딩 | UTF-8 / `Content-Type: application/json` |

> **응답에서 값이 null인 필드는 키 자체가 생략됩니다.** 파서는 필드 부재를 허용해야 합니다.
> **금액 필드는 JSON 문자열**로 내려갑니다(정밀도 손실 방지). 파싱 시 `BigDecimal`/`Decimal` 계열을 쓰십시오. 최대 소수점 18자리.

---

## 2.1 인증 — 서버 대 서버 (Open API)

`/api/v1/*` 호출에는 HMAC 서명 헤더 3개가 필요합니다. (키 발급·재발급 절차는 [§2.8](#28-api-key-발급재발급회전))

| 헤더 | 값 |
|---|---|
| `X-API-KEY` | 발급받은 API Key |
| `X-TIMESTAMP` | Unix epoch **초** (밀리초 아님) |
| `X-ACCESS-TOKEN` | `Base64( HMAC-SHA256( apiSecret, "{X-TIMESTAMP}.{X-API-KEY}" ) )` |

- 서명 대상 문자열은 **`타임스탬프 + "." + API Key`** 뿐입니다. HTTP 메서드·경로·바디는 포함하지 않습니다.
- 인코딩은 **Base64**(hex 아님).
- 타임스탬프 허용 오차 **±300초**. 서버 시각과 5분 이상 어긋나면 401.
- 파트너 상태가 `ACTIVE`가 아니면(정지 등) 즉시 401.

```bash
TS=$(date +%s)
SIG=$(printf "%s.%s" "$TS" "$API_KEY" \
      | openssl dgst -sha256 -hmac "$API_SECRET" -binary | base64)

curl -X POST https://api.cryptoments.cc/api/v1/users/deposit-wallet \
  -H "X-API-KEY: $API_KEY" \
  -H "X-TIMESTAMP: $TS" \
  -H "X-ACCESS-TOKEN: $SIG" \
  -H "Content-Type: application/json" \
  -d '{"partnerUserId":"user-001","chainType":"BSC","currencyType":"USDT"}'
```

```javascript
// Node.js
const crypto = require('crypto');
function authHeaders(apiKey, apiSecret) {
  const ts = Math.floor(Date.now() / 1000).toString();
  const sig = crypto.createHmac('sha256', apiSecret)
                    .update(`${ts}.${apiKey}`)
                    .digest('base64');
  return { 'X-API-KEY': apiKey, 'X-TIMESTAMP': ts, 'X-ACCESS-TOKEN': sig };
}
```

> ⚠️ **위젯 토큰 발급 API(`/widgets/auth/token`)는 서명 규칙이 다릅니다.** 헤더 이름도 `X-SIGNATURE`이고, 서명 대상은 `METHOD + PATH + TIMESTAMP`, 인코딩은 **hex**입니다. 두 규칙을 혼동하지 마십시오 — [2.2](#22-인증--위젯-토큰) 참조.

---

## 2.2 인증 — 위젯 토큰

위젯은 **회원 단위 단기 세션 토큰**으로 동작합니다. 브라우저에 API Secret이 절대 노출되면 안 되므로, **파트너 서버가 중계**합니다.

```
[브라우저] SDK가 파트너 서버의 토큰 발급 엔드포인트 호출
              POST /api/get-widget-token  { partnerUserId, permissions }
[파트너 서버] HMAC 서명 생성 →
              POST https://api.cryptoments.cc/widgets/auth/token
              헤더: X-API-KEY / X-TIMESTAMP / X-SIGNATURE
              바디: { partnerUserId, permissions }
[Cryptoments] → { accessToken, tokenType:"Bearer", expiresIn:86400, permissions, scope:"widget" }
[파트너 서버] 응답을 그대로 브라우저에 반환
[SDK] accessToken을 위젯 URL 쿼리로 전달 → 위젯이 이후 API 호출에 사용
```

| 항목 | 값 |
|---|---|
| 엔드포인트 | `POST /widgets/auth/token` |
| 헤더 | `X-API-KEY`, `X-TIMESTAMP`(초), `X-SIGNATURE` |
| 서명 | `hex( HMAC-SHA256( apiSecret, "POST" + "/widgets/auth/token" + timestamp ) )` |
| 바디 | `{ "partnerUserId": "user-001", "permissions": ["DEPOSIT_READ","DEPOSIT_CREATE"] }` |
| 토큰 유효기간 | **86400초 (24시간)** |

> `permissions`는 현재 서버에서 강제되지 않는 참고 값입니다. SDK가 보낸 값을 그대로 중계하십시오. **접근 통제는 아래 "반드시 지켜야 할 것" 1·2번(파트너 세션 검증)에 전적으로 의존합니다.**

```javascript
// 파트너 서버 (Express 예시)
app.post('/api/get-widget-token', async (req, res) => {
  const { partnerUserId, permissions } = req.body;
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const message = 'POST' + '/widgets/auth/token' + timestamp;
  const signature = crypto.createHmac('sha256', API_SECRET).update(message).digest('hex');

  const r = await fetch('https://api.cryptoments.cc/widgets/auth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-KEY': API_KEY,
      'X-TIMESTAMP': timestamp,
      'X-SIGNATURE': signature
    },
    body: JSON.stringify({
      partnerUserId,
      permissions: permissions || ['DEPOSIT_READ', 'DEPOSIT_CREATE']
    })
  });
  res.json(await r.json());
});
```

**반드시 지켜야 할 것**

1. 이 엔드포인트는 **파트너 사이트의 로그인 세션을 먼저 검증**해야 합니다. 검증 없이 열어두면 임의의 `partnerUserId`로 토큰이 발급됩니다.
2. `partnerUserId`는 클라이언트가 보낸 값을 그대로 쓰지 말고 **서버 세션에서 꺼내 쓰십시오.**
3. 토큰 갱신(`/widgets/auth/refresh`)은 **현재 미구현(501)** 입니다. 24시간 만료 시 재발급받으십시오.

---

## 2.3 위젯 삽입 (방식 A)

```html
<script src="https://widget.cryptoments.cc/sdk/widget.cryptoments.js" type="module"></script>
```

> ⚠️ SDK는 ES 모듈이라 **지연 실행(defer)** 됩니다. 위젯 생성 코드를 일반 `<script>`에 두면 `CryptomentsWidget is not defined`가 납니다. 반드시 `<script type="module">` 안에 두거나 `DOMContentLoaded` 이후에 실행하십시오.

```javascript
const widget = new CryptomentsWidget({
  mode: 'modal',            // 'modal'(iframe 오버레이) | 'window'(팝업)
  widgetType: 'deposit',
  partnerApiUrl: '/api/get-widget-token',   // 파트너 서버 토큰 발급 경로
  onSuccess: (result) => { /* result.type 으로 분기 */ },
  onError:   (error)  => { console.error(error.message); },
  onClose:   ()       => { /* 잔고 재조회 트리거 권장 */ }
});

// 충전 버튼 클릭 시
await widget.openDeposit('user-001', {
  presetAmount: 50000,
  presetAmountCurrency: 'KRW',   // 'KRW' | 'USD'
  allowAmountEdit: true,
  amountOptions: [10000, 50000, 100000],
  chainType: 'BSC',              // 생략 시 회원이 선택
  orderId: 'ORD-20260812-001',   // 파트너 주문 ID → 완료 콜백·Webhook에 그대로 반환
  methods: ['axim', 'crypto', 'trade']  // 노출할 결제수단 화이트리스트
});
```

### 성공 콜백 분기

```javascript
onSuccess: (result) => {
  switch (result.type) {
    case 'deposit':       /* USDT 직접 입금  */ break;
    case 'trade':         /* 원화 충전(LP)   */ break;
    case 'p2p':           /* P2P 매칭 충전   */ break;
    case 'axim_payment':  /* Axim Pay 결제   */ break;
    case 'withdrawal':    /* 출금 요청 완료  */ break;
  }
  // ⚠️ 여기서 잔고를 올리지 말 것. 화면 안내만 하고 잔고는 Webhook 기준으로 반영.
}
```

### 세분화 이벤트 (선택)

```javascript
const id = widget.addEventListener('depositCompleted', (e) => console.log(e.data));
widget.removeEventListener('depositCompleted', id);
```

주요 이벤트: `depositCompleted`, `tradeTopupCompleted`, `p2pTopupCompleted`, `p2pTopupCancelled`, `withdrawalCompleted`, `error`, `close`.

### 출금 위젯을 쓰는 경우

`openWithdrawal()`은 `reqWithdraw` 콜백이 **필수**입니다. 위젯이 이 콜백으로 출금 데이터를 넘기면, **파트너 서버가 자체 심사 후** Cryptoments 출금 API를 호출하고 결과를 반환하는 구조입니다.

```javascript
options.reqWithdraw = async (withdrawalData) => {
  const r = await fetch('/api/process-withdrawal', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ withdrawalData })
  });
  if (!r.ok) throw new Error('출금 요청 실패');
  return await r.json();
};
```

---

## 2.4 링크 방식 (방식 B)

파트너 콘솔에서 생성하거나 파트너 API로 생성합니다. 생성된 URL을 회원에게 전달하면 브라우저에서 바로 열립니다.

| 링크 종류 | 용도 | URL 형태 |
|---|---|---|
| 결제 링크 | 범용 (결제수단 선택 화면) | `https://widget.cryptoments.cc/link/{linkCode}` |
| 매칭 링크 | 원화 충전 통합(P2P→TORQ 자동 폭포) | `https://widget.cryptoments.cc/m/{linkCode}` |
| TORQ 링크 | 원화 충전(LP 고정) | `https://widget.cryptoments.cc/torq/{linkCode}` |
| 폰페이 링크 | 은행이체 전용 | `https://widget.cryptoments.cc/phonepay/{linkCode}` |

생성 시 지정 가능: 제목, 금액(고정 또는 회원 입력), 통화·네트워크, 파트너 주문번호(`partnerReference`), 만료 시간.

> 링크는 최상위 탭으로 열리므로 파트너 페이지로의 콜백이 보장되지 않습니다. **완료 판정은 Webhook으로만** 하십시오.

---

## 2.5 서버 API 엔드포인트 (방식 C)

### 2.5.1 입금 주소 발급·조회

```
POST /api/v1/users/deposit-wallet
```

| 요청 | 필수 | 설명 |
|---|---|---|
| `partnerUserId` | ✅ | 파트너 회원 ID |
| `chainType` | ✅ | `BSC`, `ETH`, `TRON` 등 |
| `currencyType` | ✅ | `USDT`, `USDC` 등 |

응답: `id`, `address`, `chainType`, `currencyType`, `status`, `createdAt`

- **멱등**: 같은 `(회원, 체인)`에 대해 재호출하면 기존 주소를 반환합니다. 신규 발급되지 않습니다.
- 주소는 체인 단위이므로 같은 체인의 서로 다른 토큰은 **같은 주소**입니다.

### 2.5.2 출금 요청

```
POST /api/v1/users/withdrawal
```

| 요청 | 필수 | 설명 |
|---|---|---|
| `amount` | ✅ | 출금 수량. 최소 `0.000001`, 그리고 **10 USD 상당 이상** |
| `currencyType` | ✅ | `USDT` 등 |
| `chainType` | ✅ | `BSC` 등 |
| `toAddress` | ✅ | 수신 주소 |
| `partnerUserId` | | 파트너 회원 ID |
| `orderId` | | **파트너 주문번호 = 멱등키. 지정을 강력히 권장** (최대 255자) |
| `amountUnit` | | `"CRYPTO"`(기본) 또는 `"KRW"` |
| `krwAmount` | | `amountUnit="KRW"`일 때 원화 금액. 서버가 시세로 환산 |
| `metadata` | | 파트너 임의 JSON 문자열. Webhook에 그대로 반환됨 |

응답: `transactionId`, `status`, `tokenAmount`, `krwAmount`, `tokenKrwPrice`, `createdAt`

> **이 응답에 `transactionHash`는 포함되지 않습니다.** 온체인 해시는 `WITHDRAWAL_CONFIRMED` Webhook 또는 조회 API로 받으십시오.

**초기 `status`는 `PENDING_APPROVAL` 또는 `APPROVED`** 입니다(자동승인 임계값 설정에 따름).

#### orderId 멱등 규칙 (중요)

| 상황 | 결과 |
|---|---|
| `orderId` 미지정 | 멱등 검사 없음. 대신 **10초 내 동일 조건 재요청은 409(`WITHDRAWAL_IN_PROGRESS`)로 거부** |
| 같은 `orderId` + **내용 동일** | 기존 건을 그대로 **200 반환** (중복 생성 없음) → 타임아웃 재시도에 안전 |
| 같은 `orderId` + **내용 상이** | **409 `DUPLICATE_ORDER_ID`** |
| 이전 건이 `FAILED`/`CANCELLED`/`REJECTED`/`EXHAUSTED` | 같은 `orderId` **재사용 가능** |

"내용 동일" 판정 기준은 `amount` + `toAddress` + `currencyType` + `chainType` 4가지입니다.

> **네트워크 타임아웃 시 반드시 같은 `orderId`로 재시도하십시오.** 새 `orderId`로 재시도하면 이중 출금이 발생합니다.

### 2.5.3 조회

| 엔드포인트 | 용도 |
|---|---|
| `GET /api/v1/users/{userId}/transactions` | 회원 입출금 내역 (입금·출금 통합) |
| `GET /api/v1/users/{userId}/deposits/pending` | 회원의 `CONFIRMED` 상태 입금 (집금 전 단계). **파트너 확정 여부와 무관하므로 대사용으로 쓰지 말 것** |
| `GET /api/v1/transactions/deposits/unconfirmed` | 파트너 전체 미확정(파트너가 아직 반영하지 않은) 입금 — **대사는 이 API 사용** |
| `GET /api/v1/partner/balances` | 파트너 MASTER 지갑 잔액 |
| `GET /api/v1/partner/wallets` | 회원 입금(HOT) 지갑 목록 |
| `GET /api/v1/partner/chains` | 파트너에 활성화된 체인 |
| `GET /api/v1/chains` | 지원 체인·토큰 전체 (인증 불필요) |
| `GET /api/v1/currency-prices` 외 | 시세 조회 및 KRW↔USDT 환산 |

아래는 `GET /api/v1/transactions/deposits/unconfirmed`의 **전체** 필드입니다.

`transactionId`, `transactionType`(`DEPOSIT`/`WITHDRAWAL`), `chainType`, `currencyType`, `fromAddress`, `toAddress`, `amount`, `priceKrw`, `priceUsd`, `txHash`, `blockNumber`, `status`, `partnerConfirmed`, `partnerConfirmedAt`, `partnerUserId`, `walletAddress`, `createdAt`, `confirmedAt`

엔드포인트별로 제공 필드가 다릅니다 (§2.0의 "null 필드는 키 생략" 규칙과 겹쳐 **키 자체가 사라집니다**).

| 엔드포인트 | 미제공 필드 |
|---|---|
| `/users/{userId}/transactions` | `priceUsd`, `blockNumber`, `partnerConfirmed`, `partnerConfirmedAt`, `walletAddress` (출금 항목은 `fromAddress`도 없음) |
| `/users/{userId}/deposits/pending` | 위 항목 + `fromAddress`, `toAddress`, `priceKrw` |

> 조회 API는 **페이지네이션이 없습니다.** 대량 조회 시 응답이 커지므로, 상시 폴링보다 Webhook + 주기적 대사 조합을 권장합니다.

### 2.5.4 입금 확정 처리 (선택)

파트너가 "이 입금을 우리 시스템에 반영했다"고 표시하는 플래그입니다. Cryptoments의 입금 상태와는 **독립적**입니다.

```
POST /api/v1/transactions/deposits/{transactionId}/confirm
POST /api/v1/transactions/deposits/confirm-batch    body: { "transactionIds": [1,2,3] }
```

미처리 건만 조회(`.../deposits/unconfirmed`)하는 방식으로 **유실 없는 대사 루프**를 만들 수 있습니다. Webhook 유실 대비 안전망으로 권장합니다.

### 2.5.5 상태 값

**출금 `status`**

| 값 | 의미 |
|---|---|
| `REQUESTED` | 요청 접수 |
| `PENDING_APPROVAL` | 승인 대기 |
| `APPROVED` | 승인 완료 |
| `REJECTED` | 거부 (종결) |
| `P2P_PENDING` | 원화 경로 전환 — 매칭 대기 |
| `PROCESSING` / `BROADCASTING` | 전송 처리 중 |
| `CONFIRMED` | 온체인 확정 |
| `COMPLETED` | 완료 (원화 경로 종결) |
| `FAILED` / `CANCELLED` / `EXHAUSTED` | 실패·취소·재시도 소진 (종결) |
| `STALE` | 장시간 미확정 — 운영 확인 필요 |

**입금 `status`**: `DETECTED` → `CONFIRMING` → `CONFIRMED` → `NOTIFIED` → `COLLECTING` → `SETTLED` (실패 시 `FAILED`)
→ 파트너는 **`CONFIRMED` 이후를 입금 성립**으로 보면 됩니다.

---

## 2.6 Webhook (필수 구현)

### 2.6.1 등록

파트너 콘솔 **설정 > 연동 > Webhook**에서 수신 URL을 등록합니다.
(API로도 가능: `PUT /api/partner/integration/webhook` — 단 이는 **파트너 콘솔 세션 인증** 기반이며 §2.1의 Open API HMAC으로는 호출되지 않습니다.)

- 파트너당 **URL 1개**. 이벤트별 구독 설정은 없으며 등록 시 **전 이벤트를 수신**합니다.
- 미등록(NULL)이면 발송 자체가 일어나지 않습니다.
- 콘솔의 **[테스트 전송]** 버튼으로 수신 확인이 가능합니다(단, 테스트 페이로드는 실제 이벤트와 형태가 다릅니다).

### 2.6.2 이벤트 종류 (전 10종)

| eventType | 발생 시점 |
|---|---|
| `DEPOSIT_CONFIRMED` | 입금 확정 — **온체인·폰페이·TORQ·P2P(TORQ 레그) 모두 이 값** |
| `WITHDRAWAL_REQUESTED` | 출금 요청 접수 |
| `WITHDRAWAL_APPROVED` | 출금 승인 |
| `WITHDRAWAL_REJECTED` | 출금 거부 |
| `WITHDRAWAL_CANCELLED` | 출금 취소 |
| `WITHDRAWAL_P2P_PENDING` | 출금이 원화 경로로 전환 |
| `WITHDRAWAL_CONFIRMED` | 출금 온체인 확정 |
| `WITHDRAWAL_COMPLETED` | 원화 경로 출금 완료 |
| `WITHDRAWAL_FAILED` | 출금 실패 |
| `WITHDRAWAL_EXHAUSTED` | 재시도 소진 종결 |

> 입금 경로(직접/원화/P2P)는 `eventType`이 아니라 페이로드의 **`depositMethod`** 로 구분합니다.
>
> ⚠️ **예외: P2P 매칭에서 파트너가 원화를 직접 수령하는 레그는 현재 Webhook이 발송되지 않습니다.** 해당 경로를 쓰는 경우 §2.5.4의 대사 폴링이 **필수**입니다. (개선 예정)

### 2.6.3 페이로드

전송: `POST {등록한 URL}`, `Content-Type: application/json`

**입금**

```json
{
  "eventType": "DEPOSIT_CONFIRMED",
  "transactionId": 12345,
  "partnerId": "7",
  "userId": "user-001",
  "orderId": "ORD-20260812-001",
  "orderCode": "dep_2608_a1b2c3d4",
  "transactionHash": "0xabc...",
  "fromAddress": "0x...",
  "toAddress": "0x...",
  "amount": "100.000000000000000000",
  "reservedAmount": "100.000000000000000000",
  "reservedAmountKrw": "141800",
  "currencyType": "USDT",
  "chainType": "BSC",
  "depositMethod": "HD_WALLET",
  "status": "CONFIRMED",
  "confirmedAt": "2026-08-12T09:15:00",
  "tokenKrwPrice": "1418.00",
  "tokenUsdPrice": "1.0002",
  "krwAmount": "141800",
  "usdAmount": "100.02",
  "timestamp": "1786000000",
  "signature": "3f5a...(hex)"
}
```

**출금**

```json
{
  "eventType": "WITHDRAWAL_CONFIRMED",
  "event": "WITHDRAWAL_CONFIRMED",
  "withdrawalId": 6789,
  "transactionId": 6789,
  "partnerId": "7",
  "userId": "user-001",
  "orderId": "ORD-W-20260812-001",
  "metadata": "{\"memo\":\"user payout\"}",
  "transactionHash": "0xdef...",
  "fromAddress": "0x...(파트너 MASTER)",
  "toAddress": "0x...",
  "amount": "50.000000000000000000",
  "currencyType": "USDT",
  "chainType": "BSC",
  "status": "CONFIRMED",
  "confirmedAt": "2026-08-12T09:20:00",
  "tokenKrwPrice": "1418.00",
  "tokenUsdPrice": "1.0002",
  "krwAmount": "70900",
  "usdAmount": "50.01",
  "feeAmount": "0",
  "timestamp": "1786000100",
  "signature": "9b2c...(hex)"
}
```

주의사항:

- `reservedAmount` / `reservedAmountKrw`는 **입금예약(금액 지정) 경로에서만** 존재하는 예상 금액입니다. 실제 `amount`와 비교해 부분입금·초과입금을 판별할 수 있습니다.
- `event` / `withdrawalId`는 **하위호환용 중복 키**입니다. 신규 연동은 `eventType` / `transactionId`를 쓰십시오.
- **null 필드는 키 자체가 없습니다** (`orderId`, `orderCode`, `metadata`, `confirmedAt` 등).
- `transactionHash`는 온체인 tx가 없는 단계·경로에서 **빈 문자열 `""`** 입니다.
- 환율은 **거래 시점 확정 환율**이 우선 사용됩니다(발송 시점 시세가 아님) — 파트너 정산 기준과 일치시키기 위함.

### 2.6.4 서명 검증

> ⚠️ **서명은 HTTP 헤더가 아니라 바디의 `signature` 필드**에 들어 있습니다.

```
signature = hex( HMAC-SHA256( apiSecret, "{partnerId}|{transactionHash}|{amount}|{timestamp}" ) )
```

네 값 모두 페이로드에 그대로 있으므로 재현 가능합니다. `transactionHash`가 `""`인 경우 서명 대상에도 `""`를 넣습니다.

```javascript
const crypto = require('crypto');

function verify(body, apiSecret) {
  const data = `${body.partnerId}|${body.transactionHash}|${body.amount}|${body.timestamp}`;
  const expected = crypto.createHmac('sha256', apiSecret).update(data).digest('hex');
  const got = body.signature || '';
  if (got.length !== expected.length) return false;   // ← 길이 선검사 필수
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got));
}
```

> ⚠️ **길이 선검사를 빠뜨리지 마십시오.** `timingSafeEqual`은 두 버퍼 길이가 다르면 `false`가 아니라 `RangeError`를 던집니다. 서명 생성이 실패한 경우 `signature`가 빈 문자열로 내려올 수 있어, 예외로 수신 핸들러가 죽으면 재시도 소진 후 이벤트가 유실됩니다.

서명 대상은 위 4개 필드로 한정됩니다(부분 서명). `amount`·`transactionHash` 위변조는 탐지되지만 `toAddress` 등은 서명에 포함되지 않으므로, **금액 반영은 서명 검증 + 조회 API 대사**를 함께 쓰는 것이 안전합니다.

### 2.6.5 멱등성 (필수)

**멱등키 = `eventType` + `transactionId`**

```sql
UNIQUE KEY uk_webhook (event_type, transaction_id)
```

- `transactionId` 단독은 부적절합니다. 하나의 출금건이 생애주기 동안 **최대 9종의 이벤트**를 같은 `transactionId`로 보냅니다.
- **`transactionHash`를 멱등키로 쓰지 마십시오.** 온체인 tx 이전 단계에서는 모든 건이 `""` 이므로 두 번째부터 전부 중복 폐기됩니다.
- 재시도 시 페이로드는 `timestamp`·`signature` 포함 **완전히 동일**하게 재전송됩니다.

### 2.6.6 응답·재시도

| 항목 | 규칙 |
|---|---|
| 성공 판정 | **HTTP 2xx** |
| **응답 형식 (필수)** | `Content-Type: application/json` + **JSON 객체 바디** (예: `{"success":true}`). 빈 바디·비JSON 응답은 실패로 처리되어 재시도될 수 있습니다 |
| 처리 안 하는 이벤트 | **그래도 200을 반환**해야 재시도가 걸리지 않음 |
| 권장 패턴 | 즉시 200 반환 후 비동기 처리 (동기 처리 중 타임아웃 시 중복 발송) |
| 재시도 | 최초 1회 + 재시도 4회 = **총 5회**. 간격 30초 → 60초 → 5분 → 15분 |
| 총 소요 | **약 21~26분** 후 종료 (재시도 활성화 스케줄러가 1분 주기라 각 간격에 최대 +60초) |
| 최종 실패 | `FAILED` 기록. **자동 복구·수동 재발송 수단 없음** |

> **그래서 대사 루프가 필요합니다.** 파트너 서버 장애가 약 25분을 넘기면 그 사이 이벤트는 영구 유실됩니다. `GET /api/v1/transactions/deposits/unconfirmed` 를 주기적으로(예: 5분) 폴링해 누락분을 메우는 로직을 반드시 두십시오.

수신 로그는 파트너 콘솔에서 확인할 수 있습니다. (API로는 `GET /api/partner/integration/webhook/logs` — 파트너 콘솔 세션 인증 기반이며 Open API HMAC으로는 호출되지 않습니다.)

---

## 2.7 에러 응답

```json
{
  "code": "DUPLICATE_ORDER_ID",
  "message": "동일한 orderId 로 이미 접수된 요청이 있습니다.",
  "description": "orderId=ORDER-001, withdrawalCode=wdr_2608_a1b2c3d4"
}
```

- **분기는 반드시 `code`로** 하십시오. `message`는 한글 고정 문구이며 변경될 수 있습니다.
- `code`는 **문자열로 비교**하십시오. 409 비즈니스 에러는 영문 코드지만, 404 등 일부는 숫자 문자열입니다(예: `"860"` = 미지원 통화, `"600"` = 미지원 체인).
- `description`은 대조용 상세 정보(선택).

| HTTP | 상황 |
|---|---|
| 400 | 필수 파라미터 누락·형식 오류, `INVALID_METADATA` |
| 401 | 서명 불일치, 타임스탬프 만료(±5분 초과), 비활성 파트너 |
| 404 | 미지원 체인/통화, 대상 없음 |
| 409 | 정책 위반 및 중복 — 아래 표 |

| code | 의미 |
|---|---|
| `MIN_AMOUNT_NOT_MET` | 최소 출금 금액(10 USD) 미달 |
| `SINGLE_LIMIT_EXCEEDED` | 건당 한도 초과 |
| `DAILY_LIMIT_EXCEEDED` | 일일 한도 초과 |
| `ADDRESS_NOT_WHITELISTED` | 화이트리스트 미등록 주소 |
| `INSUFFICIENT_BALANCE` | 잔액 부족 |
| `DUPLICATE_ORDER_ID` | 동일 orderId + 내용 상이 |
| `WITHDRAWAL_IN_PROGRESS` | orderId 없이 10초 내 동일 조건 재요청 |

---

## 2.8 API Key 발급·재발급·회전

### 2.8.1 최초 발급

키는 **파트너 계정이 생성되는 시점에 자동으로 1회 생성**됩니다. 별도 신청 절차는 없습니다.

| 항목 | 값 |
|---|---|
| API Key | `pk_` + 32자리 소문자 hex (총 35자) — 예: `pk_3f2a1b9c4d5e6f708192a3b4c5d6e7f8` |
| API Secret | `sk_` + 32자리 소문자 hex (총 35자) — 예: `sk_a1b2c3d4e5f60718293a4b5c6d7e8f90` |
| 정규식 | `^pk_[0-9a-f]{32}$` / `^sk_[0-9a-f]{32}$` |

발급 경로는 둘입니다.

1. **Cryptoments 담당자가 파트너를 등록** → 담당자가 안전한 채널로 Key/Secret 전달
2. **총판 파트너가 하위 파트너를 직접 등록** → 파트너 콘솔 > 하위 파트너 관리 > 등록 완료 화면에 **1회만** 표시

> **API Secret은 발급 시점 이후 어느 화면에서도 다시 볼 수 없습니다.** 전달받는 즉시 비밀 저장소(Vault/KMS/환경변수)에 보관하십시오. 분실 시 재발급 외에는 복구 수단이 없습니다.
> API Key는 파트너 콘솔 `설정 > 연동 설정 > API 키` 탭에서 언제든 다시 확인할 수 있습니다. (화면 라벨이 `API Key (마스킹)`으로 표기되어 있으나 **전체 값이 그대로 표시**됩니다.)

### 2.8.2 키가 쓰이는 곳 — 3군데

| # | 대상 | 사용 형태 |
|---|---|---|
| 1 | Open API 호출 | 헤더 `X-API-KEY` + Secret으로 서명 ([§2.1](#21-인증--서버-대-서버-open-api)) |
| 2 | 위젯 토큰 발급 | 헤더 `X-API-KEY` + Secret으로 서명 ([§2.2](#22-인증--위젯-토큰)) |
| 3 | Webhook 수신 검증 | Secret으로 바디 `signature` 재현·비교 ([§2.6.4](#264-서명-검증)) |

**재발급하면 이 3군데가 동시에 깨집니다.** 회전 계획 시 반드시 3곳 모두를 함께 교체하십시오.

### 2.8.3 재발급 절차

파트너 콘솔 → **설정 > 연동 설정 > `API 키` 탭** (`/partner/settings/integration?tab=api`)

1. **사전 준비** — 새 키를 즉시 반영할 수 있도록 서버 3곳(API 클라이언트 / 위젯 토큰 발급 서버 / Webhook 검증기)의 설정 변경·배포를 대기 상태로 준비합니다.
2. `API Key 재발급` 클릭 → 경고 확인
3. 2FA 사용 중이면 **OTP 6자리** 입력
4. **새 값을 두 곳에서 각각 복사**합니다.
   - **새 Secret** — 재발급 직후 나타나는 초록색 결과 박스. 라벨은 `API Key`로 되어 있으나 **실제 값은 Secret(`sk_`로 시작)** 이며 **이 화면에서만 1회 노출**됩니다. (표기 수정 예정)
   - **새 API Key** — 화면 상단 `API Key (마스킹)` 항목이 자동으로 새 `pk_` 값으로 갱신됩니다(새로고침 불필요).
   - 접두어(`pk_` / `sk_`)로 구분하십시오.
5. 서버 3곳을 새 Key/Secret으로 교체 후 배포
6. 검증: Open API 200 응답 / 위젯 토큰 발급 성공 / Webhook 서명 검증 통과

> ⚠️ **무중단 회전은 불가능합니다.** 재발급 즉시 구 키가 무효화되며 유예 기간이 없습니다. 교체가 끝나기 전까지 모든 API 호출은 401, Webhook 서명은 불일치 상태가 됩니다. **거래가 없는 시간대에 진행하십시오.**

### 2.8.4 유출 의심 시

1. 즉시 위 재발급 절차 수행 (가장 빠른 차단)
2. 동시에 Cryptoments 담당자에게 통보 → 필요 시 파트너 상태를 일시 정지하여 모든 인증을 즉시 차단
3. 유출 경위와 영향 범위(어느 서버에서 노출되었는지)를 공유

> Cryptoments의 파트너 API Key는 **Axim Pay·폰페이 등 외부 벤더 키와 완전히 별개**입니다. 한쪽을 재발급해도 다른 쪽은 영향받지 않습니다.

---

## 2.9 Axim Pay 연동 설정

Axim Pay는 회원이 **Axim Wallet 앱**으로 결제를 승인하는 방식입니다. 회원이 지갑 주소를 복사·전송할 필요가 없어 이탈률이 낮습니다.

### 2.9.1 필요한 값

| 값 | 출처 | 파트너 입력 |
|---|---|---|
| Axim API Key | Axim에서 발급 | ✅ **입력 필요** |
| Axim API Secret | Axim에서 발급 | ✅ **입력 필요** |
| Site ID | Cryptoments가 Axim에 조회해 자동 획득 | ❌ 자동 |
| Webhook URL | Cryptoments가 생성·자동 등록 (`https://api.cryptoments.cc/webhooks/axim/{파트너ID}`) | ❌ 자동 |
| MASTER 지갑 주소 | Cryptoments 지갑을 체인별로 Axim에 자동 등록 | ❌ 자동 |
| connectId / connectWalletToken | 위젯·Axim이 자동 처리 | ❌ 자동 |

**파트너가 입력하는 값은 API Key와 Secret 두 개뿐**이며, 나머지는 활성화 버튼 한 번으로 자동 처리됩니다.

### 2.9.2 설정 절차

1. **[파트너]** Axim(`pay.axim.one`)에 파트너 계정을 개설하고 **API Key / API Secret**을 발급받습니다.
2. **[Cryptoments]** 해당 파트너에 MASTER 지갑이 생성되어 있는지 확인합니다. *(없으면 Axim 측 수신 지갑 등록이 비어 결제가 실패합니다.)*
3. **[파트너]** 파트너 콘솔 → **설정 > 연동 설정 > `Axim Pay` 탭** → API Key, API Secret 입력
4. **[파트너]** **활성화 토글 ON → 저장**
5. **[자동]** 저장 시 Cryptoments가 순서대로 수행합니다.
   - Axim에 키 유효성 검증 요청 → 실패 시 활성화되지 않고 오류 반환
   - Site ID 자동 획득·저장
   - Axim에 Webhook 수신 URL 자동 등록
   - 파트너 MASTER 지갑을 체인별로 Axim에 자동 등록
   - 활성화 완료

> ⚠️ **MASTER 지갑 등록은 실패해도 활성화가 성공 처리됩니다.** 체인별로 개별 시도하며 실패 시 건너뜁니다(등록 통화는 USDT 고정). 따라서 "활성화 성공"만으로 수신 지갑이 등록되었다고 판단하지 마시고, 반드시 아래 6단계의 실결제 확인을 거치십시오.

6. **[확인]** 탭 재진입 시 활성화 토글이 ON으로 유지되는지 / 위젯 결제수단에 **Axim Pay**가 노출되는지 / **소액 실결제 1건**이 `입금 관리 > Axim Pay` 목록에 CONFIRMED로 남고 잔고까지 반영되는지
   - `Axim Pay` 메뉴 자체는 활성화 여부와 무관하게 항상 노출되므로 판정 근거로 쓰지 마십시오.

### 2.9.3 회원 연결 흐름

Axim Pay는 결제 전에 **회원 지갑을 파트너 사이트에 1회 연결**해야 합니다.

```
[회원] 위젯에서 "Axim Pay" 선택
   → 위젯이 QR 코드 / "앱으로 이동" 딥링크 표시
   → [회원] Axim Wallet 앱에서 사이트 연결 승인
   → [Axim] connection.succeeded 웹훅 → Cryptoments가 연결 정보 저장
   → 위젯이 연결 상태를 폴링해 자동으로 결제 단계로 진행
```

- 연결은 **회원당 1개만 유효**합니다. 재연결하면 이전 연결은 자동 해제됩니다.
- 파트너가 별도로 구현할 것은 없습니다. 위젯이 QR·딥링크·폴링을 모두 처리합니다.
- 파트너는 **회원 안내 문구**(Axim Wallet 앱 설치 필요)만 준비하면 됩니다.

### 2.9.4 결제 흐름

```
[회원] 위젯에서 금액 입력 → Axim Pay 선택
   → [Cryptoments] 결제 요청 생성 → Axim으로 push
   → [회원] Axim 앱에서 승인 (딥링크로 이동)
   → [Axim] payment.succeeded 웹훅 → 결제 확정
   → 온체인 입금 도착 → DEPOSIT_CONFIRMED Webhook 발송 → 파트너 잔고 반영
```

- **회원이 Axim 앱에서 승인해야 하는 시간은 Axim 기준 15분**입니다. Cryptoments는 지연 콜백 수용을 위해 20분까지 결제를 유지하며(만료 배치는 5분 주기), 15분을 넘겨 도착한 성공 콜백도 정상 반영합니다. **회원 안내 문구는 15분 기준으로 작성하십시오.**
- **결제 링크(`linkId`)를 통해 생성된 결제에 한해**, 같은 링크에 진행 중인 결제가 있으면 새 결제를 만들지 않고 기존 건을 반환합니다(중복 결제 차단). 링크 없이 위젯에서 직접 생성하거나 파트너 콘솔에서 직접 요청하는 경우에는 **서버측 중복 차단이 적용되지 않으므로** 파트너 측에서 재요청을 제어하십시오.
- 회원 잔고 반영은 다른 입금 경로와 동일하게 **`DEPOSIT_CONFIRMED` Webhook 기준**입니다. Axim 결제 성공 자체가 아니라 **입금 확정**을 기준으로 하십시오.

### 2.9.5 파트너 콘솔에서 할 수 있는 것

| 메뉴 | 기능 |
|---|---|
| 입금 관리 > **Axim Pay** | 결제 내역 조회 (결제코드·사용자·네트워크·수량·KRW·상태·일자, 상태/기간 필터) |
| 회원 관리 > 사용자 상세 > **[액심 결제 요청]** | 특정 회원에게 결제 요청을 직접 발송 |

**[액심 결제 요청] 폼 필드**

| 필드 | 비고 |
|---|---|
| 사용자 ID | 자동(표시 전용) |
| Axim 지갑 | 연결 지갑이 **2개 이상일 때만 표시**. 1개면 자동 선택 |
| 네트워크 | 필수 |
| 통화 | 필수 (네트워크 선택 후 노출) |
| 결제 금액 | 필수 |
| 참조코드 | 선택 — 미입력 시 결제코드로 대체되어 Axim 주문 ID로 전달(대사 키) |

> 버튼은 **연결 지갑 목록을 불러오는 중이거나 연결된 Axim 지갑이 없으면 비활성**입니다. 비활성 사유는 버튼 툴팁에 표시됩니다. 미연결 회원에게는 먼저 위젯으로 지갑 연결을 안내해야 합니다.

### 2.9.6 문제 해결

| 증상 | 원인 · 조치 |
|---|---|
| 위젯에 Axim Pay 결제수단이 **안 보임** | ① Axim 설정이 비활성 → 콘솔에서 활성화 확인 ② 위젯 호출 시 `methods`를 **지정하면서 `axim`을 누락**함 (미지정이면 전체 노출되므로 정상) ③ 위젯 초기화 시 `init-info` 호출 자체가 실패 → 브라우저 네트워크 탭 확인 |
| Axim Pay 카드가 **보이지만 회색으로 눌리지 않음** | 미지원 체인이거나 금액 고정(`skipInput`) 조합 문제 |
| 저장 시 `API Key와 Secret을 먼저 설정해주세요.` | 두 값 중 하나가 비어 있음 (Secret은 저장 후 화면에서 비워지므로 재입력 필요) |
| 탭 진입·결제 생성 시 `Axim 설정을 찾을 수 없습니다.` | 해당 파트너에 Axim 설정이 아직 저장된 적 없음 → 최초 등록 필요 |
| 저장 시 `활성화 중 오류: …` 또는 Axim 원문 오류 (코드 `AXIM_ACTIVATION_FAILED`) | 키가 잘못되었거나 Axim API 호출 실패 → 키 재확인 후 재시도 |
| 결제 생성 시 "지갑을 찾을 수 없음" | 회원의 Axim 지갑 연결이 없음 → 위젯으로 연결 먼저 |
| 결제는 성공했는데 입금이 안 잡힘 | Cryptoments에 문의 — Axim 웹훅 수신/매칭 확인 필요 |
| 설정을 바꿨는데 반영이 안 됨 | 활성화 값이 그대로면 재등록이 일어나지 않습니다. **토글 OFF 저장 → ON 저장**으로 재활성화하십시오.<br>⚠️ OFF 상태에서는 위젯 결제수단이 즉시 사라지고 신규 결제 생성이 차단됩니다(Axim 측 웹훅·지갑 등록은 유지). **진행 중인 결제가 없는 시간대에** OFF→ON을 연속으로 수행하십시오 |

> ⚠️ Axim 설정 변경은 반드시 **파트너 콘솔**에서 하십시오. 관리자 콘솔의 Axim 카드는 DB 값만 바꾸며 Axim 측 웹훅·지갑 등록을 수행하지 않습니다. (내부 운영 주의사항)

---

## 2.10 원화(KRW) 입출금 — 기술 상세

### 2.10.1 원화 입금 — 파트너가 구현할 것

원화 입금은 **위젯 또는 링크로만** 진행됩니다. 서버 API로 원화 입금을 직접 생성하는 경로는 없습니다.

| 진입 방식 | 경로 |
|---|---|
| 위젯 SDK | `openDeposit()` 결제수단 화면에서 "원화로 충전" 선택 |
| 매칭 링크 | `POST /api/partner/matching-links` 로 생성 → `https://widget.cryptoments.cc/m/{linkCode}` |
| 폰페이 링크 | `POST /api/partner/phonepay/links` → `https://widget.cryptoments.cc/phonepay/{linkCode}` |

파트너가 실제로 구현할 것은 **링크 생성 호출**과 **`DEPOSIT_CONFIRMED` Webhook 처리** 두 가지입니다. 매칭·이체 확인·정산은 모두 Cryptoments가 처리합니다.

### 2.10.2 원화 입금 Webhook

원화 입금도 **`DEPOSIT_CONFIRMED` 하나**로 통일되어 있습니다. 경로 구분은 `depositMethod`로 합니다.

| `depositMethod` | 의미 |
|---|---|
| `P2P` | P2P 매칭 또는 파트너 서비스 계좌 레그 정산 입금 |
| `TORQ` | 원화 충전(LP) 입금 |
| `PHONEPAY` | 폰페이 입금 |
| `HD_WALLET` / `EXTERNAL_WALLET` / `DECIMAL_MATCH` / `DIRECT` / `MANUAL` | 암호화폐 입금 |

주의사항:

- **분할 매칭이면 하나의 주문에서 Webhook이 여러 번 옵니다.** 각각 별개의 `transactionId`이며 부분 금액입니다. 주문 단위로 합산하려면 `orderId`(파트너 주문번호) 또는 `orderCode`(내부 주문 코드)로 묶으십시오.
- **P2P·LP 레그 정산 입금은 온체인 tx가 없어 `transactionHash`가 빈 문자열 `""`** 입니다. 서명 재현 시 그대로 `""`를 사용하십시오([§2.6.4](#264-서명-검증)).
- 환율은 **거래 시점 확정 환율**이 실립니다(발송 시점 시세 아님).
- 폰페이는 **KRW 통화로 원장에 기록**됩니다. USDT 입금과 통화가 다르므로 파트너 원장 반영 시 구분하십시오.

### 2.10.3 원화 출금 API

| 용도 | 엔드포인트 | 인증 |
|---|---|---|
| P2P 출금 주문 생성 | `POST /api/partner/p2p/withdraw-orders` | 파트너 콘솔 세션 |
| 출금 요청 + P2P 동시 생성 | `POST /api/partner/withdrawals/p2p` | 파트너 콘솔 세션 |
| 기존 출금을 P2P로 전환 | `POST /api/partner/withdrawals/{id}/p2p-convert` | 파트너 콘솔 세션 |
| 주문 취소 | `POST /api/partner/p2p/withdraw-orders/{orderCode}/cancel` | 파트너 콘솔 세션 |
| 잔여 처리 | `.../force-settle`, `.../cancel-remaining`, `.../convert-direct` | 파트너 콘솔 세션 |
| 출금 풀 현황 | `GET /api/partner/p2p/pool` | 파트너 콘솔 세션 |

> ⚠️ 원화 출금 API는 **파트너 콘솔 세션 인증(`/api/partner/*`)** 기반이며, [§2.1](#21-인증--서버-대-서버-open-api)의 Open API HMAC으로는 호출되지 않습니다. Open API(`/api/v1/*`)에는 P2P 출금 엔드포인트가 없습니다.
>
> 잔여 처리 3종과 `approve-usdt` / `reject-usdt`는 **파트너 콘솔 화면에서 OTP 재인증을 거쳐** 호출됩니다. API 레벨 인증은 콘솔 세션이며 `X-OTP-Code`는 콘솔이 부가하는 값입니다.

**주문 생성 요청 필드**

| 엔드포인트 | 필수 | 선택 |
|---|---|---|
| `POST /api/partner/p2p/withdraw-orders` | `partnerUserId`, `requestCurrency`, `amount`, `networkId`, **`bankAccountId`** | — |
| `POST /api/partner/withdrawals/p2p` | `partnerUserId`, `currencyId`, `networkId`, `amount` | `requestCurrency`(기본 `USDT`), `bankAccountId` |

> 계좌 미등록 상태로 주문을 먼저 만들려면 `POST /api/partner/withdrawals/p2p` 또는 `p2p-convert`를 쓰십시오. `p2p/withdraw-orders`는 계좌가 반드시 있어야 합니다.

**실패 조건**

| 상황 | 에러 코드 |
|---|---|
| 파트너 원화 기능 비활성 | `KRW_NOT_ENABLED` |
| P2P 출금 비활성 | `P2P_WITHDRAW_NOT_ENABLED` |
| 최소 금액(10,000원) 미달 | `P2P_AMOUNT_TOO_SMALL` |
| 전환 대상 출금의 상태가 부적합 | `WITHDRAWAL_STATUS_INVALID` |
| 분쟁 매칭 존재 시 취소·잔여처리 시도 | `P2P_CONVERT_BLOCKED_BY_DISPUTE` |
| 잔여 처리 재실행 | `P2P_REMAINDER_ALREADY_PROCESSED` |
| 회원 계좌 2개째 등록 | `ACCOUNT_LIMIT_EXCEEDED` |
| 타인이 등록한 계좌 | `BANK_ACCOUNT_DUPLICATE` |

### 2.10.4 상태 값

**P2P 출금 주문** (`p2p_withdraw_orders`)

`PENDING`(매칭 대기) → `PARTIALLY_MATCHED` → `FULLY_MATCHED` → `SETTLING` → `COMPLETED` / `CANCELLED`

- 매칭이 취소·만료되면 `PARTIALLY_MATCHED` → `PENDING`으로 **되돌아갑니다**(주문은 종료되지 않음).
- `EXPIRED`는 enum에 정의만 있고 **실제로 전이되지 않습니다**(자동 만료 폐기 정책).

**매칭** (`p2p_matches`)

`CREATED`(이체 대기) → `BANK_PENDING`(이체 신고됨) → `BANK_CONFIRMED` → `SETTLING` → `SETTLED`
분기: `DISPUTED`(분쟁), `FAILED`(30분 만료), `CANCELLED`

**원본 출금** (`withdrawals`)

`REQUESTED`/`PENDING_APPROVAL`/`APPROVED` → **`P2P_PENDING`** → `COMPLETED`

- `P2P_PENDING`은 일반 출금 만료 배치(24시간)의 대상이 아닙니다.

**원화 입금 주문** (`p2p_deposit_orders`): `PENDING` → `MATCHING` → `MATCHED` → `COMPLETED` / `CANCELLED` / `EXPIRED`
**원화 충전 거래**(LP): `QUOTED` → `CREATED` → `ACCEPTED` → `TRANSFERRED` → `COMPLETED` / `CANCELLED` / `DISPUTED` / `EXPIRED` / `FAILED`
**폰페이 세션**: `WAITING` → `MATCHED` → `COMPLETED` / `FAILED` / `EXPIRED` / `CANCELLED`

### 2.10.5 원화 출금 Webhook

| 이벤트 | 발생 시점 |
|---|---|
| `WITHDRAWAL_P2P_PENDING` | 출금이 P2P 경로로 전환되어 매칭 대기가 된 직후 |
| `WITHDRAWAL_COMPLETED` | **강제 정산 / 잔여 취소 / USDT 전환 / 주문 취소**로 원본 출금이 종결될 때 |

> ⚠️ **제약 1 — 전액 정상 정산으로 완료되는 경우에는 `WITHDRAWAL_COMPLETED`가 발송되지 않습니다.** 즉 가장 일반적인 성공 경로에서 완료 통지가 오지 않습니다. (수정 예정)
>
> ⚠️ **제약 2 — 두 이벤트는 원본 출금(`withdrawals`)이 연결된 주문에만 발송됩니다.** `POST /api/partner/p2p/withdraw-orders`로 직접 생성한 주문은 원본 출금이 없어 **출금 Webhook이 전혀 발송되지 않습니다.**
>
> **→ 두 제약 때문에, P2P 출금 건의 종결 판정은 `GET /api/partner/p2p/withdraw-orders`의 주문 상태(`COMPLETED`) 폴링을 기준으로 하십시오.**

`WITHDRAWAL_P2P_PENDING` 페이로드 특징:

- `transactionHash`는 빈 문자열 `""`
- `status`는 `P2P_PENDING`
- `toAddress` — **원스텝 P2P 출금**(`/withdrawals/p2p`)은 수신 지갑이 없어 필드가 생략되지만, **기존 일반 출금을 전환**한 건(`p2p-convert`)은 원 출금의 USDT 주소가 그대로 실립니다. **`toAddress` 유무로 경로를 판별하지 말고 `status == "P2P_PENDING"`으로 판단하십시오.**

### 2.10.6 회원 eKYC 요건

원화 입금은 **Axim eKYC 인증을 통과한 회원만** 이용할 수 있습니다. 미인증 회원은 위젯이 인증 화면으로 안내하며 진행이 차단됩니다.

- 판정 기준: Axim 지갑 연결됨 **AND** eKYC 인증 완료
- 따라서 **원화 기능을 쓰려면 Axim 연동이 선행 조건**입니다([§2.9](#29-axim-pay-연동-설정))
- 회원은 **eKYC에 등록된 본인 명의 계좌에서 송금**해야 합니다. 입금자명 불일치는 자동 분쟁으로 전환됩니다.

### 2.10.7 수수료

원화 거래 수수료는 **구매자(입금) 측 부담분**과 **판매자(출금 파트너) 부담분**으로 나뉘며, 파트너 체인(총판 구조)을 따라 배분됩니다.

- 구매자 부담분은 **주문 생성 시점 요율로 스냅샷**되어 이후 변동되지 않습니다.
- 출금자 부담분은 **회원 수령 원화 금액에서 차감되지 않고** 출금 파트너가 USDT로 부담합니다.
- 원화 충전(LP) 레그와 파트너 서비스 계좌 레그는 **P2P 수수료가 부과되지 않습니다**.
- 매칭이 취소·실패하면 수수료는 발생하지 않습니다(정산 완료 시점에만 계상).

> **구체적인 요율은 계약 시 개별 설정·안내됩니다.** 파트너 콘솔 `GET /api/partner/p2p/settings`에서 적용 요율을 조회할 수 있습니다.

### 2.10.8 원화 거래 문제 해결

| 증상 | 원인 · 조치 |
|---|---|
| 매칭이 계속 실패한다 | 판매 대기 물량(유동성)이 없는 상태. 장애가 아님. 출금 풀 현황으로 확인 |
| 매칭됐는데 입금 계좌가 여러 개다 | 정상. 분할 매칭이며 **각 계좌에 개별 송금** 필요 |
| 이체했는데 확인이 안 된다 | ① 입금자명이 eKYC 실명과 다름(자동 분쟁) ② 상대 회원이 수동 확인 대상인데 미확인 — 10분·30분에 재안내가 발송됨 |
| 출금 주문이 계속 대기 상태다 | 자동 만료가 없는 정책. 종료하려면 파트너가 취소하거나 회원이 USDT 전환을 요청해야 함 |
| 출금이 완료됐는데 Webhook이 안 왔다 | ① 전액 정상 정산 경로는 현재 완료 Webhook 미발송 ② `p2p/withdraw-orders`로 직접 생성한 주문은 출금 Webhook 자체가 없음 (§2.10.5). 주문 상태 조회로 확인 |
| 회원이 계좌를 바꾸고 싶어 한다 | 진행 중 주문이 있으면 변경 불가. 은행·계좌번호를 바꾸면 기존 계좌 인증이 자동 해제되어 재인증 필요 |

---

## 2.11 보안 요구사항

| # | 항목 |
|---|---|
| 1 | **API Secret은 서버에만 보관.** 프론트엔드·앱 번들·저장소에 절대 포함 금지 |
| 2 | 위젯 토큰 발급 엔드포인트는 **파트너 로그인 세션 검증 필수**, `partnerUserId`는 서버 세션에서 취득 |
| 3 | Webhook 수신 URL은 **HTTPS** 사용, 서명 검증 후에만 처리 |
| 4 | 서명 비교는 **timing-safe 비교** 함수 사용 |
| 5 | 서버 시각을 **NTP 동기화** (±5분 초과 시 전 API 401) |
| 6 | 출금 API 호출 권한은 파트너 서버 내부에서 최소 권한으로 제한 |

---

## 2.12 검수 체크리스트 (오픈 전)

**입금**

- [ ] 위젯 토큰 발급 성공 (세션 미인증 시 거부되는지 포함)
- [ ] 위젯 열림 → 입금 주소 표시 → 소액 실전송 → `DEPOSIT_CONFIRMED` 수신
- [ ] 같은 회원·체인 재요청 시 **동일 주소** 반환 확인
- [ ] 원화 충전 경로(P2P/TORQ) 1건 완주 — **P2P 원화 직수령 레그는 Webhook이 오지 않으므로 대사 폴링으로 잡히는지 확인**
- [ ] 서명 검증 통과 / 위조 페이로드 거부 확인
- [ ] `signature`가 빈 문자열인 페이로드에서 수신 핸들러가 죽지 않는지 확인
- [ ] 동일 Webhook 2회 수신 시 잔고가 **1회만** 증가

**출금**

- [ ] `orderId` 지정 요청 성공
- [ ] 같은 `orderId` 재요청 시 신규 생성 없이 기존 건 반환
- [ ] 같은 `orderId` + 금액 변경 시 409 수신
- [ ] 최소금액 미달 시 `MIN_AMOUNT_NOT_MET` 수신
- [ ] 승인 → `WITHDRAWAL_APPROVED` → `WITHDRAWAL_CONFIRMED` 순차 수신 및 상태 반영
- [ ] 실패 케이스에서 회원 잔고 원복 로직 동작

**운영**

- [ ] Webhook 수신 서버 다운 → 약 30분 후 복구 시 **대사 루프로 누락분 복구** 확인
- [ ] API Secret이 서버 환경변수/비밀 저장소에만 있고 코드·저장소에 없는지 확인
- [ ] (Axim Pay 사용 시) 활성화 후 위젯에 결제수단 노출 → 회원 지갑 연결 → 결제 1건 완주 → 입금 반영까지 확인

**원화(KRW) 사용 시**

- [ ] eKYC 미인증 회원이 원화 충전 시도 → 인증 안내로 차단되는지
- [ ] 원화 입금 1건 완주 → `DEPOSIT_CONFIRMED`의 `depositMethod` 확인 후 잔고 반영
- [ ] **분할 매칭 건에서 Webhook이 여러 번 와도 합계가 정확한지** (부분 금액 중복 가산 없는지)
- [ ] (폰페이 사용 시) KRW 통화 원장이 USDT와 구분되어 처리되는지
- [ ] P2P 출금 주문 등록 → 매칭 → 정산 → **주문 상태 조회로 완료 감지**되는지 (완료 Webhook에 의존하지 않는지)
- [ ] 회원 안내 문구에 "eKYC 실명 계좌에서 송금", "분할 시 계좌별 개별 송금" 포함
- [ ] 미확정 입금 조회 폴링 동작
- [ ] 파트너 콘솔 잔액과 파트너 자체 원장 대사 일치
- [ ] 서버 시각 NTP 동기화 확인

---

# 부록 A. 파트너사 회신 양식

아래 표를 채워 회신해 주시면 계정 발급과 정책 설정을 진행합니다.

| 항목 | 회신 |
|---|---|
| 회사명 / 서비스명 | |
| 담당자 (기획 / 개발) | |
| **연동 방식** (A 위젯 / B 링크 / C 서버API / 혼합) | |
| **사용할 결제수단** (USDT 직접 / P2P 매칭 / TORQ / 폰페이 / Axim Pay) | |
| **사용할 체인·통화** (예: BSC-USDT, TRON-USDT) | |
| 출금 기능 사용 여부 | |
| 출금 승인 방식 (전건 수동 승인 / 임계값 이하 자동승인 — 임계값 명시) | |
| 출금 한도 (건당 / 일일, USD 기준) | |
| 출금 주소 화이트리스트 사용 여부 | |
| **Webhook 수신 URL** (HTTPS) | |
| 파트너 사이트 도메인 (위젯 허용 origin) | |
| 예상 오픈 일정 | |
| 예상 거래 규모 (일 건수 / 금액) | |

---

# 부록 B. Cryptoments 내부 확인 항목 (파트너 배포 전 정리)

> 이 부록은 **대외 배포본에서 삭제**하고 내부에서만 사용합니다.

| # | 항목 | 현황 | 조치 |
|---|---|---|---|
| 1 | 입금 수수료 순액(`feeAmount`/`netAmount`)이 v1 조회 API·Webhook에 없음 | 파트너가 총액만 받음 → 회원 반영 금액을 스스로 계산해야 함 | 필드 추가 검토. 그 전까지는 §1.6 안내 문구로 커버 |
| 2 | 대외 가이드(guide-ui)의 재시도 안내가 "5회 재시도 + 6차 1시간 후" | 실제는 **총 5회 시도(재시도 4회), 약 21분** | guide-ui 수정 필요 |
| 3 | 대외 가이드의 "콘솔에서 수동 재발송" | 해당 기능 미구현 | 기능 구현 또는 문구 삭제 |
| 4 | 대외 가이드 입금 페이로드 예시에 `orderCode` 누락 | 실제 페이로드에 존재 | guide-ui 수정 |
| 5 | Webhook 응답 타임아웃 값 | 코드/설정에 명시 없음(프레임워크 기본값) | 실측 후 가이드 명시 |
| 6 | `/widgets/auth/refresh` 501 스텁 | SDK에 `refreshToken()`이 존재하나 동작 안 함 | 구현 또는 SDK에서 제거 |
| 7 | Open API HMAC 서명이 메서드·경로·바디 미포함 | 탈취 서명으로 5분간 임의 엔드포인트 호출 가능 | 서명 규칙 강화 검토(하위호환 고려) |
| 8 | 서명 비교가 `.equals()` (타이밍 공격 노출) | 위젯 쪽은 `MessageDigest.isEqual` 사용 — 불일치 | 상수시간 비교로 통일 |
| 9 | `CurrencyPriceController` 세션 미주입 | 인증 없이 시세 노출 가능 | 의도 확인 후 정리 |
| 10 | 일일 한도 검증이 토큰 수량과 USD 한도를 비교 | 건당 한도는 USD 환산 사용 — 불일치 | 버그 수정 필요 |
| 11 | 시세 조회 실패 시 최소금액(10 USD) 검증 스킵 | 안전장치 우회 | 가드 추가 |
| 12 | 멱등 동일성 판정에서 `partnerUserId` 제외 | 같은 orderId로 수신자만 바꾸면 기존 건 반환 | 정책 재확인(2026-08-10 결정 사항) |
| 13 | `/users/{userId}/transactions` 메모리 필터링·무페이징 | 대량 시 성능 위험 | 페이지네이션 추가 |
| 14 | P2P PARTNER 레그 입금에서 `eventData` null 전달 | Webhook INSERT 실패 + Telegram 알림 동반 유실 의심 | 코드 확인 및 수정 |
| 15 | `widget-demo/server.js`에 API Key/Secret 평문 커밋 | 저장소 노출 | 해당 파트너 키 재발급 + 커밋 제거 |
| 16 | SDK에 무조건 실행되는 `console.log` 다수 | `debug:false`여도 위젯 토큰이 파트너 콘솔에 출력 | 제거 |
| 17 | SDK `handleWidgetMessage`에 `case 'WIDGET_READY'` 중복 | 앞 case가 먼저 걸려 `emit('ready')`가 죽은 코드 → `ready` 이벤트 미발생 | 중복 case 제거 후 안내서에 `ready` 복원 |
| 18 | Webhook 서명 생성 실패 시 `signature: ""` 로 발송 | 파트너 검증 코드가 예외로 죽으면 재시도 소진 후 유실 | 서명 실패 시 발송 보류 또는 명시적 에러 처리 |
| 19 | 응답 시각 타임존이 코드로 보증되지 않음 | `LocalDateTime` 직렬화라 `jackson.time-zone: UTC` 무효, JVM TZ에 좌우 | 운영 실측 후 안내서 §2.0 확정 |
| 20 | Webhook 발송이 응답을 JSON Map으로 역직렬화 | 파트너가 빈 200/비JSON 반환 시 실패 처리·재시도 가능성 | 응답 파싱을 판정에서 분리 |
| 21 | `/users/{userId}/transactions`·`/deposits/pending` 응답 필드가 `unconfirmed`보다 적음 | 엔드포인트별 불일치로 파트너 혼란 | 필드 세팅 통일 |
| 22 | `/users/{userId}/deposits/pending` 이 `partner_confirmed` 무관하게 `CONFIRMED`만 반환 | 이름이 "pending"이라 대사용으로 오용되기 쉬움 | 명칭/동작 정리 |

### API Key 관련 (§2.8)

| # | 항목 | 현황 | 조치 |
|---|---|---|---|
| 23 | **관리자 파트너 생성 응답에 Secret이 실려 나가지 않음** | `PartnerCreateResponse`가 `Partner` 엔티티를 그대로 반환하는데 `apiSecretHash`에 `@JsonIgnore` → admin-ui의 "API Secret (일회성 표시)" 칸이 **빈 값**. 관리자 재발급도 동일 | **P0.** 전용 응답 DTO로 분리. 담당자가 수동으로 DB 조회해 전달하는 상황일 가능성 |
| 24 | `ApiKeyResponse` 생성자 인자 순서 오용 | `apiKeyMasked`에 마스킹 안 된 전체 Key, `apiKey`에 Secret이 들어감 → 파트너 콘솔이 Secret을 "API Key" 라벨로 표시 | 필드 정리 + 마스킹 구현 |
| 25 | `partners.api_secret_hash`가 평문 저장 | 컬럼명과 달리 해시 아님(HMAC 특성상 불가피) — DB 덤프 유출 = Secret 유출 | 컬럼 암호화 또는 KMS 검토 |
| 26 | 파트너 콘솔 재발급에 감사 로그·`@Transactional` 없음 | 관리자 경로(`REGENERATE_API_KEY`)와 비대칭 | 감사 로그 추가 |
| 27 | 파트너 콘솔 API 키 화면에 복사 버튼 없음 | admin-ui엔 있음. 수동 드래그 복사 필요 | 추가 |
| 28 | 무중단 키 회전 불가 | 구 키 유예 기간 0 | 이중 키(구/신 병행) 지원 검토 |

### Axim Pay 관련 (§2.9)

| # | 항목 | 현황 | 조치 |
|---|---|---|---|
| 29 | 관리자 콘솔 Axim 카드가 DB만 갱신 | 활성화 토글을 켜도 Axim 측 웹훅 URL·MASTER 지갑 등록이 일어나지 않음 → "결제는 생성되는데 웹훅이 안 옴" | 관리자 경로도 `activateAxim()` 경유하거나 화면에서 제거 |
| 30 | `registerMasterWallets()`가 개별 실패를 로그만 남기고 삼킴 | MASTER 지갑 0개여도 활성화 성공 → Axim 수신 지갑 없이 결제 실패 | 실패 시 활성화 중단 또는 경고 노출 |
| 31 | `isEnabled` 동일 값 저장은 no-op | 웹훅 URL 재등록이 필요해도 재활성화가 안 됨 → OFF/ON 왕복 필요 | 명시적 "재등록" 버튼 추가 |
| 32 | `partner_axim_settings.api_secret_enc`가 평문 | 필드명은 `_enc`인데 암·복호화 코드 없음 | 실제 암호화 적용 또는 명칭 정정 |
| 33 | Axim 서버 만료 15분 vs 로컬 20분 | 로컬을 15분으로 "정렬"하면 지연 도착 성공 웹훅이 최종상태 가드에 걸려 **입금은 됐는데 deposit 미생성** | 현행 유지. 코드 주석으로 이유 고정 |
| 34 | `deposit-wallets` 등록 시 `currencyType="USDT"` 하드코딩 | 다른 통화 지원 시 누락 | 통화 확장 시 수정 |
| 35 | 라우트 `meta.aximOnly` 소비처 없음 | 레이아웃 필터가 `distributorOnly`만 처리 → Axim 미사용 파트너에게도 메뉴 노출 | 가드 구현 또는 메타 제거 |
| 36 | Axim 결제 중복 차단이 `linkId` 경유에만 적용 | 위젯 직접 생성·콘솔 요청은 서버측 방어 없음(콘솔은 클라이언트 재클릭 가드만) | 서버측 in-flight 가드 확대 |

### 원화(KRW) 관련 (§1.4.1 / §1.5.1 / §2.10)

| # | 항목 | 현황 | 조치 |
|---|---|---|---|
| 37 | **전액 정상 정산 시 `WITHDRAWAL_COMPLETED` 미발송** | `P2pSettlementService.checkAndCompleteOrders`가 `withdrawals`를 COMPLETED로 바꾸면서 알림을 호출하지 않음. `WITHDRAWAL_COMPLETED` 발송처는 `completeP2pConvertedWithdrawal`(강제정산·잔여취소·전환·주문취소) 4경로뿐 | **P0.** 2026-08-10에 종결 통지를 추가하면서 자연 완료 경로를 누락. 가장 흔한 성공 케이스에서 파트너가 건을 못 닫음 |
| 38 | P2P 수수료 모델 v2.8이 워킹트리 미커밋 | 지식 repo(`domains/p2p-matching`)는 여전히 구 모델(2% 글로벌) 기재 → 문서-코드 불일치 | 배포 후 지식 repo 갱신. 안내서는 요율 수치를 명시하지 않는 방식으로 회피함 |
| 39 | 수수료 단위 혼재 의심 | `P2pFeeResolver`는 퍼센트(1.0=1%), 위젯 `computeExpectedUsdt`는 소수 비율 가정. `/route`의 `feeRate`는 `p2p.fee_rate`(소수), 주문의 `feeRate`는 체인 합(퍼센트) — 출처가 다름 | 단위 통일 검증 필요 |
| 40 | 원화 충전(LP) 단독 입금에 `deposit_fee_rate` 적용 | 온체인 DEPOSIT 경로를 타므로 파트너 입금 수수료가 붙음. 지식문서의 "수수료 0" 서술과 상충 | 운영 파트너 `deposit_fee_rate` 실값 확인 |
| 41 | `p2p.seller_allowlist` 운영 임시 게이트 | 값이 있으면 특정 회원만 매칭 후보. 파트너에게는 "유동성 없음"으로 보임 | 현재 운영값 확인 및 해제 시점 판단 |
| 42 | 위젯이 P2P 최소금액을 사전에 모름 | `route`/`match` 응답에 필드 없음 → 금액 입력 단계 사전 차단 불가 | 응답에 최소금액 추가 |
| 43 | 「동일 계좌 동시 활성 주문 불가」 제약 | 지식문서에 기재돼 있으나 코드에서 해당 제약을 확인하지 못함(확인된 것은 회원 1계좌 강제, 확인 단위 병합) | 실제 구현 여부 확인 후 문서 정정 |
| 44 | Webhook 배달 실패가 파트너 콘솔에 노출 안 됨 | 파트너가 "웹훅이 안 온다" 문의 시 DB 직접 조회 필요 | 콘솔에 실패 로그·재발송 노출 (#2·#3과 함께) |
| 45 | `p2p/withdraw-orders` 직접 생성 주문은 출금 Webhook 전무 | `withdrawalId`가 null이라 `WITHDRAWAL_P2P_PENDING`·`WITHDRAWAL_COMPLETED` 둘 다 가드에 걸림 | #37과 함께 정리 |
| 46 | 잔여 처리 API에 `@RequiresOtp` 미적용 | `P2pController`에 OTP 어노테이션이 하나도 없음 → 콘솔은 OTP를 요구하지만 **API 직접 호출 시 우회 가능** | 보안 갭. 어노테이션 추가 |
| 47 | `GET /api/partner/p2p/settings` 응답 스키마가 v2.8 작업 중 | 파트너에게 요율 조회처로 안내했으므로 필드명 확정 필요 | 배포 전 확정 |
