Payment System · Service Integration

결제서버 서비스 연동 가이드

사내 서비스에 구독·결제를 붙이는 개발자를 위한 문서입니다. 호출 방법(인증·요청 본문)과 응답 필드, 상태코드별 분기까지 — 이 한 페이지로 연동에 필요한 API 전부를 다룹니다.

기준 경로 /api/v1 결제 모듈 토스페이먼츠 사용자 식별 이메일(external_user_id) 2026-07 기준
01

먼저 알아야 할 4가지 원칙

모든 API에 공통으로 흐르는 설계 원칙입니다. 이 넷만 지키면 연동에서 생기는 사고의 대부분을 예방할 수 있습니다.

금액은 항상 서버가 계산

구독·변경·취소 어디서도 우리 서비스가 금액을 보내지 않습니다. plan_id·reason만 보내면 할인·수수료·환불액은 결제서버가 정책대로 계산합니다. 금액 위변조가 원천적으로 불가능한 구조입니다.

타임아웃(503)은 실패가 아니다

결제 결과는 성공·실패·결과 불명 셋입니다. 503은 돈이 나갔을 수도 있는 상태 — 재결제하면 이중결제가 됩니다. "확인 중"을 보여주고 내역을 재조회하세요. 결제서버가 몇 분 내 자동 확정합니다.

화면 표시는 항상 서버 응답 기준

관리자가 어드민에서 취소·환불해도 우리 DB는 모릅니다. 상태·환불액·실수령액은 로컬 기록이 아니라 조회 API 응답(status/canceled_amount/net_amount)으로 그립니다.

사용자 식별은 이메일 하나

결제서버는 우리 회원 DB를 모릅니다. 두 시스템을 잇는 열쇠는 external_user_id(이메일)뿐이며, 대소문자·공백은 서버가 정규화합니다.

02

인증 — HMAC 서명 헤더 4종

서비스 등록 시 발급받은 API 키HMAC secret으로 모든 요청에 서명합니다. 샘플의 payment_client.py를 복사하면 아래가 전부 자동 처리됩니다.

헤더
X-Service-Key발급받은 서비스 API 키
X-TimestampUNIX 초 — 서버 시각과 ±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"")를 그대로 사용
쿼리 파라미터는 서명 대상이 아닙니다. 서명에는 URL path만 들어갑니다. ?include_cancellations=true 같은 쿼리를 붙여도 서명 코드는 그대로 두면 됩니다 — path에 쿼리를 이어 붙여 서명하면 401이 납니다.

오류 응답은 모든 API가 같은 형태입니다: {"error": {"code": "...", "message": "..."}}code로 분기하고 message는 사용자 안내에 그대로 써도 됩니다.

03

카드 보관함

쉽게 말하면 — 구독 결제는 미리 맡겨둔 카드로 자동 청구됩니다. 실제 카드번호·빌링키는 토스와 결제서버가 보관하고 우리는 마스킹된 번호만 받으므로 카드정보 보안 부담이 없습니다.

POST /api/v1/cardsregister_card()

카드 등록/교체 — 같은 사용자로 다시 호출하면 기존 카드가 교체됩니다(= 카드 변경).

요청
external_user_id(이메일) · customer_key(빌링 인증창에 넘긴 값과 동일) · auth_key(인증 성공 시 토스가 준 1회용 키)
응답
201 external_user_id + card(마스킹 정보 — number "1234-****", issuerCode 등). billingKey는 절대 반환되지 않습니다.
GET /api/v1/cards/{external_user_id}get_card()
요청
본문 없음(경로에 이메일)
응답
200 등록 카드(POST와 같은 구조) · 404 카드 없음 — 오류가 아니라 "등록 화면으로 보내라"는 정상 분기
DELETE /api/v1/cards/{external_user_id}delete_card()
요청
본문 없음
응답
204 삭제 완료(본문 없음) · 409 이 카드를 쓰는 진행 중 구독이 있음 → 구독 정리 먼저
04

요금제 조회와 구독 시작

쉽게 말하면 — 요금제 목록을 받아 보여주고, 사용자가 고르면 구독 생성 POST 한 번으로 첫 결제까지 끝납니다(등록 카드로 즉시 청구 — 결제창 없음).

GET /api/v1/plansget_plans()

활성 요금제 목록. 화면에는 정가(price)가 아니라 실제 첫 청구액(amount)을 보여주세요 — 첫결제 무료·할인이 반영된 값입니다.

필드설명
id구독 생성에 넘길 plan_id
price / amount정가 / 첫 결제 실제 청구액(할인·무료 반영)
billing_cycleYEAR·MONTH·WEEK·DAY (+ cycle_days — DAY일 때 일수)
first_payment_type첫결제 정책: NONE·FREE·DISCOUNT (+ first_payment_value)
trial_enabled / trial_days무료체험 지원 여부·기간
auto_renew자동갱신 여부(false면 기간 종료 시 만료)
POST /api/v1/subscriptionscreate_subscription()

