- Python 100%
- PaySync API에서 POST /v2/cash-receipts/{id}/send-sms가 없어져 문자 전송 엔드포인트, 서비스, 요청 모델, 테스트를 지웠습니다.
- 팝빌 오류(240009) 502 테스트는 이메일 전송으로 옮겼습니다.
- README 엔드포인트 표에서 send-sms를 지웠습니다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|---|---|---|
| app | ||
| tests | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| pyproject.toml | ||
| README.md | ||
PaySync 샘플 백엔드 (Python + FastAPI)
PaySync REST API v2(https://api.paysync.kr/v2/...)를 호출하고 PaySync v2 웹훅을 받는 가맹점 서버 예제입니다.
주문(invoice), 현금영수증, 입금 내역 API를 그대로 감싸서 제공하고, 웹훅은 Standard Webhooks 서명을 검증한 뒤 처리합니다.
API 명세는 PaySync API 문서를 참고하세요.
요구사항
- Python 3.12 이상 (3.14에서 테스트했습니다)
- PaySync API 키와 웹훅 서명 키(
whsec_...) - uv (선택. 없으면
venv+pip로 실행할 수 있습니다)
주요 라이브러리: FastAPI 0.141, Uvicorn 0.54, httpx 0.28, pydantic 2.13, pydantic-settings 2.15, standardwebhooks 1.1
설정
.env.example을 .env로 복사한 뒤 값을 채웁니다. 같은 이름의 환경 변수가 있으면 .env보다 우선합니다.
| 환경 변수 | 필수 | 기본값 | 설명 |
|---|---|---|---|
PAYSYNC_BASE_URL |
https://api.paysync.kr |
PaySync API 주소 | |
PAYSYNC_API_KEY |
✅ | API 키. Authorization: Bearer <키>로 전송합니다. |
|
PAYSYNC_WEBHOOK_SIGNING_SECRET |
✅ | 웹훅 서명 키 (whsec_...) |
|
PORT |
8080 |
python -m app으로 실행할 때의 포트 |
실행
uv를 쓰는 경우
uv run fastapi dev app/main.py --port 8080 # 개발 모드 (코드 변경 시 자동 재시작)
uv run pytest # 테스트
venv + pip를 쓰는 경우
python -m venv .venv
source .venv/bin/activate # Windows (PowerShell): .venv\Scripts\Activate.ps1
pip install -e . --group dev # --group 옵션은 pip 25.1 이상에서 지원합니다
fastapi dev app/main.py --port 8080 # 개발 모드
pytest # 테스트
다른 실행 방법:
uvicorn app.main:app --reload --port 8080
python -m app # PORT 환경 변수(기본 8080)를 사용합니다
서버가 뜨면 http://localhost:8080/docs에서 Swagger UI로 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 v2는 성공하면 항상 HTTP 200과 code: "10001"을 돌려줍니다. 이 샘플은 생성 요청에 201, 데이터가 없는 요청에 204로 바꿔 응답합니다.
응답의 시각(createdAt, expiresAt 등)은 Unix 초 단위 정수입니다.
목록 조회 쿼리 파라미터(offset 기본 0, limit 기본 20, 나머지는 선택)는 값이 있는 것만 PaySync에 그대로 전달합니다.
날짜 필터 dateFrom, dateTo는 YYYY-MM-DD(한국 시간, 그날 포함)이고, status처럼 여러 값을 받는 필터는 status=PENDING,EXPIRED처럼 쉼표로 이어 보냅니다.
/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"}}'
주문 수정은 받은 JSON 객체를 그대로 PaySync에 전달합니다. 보내지 않은 필드는 그대로 두고, null을 보낸 필드는 지웁니다.
# 입금 기한을 3일 늘리고, 구매자 이메일을 지웁니다.
curl -X PATCH http://localhost:8080/api/invoices/ivc_... \
-H "Content-Type: application/json" \
-d '{"extendDays":3,"customer":{"email":null}}'
주문 취소는 본문 없이 보내거나 {"reason":"고객 요청"}처럼 사유를 넣어 보냅니다.
에러 응답
에러는 { "code": ..., "message": ..., "data": ... } 형식으로 응답합니다. data는 PaySync가 에러와 함께 보낸 값이 있을 때만 붙습니다.
{
"code": "220010",
"message": "주문 생성에 실패했습니다.",
"data": { "invoiceId": "ivc_n0o1p2q3r4s5t6u7v8w9x0y1", "number": 1042 }
}
| 상황 | 상태 코드 | code |
|---|---|---|
| 요청 값이 올바르지 않음 (필수 필드 누락, JSON 타입 오류) | 400 | INVALID_REQUEST (+ errors에 상세 내용) |
| PaySync가 4xx와 에러 코드를 응답 | PaySync 상태 코드 그대로 (예: 409) | PaySync 코드 그대로 (예: 220010) |
그 밖의 PaySync 실패 (5xx, 빈 본문이나 JSON이 아닌 응답, 연결 실패, 10001이 아닌 성공 응답) |
502 | PaySync 코드 또는 UPSTREAM_ERROR |
길이나 형식 같은 세부 검증은 PaySync가 하고, 그 에러 코드를 그대로 전달합니다. 코드 목록은 에러 코드를 참고하세요.
웹훅 로컬 테스트
POST /api/webhooks는 webhook-id, webhook-timestamp, webhook-signature 헤더와 파싱 전 원본 본문으로 서명을 검증합니다.
- 검증 성공:
정상 웹훅이 수신되었습니다: <이벤트 ID> (<type>)로그를 남기고, 이벤트type별로 처리한 뒤(app/webhooks/service.py) 200으로 응답합니다. PaySync는 HTTP 200~299를 성공으로 기록하고, 응답을 10초까지 기다립니다. 모르는type도 무시하고 200으로 응답합니다. - 검증 실패: 경고 로그만 남기고(본문은 남기지 않습니다) 400으로 응답합니다.
v2 웹훅 본문은 { "id": "evt_...", "type": "...", "timestamp": "<ISO 8601>", "data": {...} } 형식입니다.
invoice.* 이벤트(invoice.created, invoice.updated, invoice.paid, invoice.expired, invoice.canceled, invoice.reopened)의 data는 주문이고,
transaction.created의 data는 입금입니다. 웹훅 페이로드의 시각은 API 응답과 달리 ISO 8601 문자열입니다.
결제 완료 경로는 invoice.paid의 data.paidTrigger(AUTOMATIC_MATCHING, MANUAL_MATCHING, MANUAL_APPROVE, API_CALL)로 알 수 있습니다.
재시도나 재전송 때문에 같은 이벤트가 여러 번 올 수 있으니 페이로드의 id(evt_...)로 중복을 걸러야 합니다. 이벤트가 오는 순서도 보장되지 않습니다.
1. 서명한 요청을 직접 보내기
서버를 띄운 상태에서 아래 코드를 send_webhook.py로 저장하고 python send_webhook.py로 실행합니다.
.env와 같은 서명 키를 사용합니다.
import json
from datetime import UTC, datetime
import httpx
from standardwebhooks import Webhook
from app.config import get_settings
body = json.dumps(
{
"id": "evt_local",
"type": "invoice.paid",
"timestamp": "2026-04-28T03:20:12Z",
"data": {"id": "ivc_test", "paidTrigger": "API_CALL"},
}
)
msg_id, now = "whm_local_test", datetime.now(UTC)
signature = Webhook(get_settings().paysync_webhook_signing_secret.get_secret_value()).sign(msg_id, now, body)
res = httpx.post(
"http://localhost:8080/api/webhooks",
content=body,
headers={"webhook-id": msg_id, "webhook-timestamp": str(int(now.timestamp())), "webhook-signature": signature},
)
print(res.status_code) # 200
서명 없이 보내면 400이 돌아옵니다.
curl -i -X POST http://localhost:8080/api/webhooks -d '{"type":"invoice.paid"}'
2. PaySync에서 실제 웹훅 받기
PaySync에는 localhost나 사설 IP를 등록할 수 없으므로 터널로 공개 주소를 만듭니다.
cloudflared tunnel --url http://localhost:8080 # 또는: ngrok http 8080
대시보드 웹훅에서 웹훅 추가를 누르고, 받을 URL에 https://<터널 주소>/api/webhooks를 입력합니다.
API 버전은 v2를 고릅니다. 웹훅 목록의 보기에서 서명 키(whsec_...)를 확인해 PAYSYNC_WEBHOOK_SIGNING_SECRET에 넣은 뒤 서버를 다시 시작합니다.
받을 이벤트에 invoice.created를 골랐다면, 주문을 만들 때 웹훅이 옵니다. 행 메뉴의 테스트 이벤트 보내기로 서명 검증까지 확인할 수도 있습니다.
프로젝트 구조
app/
├── main.py # FastAPI 앱, lifespan(httpx 클라이언트 생성), 에러 핸들러
├── config.py # 환경 변수 설정 (pydantic-settings)
├── paysync/
│ ├── client.py # PaySync API 클라이언트, PaySyncApiError
│ └── models.py # v2 응답 봉투, 목록 응답(Page), 주체(Actor), camelCase 기본 모델
├── invoices/ # 주문: models.py, service.py, router.py
├── cash_receipts/ # 현금영수증
├── transactions/ # 입금 내역 (입금 계좌 모델 포함)
└── webhooks/
├── router.py # 서명 검증
└── service.py # 이벤트 type별 처리 (비즈니스 로직 자리)
tests/ # respx로 PaySync API를 흉내 낸 테스트