- Kotlin 100%
The PaySync API dropped `POST /v2/cash-receipts/{id}/send-sms` and retired error code `240019`.
- Remove `POST /api/cash-receipts/{id}/send-sms`, `sendSmsById` and `SendCashReceiptSmsRequest`.
- Point the Popbill 502 test at send-email and drop SMS mentions from the README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|---|---|---|
| gradle/wrapper | ||
| src | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradlew | ||
| gradlew.bat | ||
| README.md | ||
| settings.gradle.kts | ||
PaySync 샘플 백엔드 (Kotlin + Spring Boot)
PaySync REST API(v2)를 호출하고 PaySync 웹훅(v2)을 받는 가맹점 백엔드 예제입니다.
Spring Boot 4의 RestClient로 PaySync API를 호출하고, 웹훅 서명 검증에는
Standard Webhooks 공식 Java 라이브러리를 사용합니다.
로그는 kotlin-logging으로 남깁니다.
API 명세는 PaySync API 문서를 참고하세요.
요구사항
- JDK 21 이상 (Gradle 툴체인
languageVersion = 21) - PaySync API 키, 웹훅 서명 키(
whsec_...)
프로젝트 구조
src/main/kotlin/kr/paysync/sample/backend/
├── DemoApplication.kt
├── common/ PaySync 설정(PaySyncProperties), RestClient 설정, 공통 응답 모델, 주체(Actor) 모델, 예외 처리
├── invoice/ 주문 (controller / service / dto / model)
├── cashreceipt/ 현금영수증 (controller / service / dto / model)
├── transaction/ 입금 내역 (controller / service / model)
├── bankaccount/ 입금 계좌 모델
└── webhook/ 웹훅 수신, 서명 검증, 이벤트별 처리 (controller / service / model)
RestClientConfig가 base URL과 Authorization: Bearer <API 키> 헤더를 붙인 RestClient를 만들고,
각 서비스는 PaySync v2 응답을 받아 code가 성공 값인 10001인지 확인합니다.
| PaySync 응답 | 형식 |
|---|---|
| 단건 | { "code": "10001", "data": { ... } } |
| 목록 | { "code": "10001", "items": [ ... ], "totalItems": 57 } (items는 최상위에 있습니다) |
| 데이터 없음 | { "code": "10001" } |
API 응답의 시각은 Unix 초 정수이고, 웹훅 페이로드의 시각은 ISO 8601 문자열입니다.
PaySync가 응답에 새 필드를 추가해도 모르는 필드는 무시합니다. 값이 늘어날 수 있는 은행(provider), 매칭 방식,
결제 완료 경로(paidTrigger) 같은 값은 enum 대신 문자열로 받습니다.
설정
src/main/resources/application.yaml의 paysync 항목을 채우거나, 환경 변수로 설정합니다.
(Spring Boot의 relaxed binding으로 paysync.api-key ↔ PAYSYNC_API_KEY가 연결됩니다.)
| 환경 변수 | 필수 | 기본값 | 설명 |
|---|---|---|---|
PAYSYNC_BASE_URL |
https://api.paysync.kr |
PaySync API 주소 | |
PAYSYNC_API_KEY |
✔ | PaySync API 키 (sk_live_...) |
|
PAYSYNC_WEBHOOK_SIGNING_SECRET |
✔ | 웹훅 서명 키 (whsec_...) |
|
SERVER_PORT |
8080 |
샘플 서버 포트 |
API 키와 서명 키는 application.yaml에 적어 커밋하지 말고 환경 변수로 넘기세요.
# macOS / Linux
export PAYSYNC_API_KEY=발급받은_API_키
export PAYSYNC_WEBHOOK_SIGNING_SECRET=whsec_...
# Windows PowerShell
$env:PAYSYNC_API_KEY = "발급받은_API_키"
$env:PAYSYNC_WEBHOOK_SIGNING_SECRET = "whsec_..."
실행
./gradlew bootRun # Windows: gradlew.bat bootRun
테스트
./gradlew test # Windows: gradlew.bat test
테스트는 샘플 서버를 띄우고 PaySync API 주소를 테스트용 가짜 서버로 바꿔서, 요청이 v2 경로와 형식으로 전달되는지와 응답, 오류, 웹훅 서명 검증을 확인합니다. 실제 PaySync API는 호출하지 않습니다.
엔드포인트
| 샘플 서버 | PaySync API | 성공 응답 |
|---|---|---|
POST /api/invoices |
POST /v2/invoices |
201 + 주문 |
GET /api/invoices |
GET /v2/invoices |
200 + { items, totalItems } |
GET /api/invoices/{id} |
GET /v2/invoices/{id} |
200 + 주문 (처리 이력 history 포함) |
PATCH /api/invoices/{id} |
PATCH /v2/invoices/{id} |
200 + 주문 |
POST /api/invoices/{id}/mark-paid |
POST /v2/invoices/{id}/mark-paid |
200 + 주문 |
POST /api/invoices/{id}/cancel |
POST /v2/invoices/{id}/cancel |
200 + 주문 |
POST /api/cash-receipts |
POST /v2/cash-receipts |
201 + 현금영수증 |
GET /api/cash-receipts |
GET /v2/cash-receipts |
200 + { items, totalItems } |
GET /api/cash-receipts/{id} |
GET /v2/cash-receipts/{id} |
200 + 현금영수증 |
POST /api/cash-receipts/{id}/cancel |
POST /v2/cash-receipts/{id}/cancel |
200 + 취소 영수증 |
POST /api/cash-receipts/{id}/reissue |
POST /v2/cash-receipts/{id}/reissue |
201 + 새 현금영수증 |
GET /api/cash-receipts/{id}/urls |
GET /v2/cash-receipts/{id}/urls |
200 + { view, print, pdf } |
POST /api/cash-receipts/{id}/send-email |
POST /v2/cash-receipts/{id}/send-email |
204 |
GET /api/transactions |
GET /v2/transactions |
200 + { items, totalItems } |
GET /api/transactions/{id} |
GET /v2/transactions/{id} |
200 + 입금 내역 |
POST /api/webhooks |
(PaySync → 샘플 서버) | 200 |
PaySync는 주문 생성도 HTTP 200으로 응답하지만, 샘플 서버는 생성 요청에 201을 응답합니다.
요청 본문
| 엔드포인트 | 필드 |
|---|---|
| 주문 생성 | customer.name(필수), customer.email, customer.phoneNumber, amount(필수), bankAccountIds, expiresAt(Unix 초), expireAfterDays(1~365), cashReceipt.type, cashReceipt.identifier, metadata, sendGuideEmail |
| 주문 수정 | customer.name, customer.email, customer.phoneNumber, amount, expiresAt, extendDays, bankAccountIds, cashReceipt, resendGuideEmail |
| 주문 취소 | reason (본문은 생략할 수 있습니다) |
| 현금영수증 발급 | type(필수, PERSONAL/CORPORATE), identifier(selfIssued가 true가 아니면 필수), selfIssued, amount(필수), customerName, invoiceId |
| 현금영수증 취소 | reason(필수), amount (빼면 남은 금액 전체를 취소합니다) |
| 현금영수증 재발급 | identifier, type, selfIssued |
| 이메일 전송 | email(필수) |
샘플 서버는 필수 필드가 있는지와 JSON 타입만 확인하고, 길이와 형식은 PaySync가 검증해 오류 code로 응답합니다.
주문 생성에서 값이 없는 선택 필드는 null로 보내지 않고 뺍니다.
주문 수정은 필드를 빼는 것과 null을 보내는 것의 뜻이 다릅니다. customer.email: null은 이메일을 지우고,
expiresAt: null은 입금 기한을 없애고, cashReceipt: null은 현금영수증 정보를 지웁니다. 그래서 샘플 서버는
수정 본문이 JSON 객체인지만 확인하고 그대로 PaySync로 전달합니다.
목록 조회 쿼리 파라미터
offset 기본값은 0, limit 기본값은 20(최대 100)이며, 나머지는 값이 있을 때만 PaySync로 전달합니다.
status, source, bankAccountId는 여러 값을 쉼표로 이어 보낼 수 있습니다 (예: status=PENDING,EXPIRED).
dateFrom, dateTo는 YYYY-MM-DD 형식의 한국 시간(KST) 날짜이며 두 날짜 모두 결과에 포함됩니다.
/api/invoices:offset,limit,search,status,due,source,createdBy,dateFrom,dateTo/api/transactions:offset,limit,search,bankAccountId,status,matchMethod,dateFrom,dateTo/api/cash-receipts:offset,limit,search,status,type,method,kind,invoiceId,dateFrom,dateTo
예시
# 주문 생성
curl -X POST http://localhost:8080/api/invoices \
-H 'Content-Type: application/json' \
-d '{"customer":{"name":"홍길동"},"amount":50000,"expireAfterDays":1,"metadata":{"orderId":"ORDER-0001"}}'
# 결제 대기와 만료 주문 조회
curl 'http://localhost:8080/api/invoices?status=PENDING,EXPIRED&limit=10'
# 입금 기한을 3일 늘리고 이메일 지우기
curl -X PATCH http://localhost:8080/api/invoices/ivc_a1b2c3d4e5f6g7h8i9j0k1l2 \
-H 'Content-Type: application/json' \
-d '{"extendDays":3,"customer":{"email":null}}'
# 주문 취소
curl -X POST http://localhost:8080/api/invoices/ivc_a1b2c3d4e5f6g7h8i9j0k1l2/cancel \
-H 'Content-Type: application/json' \
-d '{"reason":"고객 요청으로 주문 취소"}'
에러 응답
에러는 { "code": "...", "message": "...", "data": ... } 형식이며, common/exception/PaySyncExceptionHandler.kt에서
한곳에 모아 처리합니다. data는 PaySync 오류 응답에 data가 있을 때만 담깁니다.
| 상황 | 상태 코드 | code |
|---|---|---|
| 잘못된 요청 본문 / 쿼리 파라미터, 필수 필드 누락 | 400 | BAD_REQUEST |
PaySync가 4xx와 code를 응답 |
PaySync의 상태 코드 (예: 404, 409, 422) | PaySync의 code (예: 20014, 220010) |
그 밖의 PaySync 실패 (5xx, JSON이 아닌 본문, 연결 실패, 10001이 아닌 code의 200 응답) |
502 | PaySync의 code 또는 UPSTREAM_ERROR |
예를 들어 입금자명과 금액이 같은 결제 대기 주문이 이미 있으면 기존 주문 ID가 함께 옵니다.
{
"code": "220010",
"message": "PaySync API 요청이 거부되었습니다.",
"data": { "invoiceId": "ivc_n0o1p2q3r4s5t6u7v8w9x0y1", "number": 1042 }
}
code 목록은 에러 코드 문서를 참고하세요.
웹훅
등록
대시보드 웹훅에서 웹훅 추가를 누르고, 받을 URL에
https://<공개 주소>/api/webhooks를 입력합니다. API 버전은 v2를 고르고, 받을 이벤트를 고르세요.
웹훅을 만든 뒤 웹훅 목록의 보기에서 서명 키(whsec_...)를 확인해 PAYSYNC_WEBHOOK_SIGNING_SECRET에 넣습니다.
처리
POST /api/webhooks는 webhook-id, webhook-timestamp, webhook-signature 헤더와 파싱하기 전의
원본 본문(@RequestBody String)으로 서명을 검증합니다 (webhook/service/WebhookServiceImpl.kt).
- 검증 성공: v2 봉투(
{ "id": "evt_...", "type": "...", "timestamp": "...", "data": { ... } })를 읽고,정상 웹훅이 수신되었습니다: <이벤트 ID> (<종류>)로그를 남긴 뒤 이벤트 종류별로 처리하고 200을 응답합니다. 이벤트별 처리 부분에 가맹점의 비즈니스 로직을 추가하세요. - 검증 실패:
웹훅 검증에 실패했습니다경고 로그를 남기고 400을 응답합니다. 검증되지 않은 본문은 로그에 남기지 않습니다. - 전송 시각(
webhook-timestamp)이 현재와 5분 넘게 차이 나면 검증에 실패합니다.
| 이벤트 | 전송 시점 | data |
|---|---|---|
invoice.created |
주문이 만들어졌습니다. | 주문 페이로드 |
invoice.updated |
결제 대기 주문의 내용이 바뀌었습니다. | 주문 페이로드 |
invoice.paid |
주문이 결제 완료됐습니다. 경로는 data.paidTrigger (AUTOMATIC_MATCHING, MANUAL_MATCHING, MANUAL_APPROVE, API_CALL) |
주문 페이로드 |
invoice.expired |
입금 기한이 지나 주문이 만료됐습니다. | 주문 페이로드 |
invoice.canceled |
주문이 취소됐습니다. | 주문 페이로드 |
invoice.reopened |
매칭이 해제돼 결제 완료 주문이 다시 열렸습니다. | 주문 페이로드 |
transaction.created |
새 입금이 들어왔습니다. | 입금 페이로드 |
주문 페이로드(InvoiceWebhookPayload)는 API의 주문과 비슷하지만 시각이 ISO 8601 문자열이고, createdBy, paidBy,
canceledBy, cafe24, guideUrl, guideEmailSentAt, history가 없습니다. 모르는 이벤트 종류는 무시하고 200을 응답합니다.
- PaySync는 HTTP 200~299 응답을 성공으로 기록합니다. 그 밖의 상태 코드, 리디렉션, 연결 실패, 10초 안에 응답이 없으면 실패로 기록하고 다시 보냅니다. 오래 걸리는 작업은 200을 먼저 응답한 뒤 큐나 백그라운드 작업으로 넘기세요.
- 재시도와 대시보드 재전송 때문에 같은 이벤트가 여러 번 올 수 있습니다. 이벤트 ID(
evt_...)를 저장해 두고 이미 처리한 이벤트는 건너뛰세요. - 이벤트가 오는 순서는 보장되지 않습니다. 순서가 중요하면
data.updatedAt이나timestamp를 비교하거나, 주문 단건 조회로 최신 상태를 확인하세요.
자세한 내용은 웹훅 문서를 참고하세요.
로컬에서 테스트하기
직접 서명한 요청을 보내 확인할 수 있습니다. 서버와 같은 서명 키를 사용하세요.
SECRET="$PAYSYNC_WEBHOOK_SIGNING_SECRET"
ID=whm_local_test
TS=$(date +%s)
BODY='{"id":"evt_local","type":"invoice.paid","timestamp":"2026-04-28T03:20:12Z","data":{"id":"ivc_test","number":1001,"status":"PAID","customer":{"name":"tester"},"amount":50000,"bankAccountIds":[],"metadata":{},"source":"API","createdAt":"2026-04-28T03:14:15Z","paidTrigger":"API_CALL"}}'
KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 -d | od -An -tx1 | tr -d ' \n')
SIG=$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | base64)
curl -i -X POST http://localhost:8080/api/webhooks \
-H 'Content-Type: application/json' \
-H "webhook-id: $ID" \
-H "webhook-timestamp: $TS" \
-H "webhook-signature: v1,$SIG" \
--data "$BODY"
200이 오고 주문이 결제 완료되었습니다: ivc_test (API_CALL) 로그가 남으면 성공입니다.
webhook-signature 값을 바꿔 보내면 400이 옵니다.
PaySync가 보내는 실제 웹훅을 받으려면 공개 주소가 필요합니다 (localhost는 등록할 수 없습니다).
cloudflared 나
ngrok 같은 터널로 로컬 서버를 노출하고, 대시보드 웹훅에
https://<터널 주소>/api/webhooks를 등록하세요. 등록한 뒤 웹훅 목록의 행 메뉴에서 테스트 이벤트 보내기로
서명 검증까지 확인할 수 있습니다.