Payment System · Service Integration
사내 서비스에 구독·결제를 붙이는 개발자를 위한 문서입니다. 호출 방법(인증·요청 본문)과 응답 필드, 상태코드별 분기까지 — 이 한 페이지로 연동에 필요한 API 전부를 다룹니다.
모든 API에 공통으로 흐르는 설계 원칙입니다. 이 넷만 지키면 연동에서 생기는 사고의 대부분을 예방할 수 있습니다.
구독·변경·취소 어디서도 우리 서비스가 금액을 보내지 않습니다.
plan_id·reason만 보내면 할인·수수료·환불액은 결제서버가 정책대로 계산합니다.
금액 위변조가 원천적으로 불가능한 구조입니다.
결제 결과는 성공·실패·결과 불명 셋입니다. 503은 돈이 나갔을 수도 있는 상태 — 재결제하면 이중결제가 됩니다. "확인 중"을 보여주고 내역을 재조회하세요. 결제서버가 몇 분 내 자동 확정합니다.
관리자가 어드민에서 취소·환불해도 우리 DB는 모릅니다. 상태·환불액·실수령액은
로컬 기록이 아니라 조회 API 응답(status/canceled_amount/net_amount)으로 그립니다.
결제서버는 우리 회원 DB를 모릅니다. 두 시스템을 잇는 열쇠는
external_user_id(이메일)뿐이며, 대소문자·공백은 서버가 정규화합니다.
서비스 등록 시 발급받은 API 키와 HMAC secret으로 모든 요청에 서명합니다.
샘플의 payment_client.py를 복사하면 아래가 전부 자동 처리됩니다.
| 헤더 | 값 |
|---|---|
| X-Service-Key | 발급받은 서비스 API 키 |
| X-Timestamp | UNIX 초 — 서버 시각과 ±300초를 넘으면 거부 |
| X-Nonce | 요청마다 새로 만든 UUID — 10분 내 재사용 거부(재전송 공격 차단) |
| X-Signature | 아래 정준 문자열의 HMAC-SHA256(hex) |
# 서명 정준 문자열 — 5요소를 개행(\n)으로 연결
message = "\n".join([METHOD, PATH, TIMESTAMP, NONCE, sha256_hex(BODY)])
signature = HMAC_SHA256(hmac_secret, message)
# 예: GET이면 BODY는 빈 바이트(b"") — sha256_hex(b"")를 그대로 사용
?include_cancellations=true 같은 쿼리를
붙여도 서명 코드는 그대로 두면 됩니다 — path에 쿼리를 이어 붙여 서명하면 401이 납니다.오류 응답은 모든 API가 같은 형태입니다:
{"error": {"code": "...", "message": "..."}} — code로 분기하고
message는 사용자 안내에 그대로 써도 됩니다.
쉽게 말하면 — 구독 결제는 미리 맡겨둔 카드로 자동 청구됩니다. 실제 카드번호·빌링키는 토스와 결제서버가 보관하고 우리는 마스킹된 번호만 받으므로 카드정보 보안 부담이 없습니다.
카드 등록/교체 — 같은 사용자로 다시 호출하면 기존 카드가 교체됩니다(= 카드 변경).
external_user_id(이메일) · customer_key(빌링 인증창에 넘긴 값과 동일) ·
auth_key(인증 성공 시 토스가 준 1회용 키)external_user_id + card(마스킹 정보 —
number "1234-****", issuerCode 등). billingKey는 절대 반환되지 않습니다.쉽게 말하면 — 요금제 목록을 받아 보여주고, 사용자가 고르면 구독 생성 POST 한 번으로 첫 결제까지 끝납니다(등록 카드로 즉시 청구 — 결제창 없음).
활성 요금제 목록. 화면에는 정가(price)가 아니라
실제 첫 청구액(amount)을 보여주세요 — 첫결제 무료·할인이 반영된 값입니다.
| 필드 | 설명 |
|---|---|
| id | 구독 생성에 넘길 plan_id |
| price / amount | 정가 / 첫 결제 실제 청구액(할인·무료 반영) |
| billing_cycle | YEAR·MONTH·WEEK·DAY (+ cycle_days — DAY일 때 일수) |
| first_payment_type | 첫결제 정책: NONE·FREE·DISCOUNT (+ first_payment_value) |
| trial_enabled / trial_days | 무료체험 지원 여부·기간 |
| auto_renew | 자동갱신 여부(false면 기간 종료 시 만료) |
구독 생성 — 등록 카드로 첫 결제가 즉시 실행되고 응답으로 구독 상태까지 받습니다.
external_user_id · plan_id · trial(선택 — 체험 시작).
금액은 보내지 않습니다.status(ACTIVE/TRIAL) ·
access_allowed · current_period_start·end · next_billing_at ·
plan_name · card(마스킹)쉽게 말하면 — 조회 API 한 번이면 상태·기간·카드까지 전부 옵니다.
액션은 본문 없는 POST 하나씩이고, "들여보낼까?"는 access_allowed 하나만 보면 됩니다.
자동 연장·연체 재시도는 결제서버 몫이라 우리가 챙길 배치는 없습니다.
| 필드 | 설명 |
|---|---|
| status | ACTIVE · TRIAL · CANCELED(만료 예약) · PAST_DUE(연체) · SUSPENDED(정지) · EXPIRED |
| access_allowed | 서비스 이용 허용 여부 — 접근 제어는 이 값 하나로. PAST_DUE·만료 전 CANCELED도 true |
| current_period_start / end | 현재 이용 기간 |
| next_billing_at | 다음 자동결제일(자동갱신 아니면 null) |
| card | 청구에 쓰일 마스킹 카드 정보 |
| retry_count | 연체 자동 재시도 횟수 |
| pending_plan_id / name | 다운그레이드 예약 시 만료일에 적용될 요금제 |
… = /api/v1/subscriptions/{external_user_id}.
셋 다 본문 없는 POST입니다 — 취소(만료일까지 이용 유지 후 종료 예약), 재개(만료 전 취소 철회), 변경 예약 철회.
수동 결제 — 연체(PAST_DUE)·정지(SUSPENDED) 구독의 미납분을 등록 카드로 즉시 청구해 복구합니다.
요금제 변경 — 업그레이드는 즉시(새 요금제 결제 + 기존 결제 환불 + 기간 리셋), 다운그레이드는 만료일 적용 예약. 어느 쪽인지 판정은 서버가 합니다.
plan_id · refund_amount(선택, 업그레이드만 — 비우면 남은 기간 초 단위 일할계산)change_type(UPGRADE/DOWNGRADE) ·
charged_amount · refunded_amount ·
refund_status(DONE/NONE/FAILED — 전환은 유지, 환불만 실패 → 수동 환불 안내)쉽게 말하면 — ① 우리 서버가 주문을 미리 등록하고(prepare) ② 사용자가 토스 결제창에서 인증하고 ③ 우리 서버가 승인을 확정합니다(confirm). 승인 금액은 ①에서 저장된 값만 쓰므로 브라우저에서 금액을 조작해도 소용없습니다.
toss_order_id를 받는다.payment.requestPayment()에 toss_order_id를 넘긴다.
성공 시 successUrl로 paymentKey가 온다.external_user_id · order_id(우리 주문번호, 6~64자 영숫자·-_=.) ·
order_name(결제창 표시 상품명) · amount(원)status=PENDING +
toss_order_id(결제창 orderId로 넘길 값 — 핵심 반환값).
같은 order_id 재호출은 기존 주문 반환(멱등 — 새로고침 안전).toss_order_id · payment_key(successUrl 쿼리) ·
amount(선택 — 넣으면 위변조 검증, 세션 유실 시 생략 가능)status=DONE · approved_at ·
receipt_url(매출전표). 재호출해도 같은 결과(멱등).쉽게 말하면 — 조회 한 번에 구독·단건 결제가 전부 오고, 각 건에
"지금 취소하면 수수료 얼마·환불 얼마"까지 계산되어 옵니다. 우리가 계산할 것은 없습니다.
외부에서 취소할 수 있는 것은 단건 결제뿐이며(구독 결제 환불은 어드민 전용),
전액·부분 취소를 모두 지원합니다(cancel_amount로 취소 원금 지정 — 상한은 cancel_remaining).
회차별 취소 이력이 필요하면 include_cancellations=true를 붙입니다.
구독+단건 결제 전체, 최신순 최대 50건.
쿼리 ?include_cancellations=true로 회차별 취소 내역 포함(쿼리는 서명 무관).
| 필드 | 설명 |
|---|---|
| order_id / toss_order_id | 우리 주문번호 / 토스 콘솔·매출전표 대조용 주문번호 |
| order_name | 상품명(실패·과거 건도 서버가 보관 — 그대로 표시) |
| amount / status / kind | 청구액 / PENDING·DONE·FAILED·CANCELED / SUBSCRIPTION·ONE_OFF |
| failure_code · message | 실패 시 코드·사용자용 메시지 |
| receipt_url | 토스 매출전표 링크(새 탭으로 열기 — 없으면 null) |
| cancelable | 지금 외부 취소 가능 여부 — 취소 버튼은 이 값으로만(부분취소 후 잔여가 있으면 계속 true) |
| cancel_remaining | 취소 가능 원금 = 결제금액 − (누적 환불 + 누적 수수료). 부분취소 요청의 상한 |
| cancel_fee / cancel_refund_amount | 취소 시 수수료/환불액 — 취소 전엔 예상값, 후엔 실제값 |
| canceled_amount / net_amount | 누적 환불액 / 실수령(= amount − 누적 환불). 부분취소는 status=DONE인 채 canceled_amount만 커짐 |
| cancellations[] | 회차별 취소 이력(옵션·시간순) — 아래 표 |
| 필드 | 설명 |
|---|---|
| canceled_at | 취소 발생 시각 |
| cancel_amount | 이번 회차 환불액(원) |
| cancel_fee | 이번 회차 차감 수수료 — 무수수료 취소는 null |
| reason | 취소 사유 — 이력 도입 이전 건은 "backfill"(누적 합산 1건) |
| actor_type | USER(관리자) · SERVICE(우리 요청) · SYSTEM(자동 동기화) |
reason(취소 사유) · cancel_amount(선택 — 이번에 취소할 원금, 생략하면 잔여 전액).
수수료·환불액은 취소 원금에 서비스 정책(수수료율)을 적용해 서버가 계산합니다. 부분취소는 잔여가 남는 한 반복 가능.status=CANCELED(부분이면 DONE 유지) ·
canceled_amount=누적 환불 · cancel_fee=누적 수수료 ·
cancel_remaining=남은 취소가능 원금cancel_remaining) 초과·1원 미만 ·
402 CANCEL_DISABLED — 서비스 취소 정책 꺼짐(고객센터 안내) ·
409 "진행 중" — 동시 취소 직렬화, 재시도 말고 잠시 후 내역 확인 ·
409 "관리자가 취소 처리한 결제" — 어드민 개입 건(외부 추가 취소 불가) ·
404 주문 없음쉽게 말하면 — "구독이 갱신됐어요", "결제가 실패했어요" 같은 소식을 결제서버가 우리 URL로 직접 POST해 줍니다. 받는 엔드포인트 하나만 만들어 어드민에 등록하면 되고, 서명 검증 후 바로 200을 돌려주는 것이 핵심입니다. 놓쳐도 조회 API로 복구되니 보조 수단입니다.
X-Event(이벤트명) · X-Signature/X-Timestamp/X-Nonce
— 우리 hmac_secret으로 검증(±5분·nonce 재사용 거부)EVENT(식별자) · subscribe_id/order_id(대상) ·
PRE_STATUS→STATUS(상태 변화) · service_name ·
email(대상 사용자) · date(발생 시각, KST) · DESC(설명)쉽게 말하면 — "카드 한도 초과면 우리 화면에 뭐가 뜨지?"를 실제 결제 없이 확인합니다. 요청에 헤더 하나만 얹으면 토스가 그 에러로 응답해 줍니다.
TossPayments-Test-CodeTossPayments-Test-Code: 에러코드
(서명 대상 아님 — 서명 코드 수정 불필요)| 상태 | 의미 | access_allowed |
|---|---|---|
| ACTIVE / TRIAL | 정상 이용 / 무료체험 | true |
| CANCELED | 취소 예약 — 만료일까지 이용 유지 | true(만료 전) |
| PAST_DUE | 연체 — 자동 재시도 중 | true(유예) |
| SUSPENDED | 정지 — 수동 결제로만 복구 | false |
| EXPIRED | 종료 — 재구독 가능 | false |
| 상태 | 의미 |
|---|---|
| PENDING | 요청 생성됨·승인 대기(결과 불명 포함 — 몇 분 내 자동 확정) |
| DONE | 승인 완료. 부분취소여도 DONE 유지 — canceled_amount>0로 판정 |
| FAILED | 실패 확정 — failure_code·message 참조 |
| CANCELED | 전액 환불 도달·취소 종료 |
| 코드 | 상황 | 우리가 할 일 |
|---|---|---|
| 503 | 결과 불명(타임아웃) | 재결제 금지 — "확인 중" 안내 후 내역 재조회 |
| 402 | 카드 거절·정책 거부 | error.code별 안내(카드 변경 유도 등) |
| 409 | 동시 처리·상태 충돌 | "진행 중"은 대기 후 확인 · 그 외는 상태 재조회 |
| 422 | 입력 검증 실패·금액 불일치 | 요청 본문 필드·금액 확인 |
| 401 | 서명 불일치 | 정준 문자열 5요소·시계 오차(±300초)·쿼리 미포함 확인 |
| 404 | 대상 없음 | 카드·구독 조회의 404는 정상 분기로 처리 |