구독 생성 — 등록 카드로 첫 결제가 즉시 실행되고 응답으로 구독 상태까지 받습니다.

요청
external_user_id · plan_id · trial(선택 — 체험 시작). 금액은 보내지 않습니다.
응답
201 구독 1건 — status(ACTIVE/TRIAL) · access_allowed · current_period_start·end · next_billing_at · plan_name · card(마스킹)
오류
402 첫 결제 실패 — 구독은 EXPIRED·결제는 FAILED로 남고 즉시 재시도 가능  409 이미 열린 구독 존재(서비스·사용자당 1구독)  404 요금제/카드 없음 → 카드 등록 화면으로 유도
05

구독 관리 — 조회·취소·재개·수동결제·요금제 변경

쉽게 말하면 — 조회 API 한 번이면 상태·기간·카드까지 전부 옵니다. 액션은 본문 없는 POST 하나씩이고, "들여보낼까?"는 access_allowed 하나만 보면 됩니다. 자동 연장·연체 재시도는 결제서버 몫이라 우리가 챙길 배치는 없습니다.

GET /api/v1/subscriptions/{external_user_id}get_subscription()
요청
본문 없음 · 404 = 구독 없음(요금제 화면으로 보내는 정상 분기)
응답
200 아래 필드의 구독 1건
필드설명
statusACTIVE · 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다운그레이드 예약 시 만료일에 적용될 요금제
POST …/cancel · …/resume · …/change-plan/cancel cancel() / resume() / cancel_plan_change()

= /api/v1/subscriptions/{external_user_id}. 셋 다 본문 없는 POST입니다 — 취소(만료일까지 이용 유지 후 종료 예약), 재개(만료 전 취소 철회), 변경 예약 철회.

응답
200 갱신된 구독(조회와 같은 구조 — status 변화 확인) · 409 해당 상태에서 불가능한 액션
POST …/paymanual_pay()

수동 결제 — 연체(PAST_DUE)·정지(SUSPENDED) 구독의 미납분을 등록 카드로 즉시 청구해 복구합니다.

응답
200 ACTIVE 복귀·기간 전진 · 402 카드 거절 → 카드 변경 유도 · 503 결과 불명 — 실패 아님, 재결제 금지 · 409 이미 처리 중
POST …/change-planchange_plan()

요금제 변경 — 업그레이드는 즉시(새 요금제 결제 + 기존 결제 환불 + 기간 리셋), 다운그레이드는 만료일 적용 예약. 어느 쪽인지 판정은 서버가 합니다.

요청
plan_id · refund_amount(선택, 업그레이드만 — 비우면 남은 기간 초 단위 일할계산)
응답
200 갱신된 구독 + change_type(UPGRADE/DOWNGRADE) · charged_amount · refunded_amount · refund_status(DONE/NONE/FAILED — 전환은 유지, 환불만 실패 → 수동 환불 안내)
오류
402 업그레이드 결제 실패 — 구독은 기존 그대로 무변경
06

단건 결제 — 결제창 3단계

쉽게 말하면 — ① 우리 서버가 주문을 미리 등록하고(prepare) ② 사용자가 토스 결제창에서 인증하고 ③ 우리 서버가 승인을 확정합니다(confirm). 승인 금액은 ①에서 저장된 값만 쓰므로 브라우저에서 금액을 조작해도 소용없습니다.

1
내 서버prepare — 주문 등록
금액·상품명을 서버에 저장하고 toss_order_id를 받는다.
2
브라우저토스 결제창 — 카드 인증
SDK payment.requestPayment()toss_order_id를 넘긴다. 성공 시 successUrlpaymentKey가 온다.
3
내 서버결제서버confirm — 승인 확정
successUrl 콜백에서 즉시 confirm을 호출한다(10분 내). 서버 저장 금액으로만 승인된다.
POST /api/v1/payments/prepareprepare_one_off_payment()
요청
external_user_id · order_id(우리 주문번호, 6~64자 영숫자·-_=.) · order_name(결제창 표시 상품명) · amount(원)
응답
201 status=PENDING + toss_order_id(결제창 orderId로 넘길 값 — 핵심 반환값). 같은 order_id 재호출은 기존 주문 반환(멱등 — 새로고침 안전).
POST /api/v1/payments/confirmconfirm_one_off_payment_window()
요청
toss_order_id · payment_key(successUrl 쿼리) · amount(선택 — 넣으면 위변조 검증, 세션 유실 시 생략 가능)
응답
200 status=DONE · approved_at · receipt_url(매출전표). 재호출해도 같은 결과(멱등).
오류
503 결과 불명 — 재결제 금지, 내역 재조회 · 409 같은 주문 승인 진행 중 · 422 금액 불일치(위변조 의심) · 404 주문 없음
결제창 이탈은 자동 정리됩니다. 결제창을 열고 완료하지 않은 PENDING 주문은 35분 뒤 결제서버가 FAILED(EXPIRED)로 정리합니다 — 우리가 청소할 필요 없습니다.
07

결제 내역 조회와 취소(환불)

쉽게 말하면 — 조회 한 번에 구독·단건 결제가 전부 오고, 각 건에 "지금 취소하면 수수료 얼마·환불 얼마"까지 계산되어 옵니다. 우리가 계산할 것은 없습니다. 외부에서 취소할 수 있는 것은 단건 결제뿐이며(구독 결제 환불은 어드민 전용), 전액·부분 취소를 모두 지원합니다(cancel_amount로 취소 원금 지정 — 상한은 cancel_remaining). 회차별 취소 이력이 필요하면 include_cancellations=true를 붙입니다.

GET /api/v1/payments/{external_user_id}get_payments()

구독+단건 결제 전체, 최신순 최대 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[]회차별 취소 이력(옵션·시간순) — 아래 표

cancellations[] — 취소 1회 = 1건

필드설명
canceled_at취소 발생 시각
cancel_amount이번 회차 환불액(원)
cancel_fee이번 회차 차감 수수료 — 무수수료 취소는 null
reason취소 사유 — 이력 도입 이전 건은 "backfill"(누적 합산 1건)
actor_typeUSER(관리자) · SERVICE(우리 요청) · SYSTEM(자동 동기화)
POST /api/v1/payments/{order_id}/cancelcancel_one_off_payment()
요청
reason(취소 사유) · cancel_amount(선택 — 이번에 취소할 원금, 생략하면 잔여 전액). 수수료·환불액은 취소 원금에 서비스 정책(수수료율)을 적용해 서버가 계산합니다. 부분취소는 잔여가 남는 한 반복 가능.
응답
200 잔여 소진 시 status=CANCELED(부분이면 DONE 유지) · canceled_amount=누적 환불 · cancel_fee=누적 수수료 · cancel_remaining=남은 취소가능 원금
오류
422 취소가능금액(cancel_remaining) 초과·1원 미만 · 402 CANCEL_DISABLED — 서비스 취소 정책 꺼짐(고객센터 안내) · 409 "진행 중" — 동시 취소 직렬화, 재시도 말고 잠시 후 내역 확인 · 409 "관리자가 취소 처리한 결제" — 어드민 개입 건(외부 추가 취소 불가) · 404 주문 없음
08

알림 수신 — 결제서버가 보내는 웹훅

쉽게 말하면 — "구독이 갱신됐어요", "결제가 실패했어요" 같은 소식을 결제서버가 우리 URL로 직접 POST해 줍니다. 받는 엔드포인트 하나만 만들어 어드민에 등록하면 되고, 서명 검증 후 바로 200을 돌려주는 것이 핵심입니다. 놓쳐도 조회 API로 복구되니 보조 수단입니다.

수신 POST {서비스 알림 URL}결제서버 → 우리 서비스
헤더
X-Event(이벤트명) · X-Signature/X-Timestamp/X-Nonce — 우리 hmac_secret으로 검증(±5분·nonce 재사용 거부)
본문
EVENT(식별자) · subscribe_id/order_id(대상) · PRE_STATUSSTATUS(상태 변화) · service_name · email(대상 사용자) · date(발생 시각, KST) · DESC(설명)
응답
서명 검증 후 즉시 200 — 무거운 처리는 200 반환 뒤에. 5xx여도 결제·구독 처리 자체에는 영향 없습니다(best-effort).
09

에러 재현(QA) — 실패를 일부러 만들기

쉽게 말하면 — "카드 한도 초과면 우리 화면에 뭐가 뜨지?"를 실제 결제 없이 확인합니다. 요청에 헤더 하나만 얹으면 토스가 그 에러로 응답해 줍니다.

POST confirm · subscriptions + 헤더 TossPayments-Test-Code
방법
원래 API와 요청·인증 완전히 동일 + 헤더 TossPayments-Test-Code: 에러코드 (서명 대상 아님 — 서명 코드 수정 불필요)
응답
지정한 에러의 실제 실패 응답 그대로 — 에러 분기 코드를 그대로 검증할 수 있습니다.
조건
결제서버가 비운영(STG/dev) + 에러주입 활성 + 토스 test 키일 때만. 운영·라이브 키에서는 헤더가 조용히 무시되어 안전합니다.
10

상태·에러 빠른 참조

구독 상태

상태의미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는 정상 분기로 처리