13. 서비스 연동 API
이 문서는 사내 외부 서비스(진료 앱·쇼핑몰 등)가 구독·결제 서버에 직접 호출하는 REST API 전체 레퍼런스입니다. 인증(HMAC 서명)부터 카드·구독·결제·조회, 그리고 서버가 서비스로 보내는 알림 수신까지 한곳에 정리했습니다.
🍎쉽게 말하면, 이 문서는 "내 서비스 코드가 구독·결제 서버와 주고받는 약속(요청·응답 형식)"을 그대로 적어 둔 사전입니다.
함께 보기: 카드 기능 코드 흐름 · 구독 기능 코드 흐름 · 결제 기능 코드 흐름 · 서비스 알림
참고: 모든 외부 API는
/api/v1접두어로 등록됩니다(app/main.py). 예:POST /api/v1/cards.
13.1 공통 규칙
- 요청/응답 형식: JSON. 인증이 필요한 요청에는 아래 4개 서명 헤더를 반드시 포함합니다.
- 엄격한 요청 본문: 모든 요청 스키마는
extra='forbid'이므로 정의되지 않은 필드를 보내면422로 거부됩니다(오타·숨은 필드 주입 차단). 예전에는 여분 필드가 조용히 무시됐으나 이제는 명시적으로 실패하니, 문서에 없는 키를 본문에 포함하지 마세요. - 금액 보호: 구독 금액은 서버가 요금제(Plan)에서 직접 계산하므로 클라이언트가 보낼 수 없습니다. 단건 결제만 클라이언트가
amount를 지정하며, 이때도 HMAC 본문 서명이 금액 변조를 차단합니다. - 민감 정보 비노출: 빌링키(billingKey) 등 결제 키 원문은 어떤 응답에도 포함되지 않습니다. 카드는 마스킹 정보만 반환됩니다.
- 키 스코프: 일반 서비스 키는 카드·구독·결제 API를, 모니터링 전용 키(어드민 2.13)는
/api/v1/metrics/*(13.10)만 호출할 수 있습니다. 서로의 API를 호출하면403 FORBIDDEN입니다. 모니터링 키는 허용 IP가 비어 있으면 같은 서버(루프백)에서만 통과하고,/api/v1/metrics/*에는 별도 분당 한도 30회(METRICS_RATE_LIMIT_PER_MINUTE)가 적용됩니다. - 사용자 기준 키: 대부분의 경로는
{external_user_id}(외부 서비스 측 사용자 식별자)를 사용합니다. (서비스 + 사용자 당 카드 1장·구독 1개 규칙의 기준) external_user_id(이메일)는 반드시 이메일: 전역 룰로external_user_id(이메일)에는 이메일만 허용합니다. 서버가 받는 즉시 앞뒤 공백 제거 + 소문자로 정규화해 저장·조회하므로(app/core/identifiers.py), 대소문자만 다른 값(User@x.comvsuser@x.com)은 같은 사용자로 취급됩니다. 이메일 형식이 아니면422로 거부됩니다. 경로(/cards/{external_user_id}등)에 이메일을 넣을 때+같은 특수문자는 URL 인코딩하세요. (HMAC 서명은 클라이언트가 보낸 원본 경로 기준이므로 정규화와 무관하게 동작합니다.)
13.2 인증 — HMAC 서명
모든 인증 필요 요청은 API 키 + IP 화이트리스트 + HMAC 서명 3중 검증을 통과해야 합니다(app/api/deps.py:67 authenticate_service). 검증 순서는 ① API 키 해시 대조 → ② IP 화이트리스트 → ③ 처리율 제한 → ④ 타임스탬프 윈도우 → ⑤ HMAC 서명 → ⑥ nonce 1회용 소비입니다.
🍎쉽게 말하면, 요청을 보낼 때마다 비밀키로 만든 "도장(서명)"을 찍어 보내고, 서버가 같은 도장을 다시 만들어 대조하는 방식입니다. 타임스탬프와 nonce(요청마다 바뀌는 1회용 난수)는 도장이 찍힌 요청을 나중에 훔쳐서 다시 보내는 것(재전송)을 막습니다.
13.2.1 필수 헤더 4개
| 헤더 | 예시 값 | 설명 |
|---|---|---|
x-service-key |
svc_abc123... |
서비스 API 키 원문. 어드민에서 1회 발급(svc_ 접두어). |
x-timestamp |
1749520800 |
요청 시각 Unix 초(정수 문자열). 서버 시각과 ±300초 이내여야 합니다. |
x-nonce |
a1b2c3d4e5f6... |
요청마다 다른 랜덤 문자열(UUID hex 권장). 600초 내 재사용 불가. |
x-signature |
fa3c7d8e... |
아래 정준 문자열의 HMAC-SHA256 서명(hex). |
⚠️주의: 타임스탬프 허용 오차는
hmac_timestamp_tolerance_seconds(기본 300초), nonce 1회용 키 TTL은hmac_nonce_ttl_seconds(기본 600초)입니다(app/core/config.py). 같은 nonce를 600초 내 재사용하면 401로 거부됩니다(재전송 방어).
13.2.2 정준 문자열(canonical string)과 서명 계산식
서버 구현은 app/core/security.py:62 sign_request입니다. 5개 구성요소를 줄바꿈(\n)으로 이어 붙인 뒤 HMAC-SHA256으로 서명합니다.
{METHOD 대문자}
{path}
{timestamp}
{nonce}
{sha256_hex(요청본문 bytes)}
path는 쿼리스트링을 제외한 경로 부분(예:/api/v1/cards)입니다.- 본문이 없는 요청(GET·본문 없는 POST)도 빈 바이트(
b"")를 SHA-256 해시합니다(e3b0c44298fc...). method/path/timestamp/nonce에 개행 문자가 들어오면 서명 계산이 거부됩니다(필드 간 바이트 이동 공격 방어,app/core/security.py:69).
서명 값:
x-signature = HMAC_SHA256(hmac_secret, canonical_string) # hex 인코딩
13.2.3 Python 예시
import hashlib, hmac, json, time, uuid
import requests
BASE = "http://127.0.0.1:8000" # 구독·결제 서버 주소
API_KEY = "svc_xxx" # 어드민에서 발급한 API 키
HMAC_SECRET = "xxx" # 〃 HMAC 시크릿
def sign_request(secret, method, path, timestamp, nonce, body):
"""app/core/security.py:62 sign_request 와 동일한 알고리즘."""
body_hash = hashlib.sha256(body).hexdigest()
message = "\n".join([method.upper(), path, timestamp, nonce, body_hash])
return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
def call(method, path, json_body=None):
body = json.dumps(json_body).encode() if json_body is not None else b""
ts = str(int(time.time()))
nonce = uuid.uuid4().hex # 요청마다 새로 — 절대 재사용 금지
headers = {
"x-service-key": API_KEY,
"x-timestamp": ts,
"x-nonce": nonce,
"x-signature": sign_request(HMAC_SECRET, method, path, ts, nonce, body),
}
if json_body is not None:
headers["Content-Type"] = "application/json"
resp = requests.request(method, BASE + path, headers=headers,
data=body or None, timeout=30)
if resp.status_code >= 400:
err = resp.json()["error"]
raise Exception(f"{err['code']}: {err['message']}")
return resp.json()
⚠️주의(자주 하는 실수): ① METHOD는 대문자, ②
path앞/포함·쿼리스트링 제외, ③ 빈 body도sha256("")로 해시, ④ JSON 본문은 서명에 쓴 바이트와 실제 전송 바이트가 동일해야 함(json.dumps()결과 그대로 전송).
13.3 조회 API — 서비스·요금제 목록
연동을 시작할 때 필요한 읽기 전용 엔드포인트입니다. 서버 상태·서비스 목록은 키 입력 전 단계에서도 호출할 수 있도록 무인증이고, 요금제 목록은 일반 HMAC 인증이 필요합니다.
| 메서드·경로 | 인증 | 용도 | 라우트 |
|---|---|---|---|
GET /api/v1/server-status |
무인증 | 결제서버 킬스위치 상태·안내 문구 조회(점검 배너용) | app/api/v1/services.py |
GET /api/v1/work-notices |
무인증 | 진행·예정 서버 작업 공지 조회(작업 배너용) | app/api/v1/services.py |
GET /api/v1/services |
무인증 | 등록된 서비스 목록(id·이름·상태)만 조회 | app/api/v1/services.py |
GET /api/v1/plans |
HMAC | 인증된 서비스의 활성(ACTIVE) 요금제 목록 조회 | app/api/v1/plans.py:15 |
13.3.1 서비스 목록 조회 — GET /api/v1/services
API 키 입력 전 단계에서 서비스를 식별·선택하기 위한 용도입니다. 인증이 필요 없으며, 키·시크릿·구독 등 민감정보는 절대 포함하지 않습니다(id·name·status만). 이름 오름차순으로 정렬해 반환합니다.
⚠️주의: 운영 환경에서 사내 서비스 구성 노출이 우려되면
public_service_list_enabled=false로 이 엔드포인트를 끌 수 있습니다(app/core/config.py, 기본 true). 끄면 존재 자체를 숨기기 위해 404를 반환합니다.
응답 (200) (ServiceListResponse, app/schemas/api.py:441)
| 필드 | 타입 | 설명 |
|---|---|---|
services |
array | 서비스 항목 배열(이름 오름차순) |
services[].id |
string | 서비스 ID |
services[].name |
string | 서비스 이름 |
services[].status |
string | 서비스 상태: ACTIVE | INACTIVE |
{
"services": [
{"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "진료 앱", "status": "ACTIVE"}
]
}
13.3.2 요금제 목록 조회 — GET /api/v1/plans
인증된 서비스에 속한 활성(status=ACTIVE) 요금제만 반환합니다(비활성 요금제는 외부에 노출하지 않아, 이미 판매 종료된 요금제로 신규 구독을 요청하는 것을 막습니다). 응답의 id를 구독 생성(13.5.1)의 plan_id로 사용합니다. 요청 본문은 없습니다.
응답 (200) (PlanListResponse → PlanResponse 배열, app/schemas/api.py:62)
| 필드 | 타입 | 설명 |
|---|---|---|
id |
UUID | 요금제 ID. 구독 생성 시 plan_id로 사용 |
name |
string | 요금제 이름 |
price |
int | 정가(원) |
amount |
int | 실제 정기 청구 금액(원). 상시 할인 적용 후 값이며, 할인이 없으면 price와 동일 |
currency |
string | 통화 코드(예: KRW) |
billing_cycle |
string | 결제 주기: YEAR | MONTH | WEEK | DAY | MINUTE |
cycle_days |
int | null | DAY 주기일 때의 실제 일수. 그 외 주기에서는 null |
cycle_minutes |
int | null | MINUTE 주기일 때의 실제 분(5 이상). 그 외 null. 테스트용·비운영 전용 |
first_payment_type |
string | 첫 결제 혜택 유형: NONE | FREE | DISCOUNT_AMOUNT | DISCOUNT_PERCENT |
first_payment_value |
int | null | 첫 결제 할인 값(정액=원, 정률=%). 혜택 없으면 null |
trial_enabled |
bool | 체험 제공 여부. true일 때만 구독 생성에서 trial=true 가능 |
trial_days |
int | null | 체험 일수. 체험 미제공 시 null |
auto_renew |
bool | 자동갱신 여부. false면 첫 주기 종료 후 자동결제 없이 만료 |
extra_info |
object | 서비스 측 요금제 부가 정보(key/value) |
{
"plans": [
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "스탠다드 월간",
"price": 10000,
"amount": 9000,
"currency": "KRW",
"billing_cycle": "MONTH",
"cycle_days": null,
"cycle_minutes": null,
"first_payment_type": "FREE",
"first_payment_value": null,
"trial_enabled": true,
"trial_days": 7,
"auto_renew": true,
"extra_info": {}
}
]
}
13.3.3 결제서버 상태 조회 — GET /api/v1/server-status
결제서버 킬스위치(점검) 상태를 조회합니다(요청 037). 인증이 필요 없으며, 외부 서비스가 자기 화면 모든 페이지 상단에 "결제 서버 점검 중 …" 배너를 띄우는 용도입니다. message는 관리자가 킬스위치 비활성화 시 입력한 사유(점검 일정 등)로, 킬스위치 503 오류 응답의 메시지와 동일한 문구입니다. disabled=true인 동안 다른 외부 API는 모두 503(SERVER_DISABLED)을 반환합니다.
응답 (200) (ServerStatusResponse, app/schemas/api.py)
| 필드 | 타입 | 설명 |
|---|---|---|
disabled |
bool | true면 결제서버 비활성(점검 중) |
message |
string | null | 비활성 사유·안내 문구. 활성 상태면 null, 사유 미입력 시 "서비스 점검 중입니다" |
{"disabled": true, "message": "정기 점검 2026-06-20 00:00~06:00"}
💡팁: 결제 호출의 503 응답을 기다리지 말고, 이 엔드포인트를 주기적으로(예: 1분) 폴링해 점검 배너를 선제적으로 노출하세요. 상태는 서버에서 5초 TTL로 캐시되므로 부담 없이 호출할 수 있습니다.
13.3.4 작업 공지 조회 — GET /api/v1/work-notices
관리자가 공지사항 게시판에 등록한 작업(WORK) 공지 중 상단노출이 체크되고 종료일시가 지나지 않은 것(예정 포함)을 반환합니다 — 배너 노출 여부는 관리자의 상단노출 체크박스가 결정하며, 체크가 해제되면 기간이 남아 있어도 목록에서 빠집니다. 외부 서비스는 이 값으로 자기 화면 상단에 작업 안내 배너를 띄울 수 있습니다(킬스위치 server-status와 같은 무인증 패턴 — 샘플 서비스가 실제로 사용).
| 필드 | 타입 | 설명 |
|---|---|---|
id |
UUID | 공지 ID |
title |
string | 작업 제목 |
content |
string | 본문 원문 — is_markdown=true면 마크다운 문서(렌더링은 수신 서비스 몫) |
is_markdown |
bool | 본문이 마크다운인지 |
work_start_at / work_end_at |
datetime | 작업 시작/종료 일시(UTC). 종료가 지나면 목록에서 빠짐 |
{"work_notices": [{"id": "…", "title": "7/20 DB 점검", "content": "**00:00~06:00** 결제 지연 가능",
"is_markdown": true, "work_start_at": "2026-07-19T15:00:00Z",
"work_end_at": "2026-07-19T21:00:00Z"}]}
13.4 카드 API
구독 결제 전에 카드를 먼저 등록해야 합니다(단건결제는 결제창 방식이라 카드 등록 불필요 — 13.6 참고). 빌링키는 등록된 카드(카드 보관함)에서 서버가 자동 조회하므로, 구독 요청에는 카드 정보를 넣지 않습니다. 예외: 체험(trial=true) 구독은 카드 없이 시작할 수 있습니다 — 체험 만료 시 카드가 등록되어 있으면 첫 자동결제로 ACTIVE 전환, 없으면 결제 시도 없이 즉시 만료(EXPIRED)됩니다.
| 메서드·경로 | 인증 | 용도 | 라우트 |
|---|---|---|---|
POST /api/v1/cards |
HMAC + 결제 제한 | 카드 등록 또는 교체(빌링키 발급) | app/api/v1/cards.py:41 |
GET /api/v1/cards/{external_user_id} |
HMAC | 등록 카드 마스킹 정보 조회(없으면 404) | app/api/v1/cards.py:84 |
DELETE /api/v1/cards/{external_user_id} |
HMAC | 카드·빌링키 삭제(204) | app/api/v1/cards.py:117 |
13.4.1 카드 등록 / 교체 — POST /api/v1/cards
(service, external_user_id)당 1장을 유지하며, 카드가 이미 있으면 기존 행을 교체하고 이전 빌링키를 best-effort 삭제합니다. 응답에 billingKey는 절대 포함되지 않습니다.
요청 본문 (CardRegisterRequest, app/schemas/api.py:499)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
external_user_id(이메일) |
string | 이메일, 1–255자 | 외부 서비스 측 사용자 식별자(이메일·소문자 정규화) |
customer_key |
string | 2–300자 | 토스 customerKey(고객 식별자, 최소 2자) |
auth_key |
string | 1–300자 | 토스 결제창에서 발급받은 1회용 authKey(빌링키 발급에 사용) |
{
"external_user_id": "user@example.com",
"customer_key": "cust-123",
"auth_key": "toss_auth_key_xxx"
}
⚠️중요:
customer_key와auth_key는 이 API를 호출하기 전에 서비스 클라이언트(앱/웹)에서 토스 결제창(빌링 인증)으로 얻습니다. 결제 서버가 발급하는 값이 아닙니다. 흐름은 다음과 같습니다.
- 서비스가 사용자별로 정한
customerKey(중복 없는 고객 식별자)로 토스 SDK 빌링 인증창(requestBillingAuth)을 띄웁니다.- 사용자가 카드 인증을 마치면, 토스가 지정한 successUrl로 1회용
authKey를 돌려줍니다.- 서비스 서버가 이
customerKey/authKey를 받아POST /api/v1/cards로 전달하면, 결제 서버가 토스에 빌링키 발급을 요청해 카드 보관함에 암호화 저장합니다.
authKey는 1회용이라 빌링키 발급에 한 번 쓰면 재사용할 수 없습니다(재발급은 인증창부터 다시). 토스 결제창(빌링) 연동 자체는docs/toss/3.SDK·docs/toss/1.가이드또는 토스페이먼츠 개발자 문서를 참고하세요.
응답 (201) (CardResponse, app/schemas/api.py:525)
| 필드 | 타입 | 설명 |
|---|---|---|
external_user_id(이메일) |
string | 외부 서비스 측 사용자 식별자 |
card |
object | null | 카드 마스킹 정보(issuerCode·number 등 표시용). 정보 없으면 null |
{
"external_user_id": "user@example.com",
"card": {"issuerCode": "61", "number": "123456******1234"}
}
13.4.2 카드 조회 — GET /api/v1/cards/{external_user_id}
응답 형식은 등록과 동일(CardResponse)합니다. 등록된 카드가 없으면 404를 반환합니다.
13.4.3 카드 삭제 — DELETE /api/v1/cards/{external_user_id}
성공 시 본문 없이 204 No Content를 반환합니다.
⚠️주의: billing-active 상태(TRIAL·ACTIVE·PAST_DUE·SUSPENDED·EXTENDED)의 구독이 이 카드를 사용 중이면 409(CONFLICT) 로 삭제가 거부됩니다. 카드가 없으면 404입니다.
13.5 구독 API
💬이 절은 API 명세입니다 — 카드 등록부터 구독 생성·관리·요금제 변경까지 따라 하기 코드는 20.4 구독 적용 가이드를 보세요.
중요: 구독 생성 전에 반드시
POST /api/v1/cards로 카드를 먼저 등록해야 합니다(체험trial=true는 예외 — 카드 없이 시작 가능, 만료 시 무카드면 즉시 만료). 구독 요청에는 카드/빌링키 정보를 넣지 않으며, 서버가 등록된 카드에서 빌링키를 조회합니다(app/services/subscriptions.py:296).
| 메서드·경로 | 인증 | 용도 | 라우트 |
|---|---|---|---|
POST /api/v1/subscriptions |
HMAC + 결제 제한 | 구독 생성(trial 가능) | app/api/v1/subscriptions.py:74 |
GET /api/v1/subscriptions/id/{subscription_id} |
HMAC | 구독 ID(UUID)로 단건 조회(없으면 404) | app/api/v1/subscriptions.py:173 |
GET /api/v1/subscriptions/{external_user_id} |
HMAC | 최근 구독 조회(없으면 404) | app/api/v1/subscriptions.py:198 |
POST /api/v1/subscriptions/{external_user_id}/cancel |
HMAC | 취소 예약(만료일에 자동 종료) | app/api/v1/subscriptions.py:224 |
POST /api/v1/subscriptions/{external_user_id}/resume |
HMAC | 취소 예약 철회(재개) | app/api/v1/subscriptions.py:248 |
POST /api/v1/subscriptions/{external_user_id}/pay |
HMAC + 결제 제한 | 정지(SUSPENDED)·미수(PAST_DUE) 구독 수동 결제 복구 | app/api/v1/subscriptions.py:112 |
POST /api/v1/subscriptions/{external_user_id}/add-days |
HMAC | 사용일 추가(만료일·다음 결제일 연장) | app/api/v1/subscriptions.py:146 |
POST /api/v1/subscriptions/{external_user_id}/change-plan |
HMAC + 결제 제한 | 요금제 변경(업그레이드 즉시 / 다운그레이드 예약) | app/api/v1/subscriptions.py:276 |
POST /api/v1/subscriptions/{external_user_id}/change-plan/cancel |
HMAC | 요금제 변경 예약 취소 | app/api/v1/subscriptions.py:310 |
13.5.1 구독 생성 — POST /api/v1/subscriptions
요청 본문 (SubscriptionCreateRequest, app/schemas/api.py:26)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
external_user_id(이메일) |
string | 이메일, 1–255자 | 외부 서비스 측 사용자 식별자(이메일·소문자 정규화, 서비스+사용자 당 구독 1개 기준) |
plan_id |
UUID | - | 구독할 요금제 ID(요금제 목록 응답의 id) |
trial |
bool | 기본 false | true이면 체험 시작. 요금제 trial_enabled=true일 때만 허용(아니면 422) |
{
"external_user_id": "user@example.com",
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trial": false
}
💡참고: 금액 필드가 없습니다. 서버가 요금제에서 계산하므로 클라이언트가 금액을 조작할 수 없습니다(
app/schemas/api.py:51).
응답 (201) (SubscriptionResponse, app/schemas/api.py:106)
| 필드 | 타입 | 설명 |
|---|---|---|
id |
UUID | 구독 ID |
external_user_id(이메일) |
string | 외부 서비스 측 사용자 식별자 |
plan_id |
UUID | 구독한 요금제 ID |
plan_name |
string | 구독한 요금제 이름 |
status |
string | TRIAL | ACTIVE | EXTENDED | PAST_DUE | SUSPENDED | CANCELED | EXPIRED (EXTENDED = 관리자가 만료일을 연장한 상태) |
access_allowed |
bool | 서비스 접근 허용 여부. TRIAL/ACTIVE/EXTENDED/PAST_DUE=true, SUSPENDED/EXPIRED=false. CANCELED는 만료일 전까지만 true — 만료일이 이미 지난 해지(결제실패·정지 상태에서의 해지)는 즉시 false |
current_period_start |
datetime | 현재 결제 주기 시작 시각 |
current_period_end |
datetime | 현재 결제 주기 종료(만료) 시각 |
next_billing_at |
datetime | null | 다음 자동결제 예정 시각. 해지 예약·만료 시 null |
card |
object | null | 등록 카드 마스킹 정보. 미등록 시 null |
retry_count |
int | PAST_DUE 상태에서의 결제 재시도 횟수 |
bonus_days |
int | 현재 주기에 무료로 더해진 사용일 누계(어드민 사용일추가·add-days). 업그레이드 일할계산 환불에서 이 일수는 유료 기간에서 제외됩니다. 갱신·업그레이드로 주기가 리셋되면 0 |
first_payment_failed_at |
datetime | null | 현재 주기 정기결제가 처음 실패한 시각. PAST_DUE/SUSPENDED에서만 채워지고 그 외 null |
suspended_at |
datetime | null | 결제 재시도 소진으로 정지(SUSPENDED)된 시각. 정지 상태가 아니면 null |
expire_due_at |
datetime | null | 정지 유예 기간이 끝나 이용이 완전 종료(EXPIRED)될 예정 시각(정지 시각 + 정지 유예 일수). 정지 상태가 아니면 null |
pending_plan_id |
UUID | null | 예약된 요금제 변경(다운그레이드) 대상 요금제 ID. 예약 없으면 null |
pending_plan_name |
string | null | 예약된 요금제 이름. 예약 없으면 null |
{
"id": "aabbccdd-1111-2222-3333-444455556666",
"external_user_id": "user@example.com",
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"plan_name": "스탠다드 월간",
"status": "ACTIVE",
"access_allowed": true,
"current_period_start": "2026-06-10T03:00:00Z",
"current_period_end": "2026-07-10T03:00:00Z",
"next_billing_at": "2026-07-10T03:00:00Z",
"card": {"issuerCode": "61", "number": "123456******1234"},
"retry_count": 0,
"bonus_days": 0,
"first_payment_failed_at": null,
"suspended_at": null,
"expire_due_at": null,
"pending_plan_id": null,
"pending_plan_name": null
}
⚠️중요: 외부 서비스는
access_allowed값으로 사용자의 서비스 접근을 판단하세요(app/schemas/api.py:121). 상태 문자열을 직접 해석하기보다 이 불리언 한 개를 쓰는 것이 안전합니다.
13.5.2 조회 / 취소 / 재개
- 조회(사용자):
GET /api/v1/subscriptions/{external_user_id}— 가장 최근 구독을SubscriptionResponse로 반환합니다. 구독이 없으면 404. - 조회(구독 ID):
GET /api/v1/subscriptions/id/{subscription_id}— 구독 생성 응답의id(UUID)로 특정 구독을 단건 조회합니다. 요청 서비스 소유가 아니거나 없는 ID면 동일하게 404(서비스 간 격리). 응답은 같은SubscriptionResponse. - 취소 예약: 즉시 삭제가 아니라 만료일이 되면 자동 종료됩니다. 취소 예약 후에도 만료 전까지는
access_allowed=true(CANCELED). - 결제실패(PAST_DUE)·정지(SUSPENDED)에서의 취소: 두 상태는 결제한 기간이 이미 끝난 상태이므로, 취소하면 응답부터 곧바로
access_allowed=false가 되고 수 분 내(배치 주기) EXPIRED로 완전 종료됩니다 — "돈 안 낸 기간 이용"이 발생하지 않습니다. (정지 상태의 취소는 과거에는 404로 거부되었으나 이제 허용됩니다.) - 연장처리(EXTENDED)에서의 취소: 운영자가 만료일을 연장한 구독도 취소 예약할 수 있습니다. 연장된 만료일까지
access_allowed=true가 유지되고next_billing_at만null이 되어 다음 자동결제가 해제됩니다. 만료 전 재개하면next_billing_at이 연장된 만료일로 복원됩니다(상태는 ACTIVE). (연장처리 상태의 취소는 과거에는 404로 거부되었으나 이제 허용됩니다.) - 재개: 취소 예약을 철회해 원래 상태로 복귀합니다. 셋 다 요청 본문이 없고 응답은
SubscriptionResponse입니다.
13.5.3 수동 결제(정지 구독 복구) — POST /.../pay
정지(SUSPENDED)·미수(PAST_DUE) 구독의 미수금을 즉시 결제합니다. 성공 시 ACTIVE로 복귀하고 기준일이 리셋됩니다. 토스 청구가 발생하므로 결제 전용 처리율 제한이 적용됩니다. 요청 본문 없음, 응답은 갱신된 SubscriptionResponse.
💬동시 청구 직렬화(보안 A-1): 자동 갱신 배치와 같은 주문별 락으로 상호배제됩니다. 갱신 배치나 다른 수동 결제가 이 구독을 처리 중이면 409(
이 구독의 결제가 처리 중입니다)로 거절합니다 — 배치와 동시에 청구돼 같은 기간이 이중과금되는 것을 막습니다. 잠시 후 재시도하세요. 결과 불명(503): 토스 응답 타임아웃·5xx·통신 단절은 실패가 아니라 결과 불명으로 처리되어 결제가 PENDING으로 남고 정산 스윕이 확정합니다(PAYMENT_UNRESOLVED). 확정 실패로 오인해 재청구하면 이중과금 위험이 있어, 서버가 같은 주문으로 재조회·재수렴합니다(보안 R-1).
13.5.4 사용일 추가 — POST /.../add-days
이용 중(ACTIVE·EXTENDED·PAST_DUE) 구독의 만료일·다음 결제일을 days만큼 미룹니다(상태는 변경하지 않음). 토스 결제 호출이 없으므로 일반 HMAC 인증으로 충분합니다.
요청 본문 (UsageDaysRequest, app/schemas/api.py:245)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
days |
int | 1 이상(상한은 운영 설정 — 기본 3650) | 추가할 사용일수 |
{ "days": 30 }
⚠️주의: 대상 상태(ACTIVE·EXTENDED·PAST_DUE)가 아니면 409(CONFLICT), 구독이 없으면 404를 반환합니다. 또한 그 구독에 결과 확인 중인 결제(갱신 결제 타임아웃 등)가 남아 있으면 이중청구 방지를 위해 일시적으로 409("처리 중인 결제가 있어 지금 사용일을 추가할 수 없습니다")를 반환합니다 — 정산이 자동 확정하는 최대 10분 내에 재시도하면 됩니다.
재시도 안전 — Idempotency-Key 헤더(선택)
이 요청은 "현재 만료일을 읽어 days를 더해 되쓰는" 방식이라 같은 요청이 두 번 도착하면 두 번 더해집니다. 타임아웃 후 HTTP 클라이언트가 자동 재시도하거나 이벤트 배치를 다시 돌리면 그대로 중복 적용됩니다. HMAC의 nonce는 동일 패킷의 재전송만 막고, 새로 서명한 재시도는 정상 요청으로 통과합니다.
재시도가 있을 수 있는 호출에는 요청마다 고유한 Idempotency-Key 헤더를 붙이세요(UUID 권장). 서명 대상(메서드·경로·타임스탬프·nonce·본문)에 헤더는 포함되지 않으므로 기존 서명 코드는 그대로 쓰면 됩니다.
| 항목 | 규칙 |
|---|---|
| 형식 | 1~128자, 공백 없는 인쇄 가능 ASCII. 위반 시 422 |
| 범위 | 서비스 키별로 격리 — 다른 서비스와 같은 문자열을 써도 충돌 없음 |
| 유효기간 | 24시간. 이후 같은 키는 새 요청으로 처리 |
| 같은 키 + 같은 본문 재요청 | 다시 적용하지 않고 최초 응답을 그대로 반환(응답 헤더 Idempotent-Replayed: true) |
같은 키 + 다른 본문(사용자·days가 다름) |
422 VALIDATION_ERROR — 새 키를 사용 |
| 같은 키가 아직 처리 중 | 409 CONFLICT — 잠시 후 같은 키로 재시도 |
| 최초 요청이 실패(404·409·422) | 저장하지 않음 — 원인 해소 후 같은 키로 재시도 가능 |
POST /api/v1/subscriptions/user@example.com/add-days
X-Service-Key: ...
X-Timestamp: ...
X-Nonce: ...
X-Signature: ...
Idempotency-Key: 3b6f1c2e-0d4a-4f7e-9a51-2c8e7d1f0a9b
Content-Type: application/json
{ "days": 7 }
💬헤더를 생략하면 기존과 동일하게 동작합니다(재시도 보호 없음). 어드민의 요금제 "사용일추가" 버튼은 별도 경로이며 이 헤더와 무관합니다.
추가한 일수는 응답의
bonus_days에 누적됩니다. 이 무료 일수는 이후 업그레이드 일할계산 환불에서 유료 기간에서 제외되며(13.5.5 참고), 갱신 결제나 업그레이드로 주기가 리셋되면 0이 됩니다. 업그레이드는 기간을 지금부터 새 주기로 리셋하므로 남은 무료 일수는 이월되지 않습니다.
13.5.5 요금제 변경 — POST /.../change-plan
구독 중 다른 요금제로 변경합니다(요청 r02). 이용 중(ACTIVE) 구독만 가능하며, 서버가 두 요금제의 상시할인 적용가를 비교해 방향을 판정합니다 — 서비스가 업그레이드/다운그레이드를 지정하지 않습니다.
| 판정 | 조건 | 처리 |
|---|---|---|
| 업그레이드 | 새 요금제 금액 ≥ 현재 금액 | 즉시 전환 — 새 요금제 결제 → 기존 결제 환불 → 기간을 지금부터 새 주기로 리셋. 첫구독 혜택은 적용되지 않음 |
| 다운그레이드 | 새 요금제 금액 < 현재 금액 | 예약 전환 — pending_plan_id에 저장만 하고 결제·기간 변경 없음. 다음 결제일에 갱신 배치가 새 요금제로 청구·전환 |
요청 본문 (PlanChangeRequest, app/schemas/api.py:159)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
plan_id |
UUID | - | 변경할 요금제 ID(요금제 목록 응답의 id). 현재와 같은 요금제면 409 |
refund_amount |
int | null | 0 이상, 생략 가능 | 업그레이드 시 환불할 금액(원). 생략하면 서버가 미사용 기간을 일할계산(초 비례, 원 단위 내림). 이 주기에 무료로 더해진 사용일(bonus_days)은 유료 기간에서 제외해 계산합니다 — 예: 30일 요금제에 무료 30일을 받은 사용자가 15일째 업그레이드하면 유료 30일 중 15일 남은 것으로 보아 50% 환불(무료 일수까지 결제액 기준으로 환불하지 않음). 잔여 환불가능액 초과 시 422. 다운그레이드에서는 무시 |
{
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"refund_amount": 5000
}
응답 (200) (PlanChangeResponse, app/schemas/api.py:179) — SubscriptionResponse 전체 필드에 변경 처리 상세 4개가 추가됩니다.
| 필드 | 타입 | 설명 |
|---|---|---|
change_type |
string | UPGRADE(즉시 전환) | DOWNGRADE(다음 결제일 예약) |
charged_amount |
int | 이번에 결제한 금액(업그레이드). 다운그레이드는 0 |
refunded_amount |
int | 실제 환불된 금액. 환불 실패/없음이면 0 |
refund_status |
string | DONE(완료) | FAILED(실패 — 관리자 수동 환불 예정) | NONE(환불 대상 없음/다운그레이드) |
동작 규칙
- 업그레이드는 결제 먼저, 환불 나중 순서입니다. 새 요금제 결제가 실패하면 구독은 아무것도 바뀌지 않고 402를 반환합니다. 결제 성공 후 환불만 실패하는 드문 경우에는 전환은 확정되고
refund_status=FAILED로 응답하며, 관리자가 수동 환불 대상으로 처리합니다. - 다운그레이드 예약이 있는 상태에서 다시 요청하면 예약을 덮어씁니다(더 높은 요금제를 선택하면 업그레이드 규칙으로 즉시 전환).
- 구독을 취소(cancel)해도 예약은 남습니다 — 만료되면 갱신이 없어 자연 무효, 재개(resume)하면 예약이 다시 유효해집니다.
- 자동갱신과 상호배제됩니다. 이 구독에 갱신 배치나 수동결제가 진행 중이면 요금제 변경은 409("이 구독의 결제가 처리 중입니다")로 거절됩니다. 업그레이드 결제와 갱신 결제가 같은 구독에 동시에 청구되는 것을 막기 위한 잠금이며, 진행 중인 결제가 끝나면(보통 수 초) 재요청으로 정상 처리됩니다. 409를 받으면 잠시 후 재시도하도록 구현하세요.
- 에러: 요금제 없음/비활성·구독 없음·(업그레이드 시) 카드 미등록 404, ACTIVE 아님·동일 요금제·비활성 카드·처리 중 결제 존재·다른 결제 처리 중(잠금) 409,
refund_amount초과 422, 결제 실패 402.
13.5.6 요금제 변경 예약 취소 — POST /.../change-plan/cancel
예약된 요금제 변경(다운그레이드 예약)을 취소해 pending_plan_id를 지웁니다. 요청 본문 없음, 응답은 SubscriptionResponse. 예약이 없으면 404. 결제 호출이 없으므로 일반 HMAC 인증만 적용됩니다.
13.6 결제 API (단건/1회성 — 결제창)
💬이 절은 API 명세입니다 — "무엇을 어떤 순서로 만들면 되는지" 따라 하기 코드는 20.3 단건결제 적용 가이드를 보세요.
구독과 무관한 1회성 결제입니다. 결제창 전환(2026-07-15): 단건결제는 빌링키를 쓰지 않고 토스 결제창(카드/간편결제 통합창)으로 진행합니다 — 카드 사전 등록이 필요 없습니다(카드 보관함은 구독 전용). 흐름은 3단계입니다.
① 준비 서비스 서버 → POST /api/v1/payments/prepare (주문 선점·PENDING, toss_order_id 반환)
② 결제창 서비스 클라이언트 → SDK payment.requestPayment({ method:"CARD",
orderId: toss_order_id, amount, orderName, successUrl, failUrl })
· Redirect 방식 고정(모바일 iframe 금지) — 인증 성공 시 successUrl 쿼리로
paymentKey·orderId·amount 수신(amount가 보낸 값과 같은지 1차 대조)
③ 승인 서비스 서버 → POST /api/v1/payments/confirm (서버가 저장 금액으로 토스 승인)
② 결제창 — SDK payment.requestPayment() 파라미터 (SDK v2: <script src="https://js.tosspayments.com/v2/standard">)
| 파라미터 | 값 | 설명 |
|---|---|---|
method |
"CARD" |
카드/간편결제 통합창 — 카드사·카카오페이 등 간편결제가 한 창에 나온다 |
amount |
{ currency: "KRW", value: <금액> } |
prepare 때 보낸 금액과 동일해야 한다(다르면 승인 시 거절) |
orderId |
prepare 응답의 toss_order_id |
내 서비스 order_id가 아니다. 토스 콘솔·매출전표의 주문번호와 동일한 값 |
orderName |
주문명 | 결제창 우측에 표시 |
successUrl / failUrl |
내 서비스 URL | 인증 결과 리다이렉트 목적지(아래 쿼리 표 참고) |
customerKey |
사용자 식별자 또는 TossPayments.ANONYMOUS |
회원 결제면 서비스의 사용자 식별자, 비회원이면 ANONYMOUS |
customerEmail |
(선택) 구매자 이메일 | 토스가 결제 상태 변경 시 이 주소로 결제내역 메일을 보낸다(최대 100자) |
customerName |
(선택) 구매자명 | 토스 상점관리자 화면·결제내역 메일에 표시(최대 100자) |
customerMobilePhone |
(선택) 구매자 휴대폰 번호 | 가상계좌 안내·퀵계좌이체 번호 자동완성용(숫자만 8~15자). method:"CARD"에서는 쓰이지 않는다 |
💬
customerEmail/customerName/customerMobilePhone은 토스 쪽에서만 사용됩니다 — successUrl 쿼리·confirm 응답으로 돌아오지 않으며, 결제서버 DB에도 저장되지 않습니다(결제서버가 저장하는 사용자 식별자는external_user_id=이메일뿐). 참고로 빌링 카드 등록창(requestBillingAuth)은customerEmail/customerName만 받고customerMobilePhone파라미터가 없습니다.모바일에서는 iframe/frame 위에서 결제창을 호출하면 안 됩니다(특정 간편결제 미동작 + 페이지 이동 필수 — 토스 공식 가이드). Redirect 방식을 그대로 쓰세요.
리다이렉트 쿼리 파라미터
| 목적지 | 쿼리 | 설명 |
|---|---|---|
successUrl |
paymentKey · orderId · amount |
orderId=toss_order_id. amount는 대조용으로만 사용(신뢰 금지) — 그대로 confirm의 대조 amount로 넘기면 서버가 검증 |
failUrl |
code · message (± orderId) |
사용자가 창을 닫으면 PAY_PROCESS_CANCELED(이때 orderId 쿼리 없음), 인증 실패는 PAY_PROCESS_ABORTED |
💬실제 결제창 화면(수단 선택 → 카드 상세·약관 → 승인 완료)은 19장 ⑥ 일반결제의 캡처 (a)(b)(c)를 참고하세요.
| 메서드·경로 | 인증 | 용도 | 라우트 |
|---|---|---|---|
POST /api/v1/payments/prepare |
HMAC + 결제 제한 | 단건 결제 준비(PENDING 선점) | app/api/v1/payments.py |
POST /api/v1/payments/confirm |
HMAC + 결제 제한 | 결제창 인증 건 승인(확정) | app/api/v1/payments.py |
POST /api/v1/payments/{order_id}/cancel |
HMAC + 결제 제한 | 단건 결제 취소(환불, 수수료 공제) | app/api/v1/payments.py |
GET /api/v1/payments/information |
HMAC | 결제 정보 조회(단건결제 1회 최대금액 등) | app/api/v1/payments.py |
GET /api/v1/payments/{external_user_id} |
HMAC | 결제 내역 조회(최신순 최대 50건) | app/api/v1/payments.py |
13.6.0 결제 정보 조회 — GET /api/v1/payments/information
결제 화면 구성 전에 알아야 할 서버 측 설정/한도를 반환합니다. 이후 결제 관련 정보가 생기면 이 응답에 필드가 추가됩니다(기존 필드는 하위 호환 유지).
| 필드 | 타입 | 설명 |
|---|---|---|
one_off_max_amount |
int | 단건결제 1회 최대 금액(원). 이 금액을 초과하는 결제 준비(prepare) 요청은 422로 거부됩니다. 결제 화면에서 상한 사전 안내에 사용하세요 |
{
"one_off_max_amount": 100000000
}
💬값은 결제 준비 검증이 실제 사용하는 서버 설정과 동일한 소스에서 반환되므로, 안내와 검증이 어긋나지 않습니다.
13.6.1 단건 결제 준비 — POST /api/v1/payments/prepare
요청 본문 (OneOffPrepareRequest, app/schemas/api.py)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
external_user_id(이메일) |
string | 1–255자 | 결제 대상 사용자 식별자 |
order_id |
string | 6–64자 | 주문 ID. 서비스 내 고유(타 서비스와는 중복 가능). 같은 order_id 재요청 시 동작은 기존 주문의 상태에 따라 갈립니다(아래 표 참고) |
같은 order_id로 다시 요청했을 때
| 기존 주문 상태 | 동작 |
|---|---|
| 대기(PENDING) · 완료(DONE) | 기존 주문을 그대로 반환합니다(멱등 — 같은 요청을 여러 번 보내도 한 번만 처리). |
| 실패(FAILED) | 같은 주문번호로 재결제할 수 있습니다. 주문이 다시 대기 상태가 되고 toss_order_id는 새 값으로 발급됩니다 — 결제창에는 반드시 응답으로 받은 새 toss_order_id를 쓰세요. 직전 실패 이력은 감사 로그에 보존됩니다. |
| 취소(CANCELED) | 409로 거절합니다. 환불 이력이 있는 주문번호를 재사용하면 같은 번호가 두 사건을 가리키게 되어 정산이 어긋납니다 — 새 주문번호로 요청하세요. |
💬💡 카드 한도초과 등으로 결제가 실패한 뒤 다른 카드로 재시도하는 것은 가장 흔한 흐름입니다. 자사 주문번호를 그대로 쓰는 서비스도 이 경우 주문번호를 바꿀 필요가 없습니다. 단, 재요청 응답의
toss_order_id를 반드시 새로 읽어 결제창에 넘겨야 합니다(이전 값은 토스에서 이미 소진된 상태입니다). |order_name| string | 1–100자 | 결제창에 표시되는 주문명 | |amount| int | 0 초과, 상한 이하 | 결제 금액(원). 승인은 항상 이 값(서버 저장 금액)으로 수행 — 결제창·successUrl에서 금액이 위변조되면 토스/서버가 거절 |
{
"external_user_id": "user@example.com",
"order_id": "order-20260610-0001",
"order_name": "프리미엄 1회 이용권",
"amount": 10000
}
응답은 PaymentResponse(201, status=PENDING) — toss_order_id를 결제창 orderId로 사용하세요.
⚠️주의: 결제창을 열고 결제를 완료하지 않은 주문은 서버 정산 스윕이 35분 후
FAILED(code=EXPIRED)로 정리합니다(토스 결제창 유효시간 30분 + 여유). 같은 order_id로 다시 prepare하면 기존(EXPIRED 전 PENDING) 주문이 반환되므로, 새 결제는 새 order_id로 요청하세요.
13.6.1-2 단건 결제 승인 — POST /api/v1/payments/confirm
요청 본문 (OneOffConfirmRequest, app/schemas/api.py)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
toss_order_id |
string | 6–64자 | prepare가 반환한 토스 주문번호(successUrl 쿼리의 orderId) |
payment_key |
string | 1–200자 | successUrl 쿼리로 받은 paymentKey |
amount |
int | null | 선택 | successUrl 쿼리의 amount(대조값). 서버 저장 금액과 다르면 422 |
{
"toss_order_id": "t3f4a9c1b2e8d7f6a5c4b3a29182736450",
"payment_key": "tviva20260715…",
"amount": 10000
}
응답은 PaymentResponse(status=DONE, 매출전표 receipt_url 포함).
동작 규칙
- 승인 금액은 항상 prepare 때 저장한 서버 금액입니다 — confirm의
amount는 대조용일 뿐 승인에 쓰이지 않습니다. - 멱등: 이미 승인된 주문에 같은
payment_key로 재요청하면 기존 결제를 반환합니다(중복 승인 없음). - 동시 승인 직렬화: 같은 주문의 confirm이 이미 진행 중이면(successUrl 중복 리다이렉트·더블클릭·재시도) 서버가 주문별 락으로 토스를 재차 부르지 않고 409(
결제 승인이 이미 진행 중입니다)로 즉시 거절합니다. 선행 요청이 결제를 확정하므로, 잠시 후 재시도하면 멱등 DONE으로 수렴합니다(성공 결제가 실패로 뒤집히지 않음). - 타임아웃(결과 불명): PENDING 유지 + 503(
PAYMENT_UNRESOLVED) — 서버 정산 스윕이 토스 재조회로 자동 확정하므로 재시도 전 결제 내역을 먼저 확인하세요. - 승인은 인증 직후 즉시: 토스는 결제창 인증 완료 후 10분 이내에 승인하지 않으면 결제 데이터를 유실합니다(
NOT_FOUND_PAYMENT_SESSION). successUrl 핸들러에서 confirm을 바로 호출하고, 승인을 큐 등으로 미루지 마세요. - 에러: 주문 없음 404, 이미 실패/취소된 주문·다른 paymentKey로 완료된 주문·승인 진행 중 409, 승인 거절(금액 불일치 등) 4xx(토스 에러 코드 포함 — 인증 세션 만료
NOT_FOUND_PAYMENT_SESSION, 카드사 거절REJECT_CARD_COMPANY등). - failUrl 에러: 사용자가 결제창을 닫으면
PAY_PROCESS_CANCELED(이때는 orderId 쿼리가 오지 않음), 결제 실패는PAY_PROCESS_ABORTED.
QA 에러 재현 헤더 — TossPayments-Test-Code (요청 qa/r01) — 상세는 21. 에러 테스트
💬결제 단계가 아닌 모든 API의 강제 에러 재현(서비스별 에러 테스트 모드,
x-test-error-code헤더)은 21.8을 참고하세요 — 어드민에서 서비스별 토글로 켜며, 응답 error에test_mode: true가 표시됩니다.
STG에서 결제 실패·거절 케이스를 재현하려면, confirm 요청에 재현할 토스 에러코드를 헤더로 실으면 서버가 토스 승인 호출로 그대로 중계합니다.
POST /api/v1/payments/confirm
TossPayments-Test-Code: REJECT_CARD_COMPANY
- 비운영 전용:
environment != "prod"+TEST_ERROR_INJECTION_ENABLED=true(STG opt-in)일 때만 동작. 운영에선 헤더를 무시합니다. - test 키 전용: 해당 서비스가 토스 test 키일 때만 적용(라이브 키는 토스가 헤더를 무시). 헤더 형식은 대문자·숫자·언더스코어만 통과하며, 미지원 코드는 토스가
INVALID_TEST_CODE(400)로 응답합니다. - 응답 주의: 카드 거절 계열(4xx, 예
REJECT_CARD_COMPANY)은 확정 실패(402/4xx) 로,FAILED_CARD_COMPANY_RESPONSE(500)는 결과 불명 처리라 503(PAYMENT_UNRESOLVED) + 결제 PENDING 유지로 옵니다. "카드 거절 화면" 검증엔 4xx 코드를 쓰세요. - HMAC 서명은 임의 헤더를 서명 대상에 포함하지 않으므로, 이 헤더 추가가 서비스 인증을 깨지 않습니다.
- 적용 범위(confirm + charge): 같은 헤더가 결제창 승인(
POST /payments/confirm)뿐 아니라 구독 첫 결제(POST /subscriptions)와 구독 수동결제(POST /subscriptions/{uid}/pay) — 즉 저장 카드 청구(charge) 경로에도 동일하게 적용됩니다. 구독 결제는 결제창이 없으므로 헤더를 실어 호출하면 응답으로 실패가 바로 옵니다. 게이트(비운영·플래그·test 키)는app/api/deps.py의resolve_test_error_code로 세 라우트가 공유합니다.
💡참고: 결제수단으로 간편결제를 선택하면 토스 승인 응답의 결제수단 필드가 카드와 다릅니다 — 응답
raw는 노출되지 않으므로 서비스 화면 표시는PaymentResponse공통 필드를 사용하세요.
13.6.2 단건 결제 취소 — POST /api/v1/payments/{order_id}/cancel
서비스 취소 정책에 따라 환불(수수료 공제)합니다. 전액·부분 취소를 모두 지원하며, 부분취소는 잔여가 남는 한 반복할 수 있습니다.
요청 본문 (OneOffCancelRequest)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
reason |
string | 1–200자, 기본 "사용자 취소" | 취소 사유. 토스 취소 API의 cancelReason으로 전달 |
cancel_amount |
int | null | ≥1, 생략 가능 | 이번에 취소할 원금(원). 생략(null)하면 잔여 전액 취소. 취소가능금액(응답 cancel_remaining)을 넘으면 422 |
{ "reason": "고객 요청으로 취소", "cancel_amount": 3000 }
수수료는 취소 원금에 비례해 공제됩니다: fee = cancel_amount × 수수료% (내림), 환불 = cancel_amount − fee. 잔여 취소가능 원금(cancel_remaining = 결제금액 − 누적 환불 − 누적 수수료)이 0이 되면 status=CANCELED로 종료되고, 남으면 DONE을 유지한 채 추가 부분취소가 가능합니다.
⚠️주의: 서비스 정책이 취소 비허용(
cancellation_enabled=false)이거나 결제가 완료(DONE) 상태가 아니면 오류를 반환합니다. 관리자가 어드민에서 취소를 실행한 이력이 있는 결제는 외부 취소가 409로 거절됩니다(고객센터 안내). 취소 성공 시canceled_amount(누적 환불)/cancel_fee(누적 수수료)/cancel_remaining이 갱신된 결과를 반환합니다.관리자 개입 결제는 미리 판별하세요. 한 번이라도 관리자가 취소하면 그 결제는 잔여 금액이 남아 있어도 서비스 취소가 영구 차단됩니다. 고객 응대·수동 조정이 진행 중인 건에 자동 취소가 겹쳐 이중환불·정산 혼선이 나는 것을 막기 위한 규칙입니다.
이 상태는 결제 조회 응답에 그대로 드러납니다 —
cancelable=false,cancel_blocked_reason="ADMIN_HANDLED"이며cancel_remaining은 남은 금액을 그대로 보여줍니다. 취소 버튼은cancelable값으로만 활성화하고,cancel_blocked_reason이 있으면 그 사유를 안내 문구로 띄우세요. 잔여 금액만 보고 버튼을 열어 두면 사용자가 눌렀을 때 409를 받게 됩니다.예) 사용자가 3,000원 부분취소 → 관리자가 2,000원 부분취소 → 사용자가 다시 취소 시도
json { "cancelable": false, "cancel_blocked_reason": "ADMIN_HANDLED", "cancel_remaining": 5000 }남은 5,000원은 관리자(어드민)만 취소할 수 있습니다. 사용자에게는 "고객센터로 문의" 안내를 노출하세요. 동시 취소 직렬화(보안 A-2): 같은 결제에 취소가 이미 진행 중이면(사용자·관리자 동시 또는 연타) 결제별 락으로 409(결제 취소가 이미 진행 중입니다)로 거절합니다 — 각자 낡은 누적 환불액을 읽어 초과 환불(lost update)하는 것을 막습니다. 잠시 후 다시 시도하세요.
13.6.3 결제 내역 조회 — GET /api/v1/payments/{external_user_id}
해당 사용자의 결제 내역을 최신순 최대 50건 반환합니다. 구독 정기결제와 단건(ONE_OFF) 결제를 모두 포함합니다. Payment.service_id로 범위가 격리되어 다른 서비스의 결제는 보이지 않습니다.
쿼리 파라미터
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
include_cancellations |
bool | false |
true면 각 결제에 회차별 취소 내역(cancellations 배열)을 포함합니다. 기본값이면 cancellations는 null입니다. |
💡참고: HMAC 서명은 URL path만 서명 대상에 포함하므로, 쿼리 파라미터를 붙여도 서명 계산은 달라지지 않습니다.
{
"payments": [ /* PaymentResponse 객체 배열 */ ]
}
13.6.4 결제 응답(PaymentResponse)과 취소/환불 필드
PaymentResponse(app/schemas/api.py:283)는 결제 결과 + 서비스 취소 정책에서 만들어집니다. toss_payment_key·raw_response 등 내부 필드는 노출되지 않습니다.
| 필드 | 타입 | 설명 |
|---|---|---|
order_id |
string | 주문 ID |
toss_order_id |
string | 토스에 전달된 주문번호 — 토스 상점 콘솔의 주문번호와 동일. 구독 결제는 order_id와 같고, 단건 결제는 서버 생성값이라 다름 |
order_name |
string | null | 상품명 — 단건은 prepare 때 보낸 order_name, 구독은 요금제명. 실패·과거 건도 서버가 보관하므로 결제 내역 표시에 그대로 쓰면 된다(별도 로컬 저장 불필요) |
amount |
int | 실제 청구된 금액(원) |
status |
string | PENDING | DONE | FAILED | CANCELED |
kind |
string | SUBSCRIPTION(구독 정기) | ONE_OFF(단건) |
payment_type |
string | FIRST | RENEWAL | RETRY | ONE_OFF |
failure_code |
string | null | 실패 코드. status=FAILED일 때만 값 존재 |
failure_message |
string | null | 실패 사유 메시지. status=FAILED일 때만 값 존재 |
requested_at |
datetime | 결제 요청 시각 |
approved_at |
datetime | null | 승인 시각. 실패·대기 중에는 null |
receipt_url |
string | null | 토스 매출전표(영수증) URL. 카드결제(DONE)는 보통 존재, 가상계좌·실패·대기·과거 미보유 건은 null. 새 탭으로 열어 영수증을 보여줄 수 있음 |
cancelable |
bool | 지금 취소 가능 여부. 단건(ONE_OFF)·완료(DONE)·서비스 취소허용·cancel_remaining>0이고 관리자가 취소 처리한 적이 없을 때 true — 부분취소 후 잔여가 있으면 계속 true |
cancel_blocked_reason |
string | null | 잔여가 남아 있는데도 취소할 수 없는 이유. ADMIN_HANDLED=관리자가 취소 처리한 결제(고객센터 문의 필요) / CANCEL_DISABLED=서비스 정책상 취소 불가. 취소 가능하거나 잔여가 0이면 null |
cancel_remaining |
int | 취소 가능 원금(원) = 결제금액 − (누적 환불 + 누적 수수료). 부분취소 요청(cancel_amount)의 상한 |
cancel_fee_percent |
int | 서비스 취소 수수료율(%) |
cancel_fee |
int | 취소 시 차감 수수료(원). 취소 가능 결제는 예상액, 이미 취소된 결제는 실제 차감액 |
cancel_refund_amount |
int | 환불액(원). 취소 가능 결제는 예상액, (부분/전액) 취소된 결제는 실제 누적 환불액 |
canceled_amount |
int | 실제 환불된 누적 금액(원). 어드민 부분취소 시 status는 DONE이지만 이 값이 0보다 큼 |
net_amount |
int | 실수령(순) 금액(원) = amount − canceled_amount. 부분취소 반영 |
cancellations |
array | null | 회차별 취소 내역(시간순). include_cancellations=true로 조회할 때만 포함(취소 없는 결제는 []), 그 외에는 null |
cancellations[] 항목(PaymentCancellationDetail)
| 필드 | 타입 | 설명 |
|---|---|---|
canceled_at |
datetime | 취소 발생 시각(토스 canceledAt, 없으면 서버 확정 시각) |
cancel_amount |
int | 이번 회차 환불액(원) |
cancel_fee |
int | null | 이번 회차 차감 수수료(원). 관리자 무수수료 취소 등 수수료 없는 취소는 null |
reason |
string | 취소 사유. 원장 도입 이전의 취소는 "backfill"(누적 합산 1건)로 옵니다 |
actor_type |
string | 처리 주체: USER(관리자) | SERVICE(외부 서비스 요청) | SYSTEM(웹훅 동기화·백필) |
💡참고: 취소 수수료는 실제 취소 처리와 동일한 계산을 공유합니다 →
cancel_fee = amount × cancel_fee_percent ÷ 100(내림),cancel_refund_amount = amount − cancel_fee. 따라서 결제 내역만으로 "지금 취소하면 얼마가 빠지고 얼마가 환불되는지"를 화면에 미리 안내할 수 있습니다.중요(부분취소 반영): 관리자가 어드민에서 단건 결제를 부분취소하면
status는DONE을 유지한 채canceled_amount만 누적됩니다. 외부 서비스는status == "CANCELED"만으로 취소를 판정하지 말고canceled_amount/net_amount로 실제 환불·실수령을 표시하세요. 이미 (부분)취소된 결제는cancelable=false라 외부에서 추가 취소할 수 없습니다.참고(매출전표의 주문번호 접두어):
receipt_url로 열리는 토스 매출전표 화면의 주문번호에는 토스가 상점 구분용 접두어(예:19e341_)를 붙여 표시합니다. 이 접두어는 토스가 전표 표시 시에만 붙이는 값으로, API 요청·응답의order_id/toss_order_id에는 존재하지 않습니다 — 전표의 주문번호는_뒤 부분이toss_order_id와 일치하므로 그 부분으로 대조하세요. 토스 상점 콘솔(관리자 화면)의 주문번호는 접두어 없이toss_order_id그대로 표시됩니다.
{
"order_id": "order-20260610-0001",
"toss_order_id": "t3f4a9c1b2e8d7f6a5c4b3a29182736450",
"order_name": "프리미엄 1회 이용권",
"amount": 10000,
"status": "DONE",
"kind": "ONE_OFF",
"payment_type": "ONE_OFF",
"failure_code": null,
"failure_message": null,
"requested_at": "2026-06-10T02:55:00Z",
"approved_at": "2026-06-10T02:55:01Z",
"receipt_url": "https://dashboard.tosspayments.com/receipt/...",
"cancelable": true,
"cancel_blocked_reason": null,
"cancel_remaining": 10000,
"cancel_fee_percent": 10,
"cancel_fee": 1000,
"cancel_refund_amount": 9000,
"canceled_amount": 0,
"net_amount": 10000,
"cancellations": null
}
include_cancellations=true로 조회한, 부분취소가 2회 있었던 결제의 예:
{
"order_id": "order-20260610-0002",
"amount": 10000,
"status": "DONE",
"canceled_amount": 5000,
"net_amount": 5000,
"cancellations": [
{"canceled_at": "2026-06-12T04:00:00Z", "cancel_amount": 3000,
"cancel_fee": 300, "reason": "고객 요청", "actor_type": "SERVICE"},
{"canceled_at": "2026-06-20T09:30:00Z", "cancel_amount": 2000,
"cancel_fee": null, "reason": "관리자 부분취소", "actor_type": "USER"}
]
}
13.7 서비스 알림 수신(아웃고잉 웹훅)
구독·결제·카드·요금제 상태가 바뀌면, 서버가 서비스 상세에 등록된 알림 URL로 JSON 알림을 POST합니다. (어드민 → 서비스 상세 → '서비스 알림 URL'에 등록, 비우면 끔)
- best-effort(fire-and-forget): 알림 전송은 백그라운드 단발 POST(타임아웃 5초)로 처리되며, 실패해도 재시도하지 않습니다(결제·구독 본 처리에는 영향 없음, 로그만 남김). 수신 측 전달 보장·멱등 처리 규약은 서비스 알림을 반드시 참고하세요.
- 헤더:
Content-Type: application/json+X-Event(이벤트 이름) +X-Signature/X-Timestamp/X-Nonce(서명 3종)으로 보냅니다. - 서명: 서비스의 HMAC 시크릿으로 서명합니다. API 호출 서명과 완전히 동일한 방식입니다(
X-Event는 서명 대상이 아니며 라우팅 편의용 힌트입니다).
13.7.1 payload 구조
{
"EVENT": "payment.one_off",
"subscribe_id": "", // 구독 ID(구독 이벤트만)
"order_id": "...", // 결제 주문번호(결제 이벤트만)
"PRE_STATUS": "", // 이전 상태(상태 변화 시)
"STATUS": "DONE", // 새 상태
"service_name": "...",
"email": "...", // 관련 사용자(external_user_id)
"date": "YYYY-MM-DD HH:MM:SS", // KST
"DESC": "금액 등 상세 설명"
}
💡참고: 없는 값은 빈 문자열입니다. 요금제 이벤트는 사용자 비귀속이라
subscribe_id/order_id/DESC에 요금제명·상세가 담깁니다.
13.7.2 이벤트 목록(EVENT)
| 상황 | EVENT |
|---|---|
| 새로운 구독자 발생 | subscription.created |
| 구독 상태 변화(취소·재개·미수·정지·만료·수동결제복구) | subscription.status_changed |
| 구독 자동결제 발생 | subscription.renewed |
| 요금제 변경(업그레이드 즉시 / 다운그레이드 예약 / 예약 취소) | subscription.plan_changed |
| 관리자 강제 구독취소 | subscription.force_canceled |
| 만료일 연장 | subscription.extended |
| 카드 등록 / 변경 / 삭제 | card.registered / card.replaced / card.deleted |
| 관리자 카드 활성화 / 비활성화 | card.activated / card.deactivated |
| 사용자 일반결제 | payment.one_off |
| 사용자 일반결제 취소 | payment.one_off_canceled |
| 관리자 일반결제 취소(전액/부분) | payment.one_off_admin_canceled |
| 요금제 활성화 / 비활성화 / 삭제 | plan.activated / plan.archived / plan.deleted |
| 요금제 사용일 추가 | plan.bonus_days |
| 테스트 알림(어드민 버튼) | notification.test |
상수 정의: app/notifications/service_notify.py.
13.7.3 서명 검증(수신 측)
받은 알림이 진짜 서버에서 온 것인지 아래 방식으로 검증합니다(API 호출 서명과 동일).
canonical = "POST\n{path}\n{X-Timestamp}\n{X-Nonce}\n{sha256_hex(body)}"
X-Signature == HMAC_SHA256(service_hmac_secret, canonical)
path는 알림 URL의 경로 부분(예:https://svc/hooks/notify→/hooks/notify)입니다.body는 받은 요청의 원문 바이트 그대로(파싱 전)를 SHA-256 해시합니다.
import hashlib, hmac
def verify_notification(secret, path, headers, body_bytes):
"""서버가 보낸 알림 서명을 검증한다 — 13.2.2 sign_request와 동일."""
body_hash = hashlib.sha256(body_bytes).hexdigest()
canonical = "\n".join([
"POST", path, headers["X-Timestamp"], headers["X-Nonce"], body_hash,
])
expected = hmac.new(secret.encode(), canonical.encode(),
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["X-Signature"])
💡참고: 수신 데모는
sample_service의POST /notify(서명 검증 후 저장)와/notifications(받은 알림 목록) 화면에 있습니다. 어드민의 '테스트 알림 전송' 버튼으로 연결을 즉시 확인할 수 있습니다.
13.7.4 토스 웹훅 수신 — POST /api/v1/webhooks/toss
💡참고: 이 엔드포인트는 토스페이먼츠 → 결제 서버 방향의 결제 이벤트 수신용입니다. 연동 서비스가 직접 호출하지 않습니다(참고용으로만 기재).
| 메서드·경로 | 인증 | 용도 | 라우트 |
|---|---|---|---|
POST /api/v1/webhooks/toss |
무인증(IP 검증 선택) | 토스 결제 이벤트 수신·처리 | app/api/v1/webhooks.py:31 |
- 진위 검증(보안 M-1): 토스는 결제/빌링 웹훅(
PAYMENT_STATUS_CHANGED·BILLING_DELETED)에 서명을 제공하지 않으므로(서명 헤더는 지급대행payout.changed·셀러seller.changed전용), 발신 IP 허용목록이 유일한 진위 장치입니다.webhook_ip_check_enabled=true이면toss_webhook_allowed_ips외 IP를 403으로 거부하며, prod/stg에서 이 값이 false면 서버 기동 자체가 거부됩니다(fail-closed — 오설정으로 인한 무인증 노출 차단). - 페이로드 불신:
PAYMENT_STATUS_CHANGED는 페이로드의 status를 믿지 않고orderId로 토스 API를 재조회해 확정하므로 "결제 성공/취소" 위조가 무의미합니다. - 위조 무해화:
BILLING_DELETED(서명 불가 이벤트)로 유발되는 담당자 알림 메일은 서비스별 시간창 내 발송 상한으로 제한되어, 유효 billingKey를 아는 공격자가 알림을 대량 유발하는 것을 막습니다. - 중복 방지:
tosspayments-webhook-transmission-id헤더로 동일 이벤트 재전송(at-least-once)을 DB 유니크 제약으로 1회 처리합니다. - 응답 (200) (
WebhookAck): 처리 상태만 반환합니다 —RECEIVED(수신) |PROCESSED(반영 완료) |IGNORED(대상 아님·중복) |FAILED(처리 실패).
{ "status": "PROCESSED" }
13.8 오류 응답 형식과 상태 코드
모든 API 오류는 아래 공통 형식으로 반환됩니다(ErrorResponse, app/schemas/api.py:493).
{
"error": {
"code": "UNAUTHORIZED",
"message": "인증에 실패했습니다"
}
}
code |
HTTP | 의미 |
|---|---|---|
UNAUTHORIZED |
401 | API 키 불일치, HMAC 서명 오류, 타임스탬프 초과, nonce 재사용 |
| (토스 오류코드) | 402 | 결제 승인/취소 실패 — code에 토스 오류코드가 그대로 전달됩니다(예: INSUFFICIENT_FUNDS, INVALID_CARD_EXPIRATION). 목록은 토스 에러코드 문서 참조 |
NO_BILLING_KEY |
402 | 등록된 카드가 없어 청구 불가 — 카드 등록 후 재시도 |
CARD_INACTIVE |
402 | 비활성화된 카드로 청구 시도 — 카드 활성화 또는 재등록 필요 |
CANCEL_DISABLED |
402 | 해당 서비스에 취소 기능이 비활성화됨 — 어드민 설정 확인 |
FORBIDDEN |
403 | IP 화이트리스트 미포함, 또는 키 스코프 불일치(모니터링 전용 키로 일반 API 호출 / 일반 키로 /api/v1/metrics/* 호출 — 13.10) |
NOT_FOUND |
404 | 구독·요금제·카드·서비스 등 리소스 없음 |
CONFLICT |
409 | 구독 중복 생성, 사용 중 카드 삭제, add-days 대상 상태 아님, 결제/취소가 이미 진행 중(동시 요청 직렬화) 등 |
VALIDATION_ERROR |
422 | 필드 검증 실패, 선언되지 않은 필드 포함(오타·주입 차단 — 요청 스키마는 extra='forbid'), 또는 비즈니스 규칙 위반(trial 불가 등) |
TOSS_KEY_NOT_CONFIGURED |
422 | 서비스에 토스 시크릿 키가 설정되지 않아 결제 호출 불가 — 어드민에서 키 등록 필요 |
RATE_LIMITED |
429 | 분당 요청 한도 초과(일반 120/분, 결제 20/분) |
SERVER_DISABLED |
503 | 킬스위치 — 어드민에서 서버 비활성화됨 |
PAYMENT_UNRESOLVED |
503 | 토스 응답 타임아웃·5xx·통신 단절 등으로 결제 결과 불명 — 결제는 PENDING으로 남고 서버가 사후 정산으로 확정. 즉시 재결제하지 말 것(보안 R-1) |
DOMAIN_ERROR |
400 | 기타 비즈니스 규칙 위반 |
INTERNAL_ERROR |
500 | 예상하지 못한 서버 오류 |
⚠️주의: 결제 전용 엔드포인트(카드 등록·구독 생성·수동 결제·단건 결제·단건 취소)는 일반 한도(120/분) 위에 결제 전용 추가 한도(20/분)가 더 적용됩니다(
payment_rate_limit,app/api/deps.py:134). 429가 나오면 1분 후 재시도하세요.참고: 위 표의
{"error": {...}}JSON 형식은 API 예외 핸들러(app/api/errors.py)가 만드는 응답입니다. 다음 두 경우는 형식이 다르므로 클라이언트가error키 없는 응답도 안전하게 처리해야 합니다. ① 라우팅 수준 오류 — 존재하지 않는 경로(404)·잘못된 HTTP 메서드(405)는 FastAPI 기본 형식{"detail": "..."}으로 반환됩니다. ② 프록시(nginx) 수준 오류 — 배포/재시작 중 502, 게이트웨이 타임아웃 504는 JSON이 아닌 nginx 기본 응답으로 옵니다.
13.9 처음부터 끝까지 — 최소 연동 예제
13.2.3의 call() 헬퍼가 있다고 가정하고 카드 등록 → 구독 생성 → 알림 수신까지 잇는 최소 흐름입니다. auth_key는 13.4.1처럼 클라이언트 토스 결제창에서 먼저 받아 둔 값입니다.
EXT_USER = "user@example.com"
# ① 카드 등록 — 클라이언트 토스 결제창에서 받은 customer_key/auth_key 전달
card = call("POST", "/api/v1/cards", {
"external_user_id": EXT_USER,
"customer_key": "cust-123",
"auth_key": "toss_auth_key_xxx", # 1회용(빌링키 발급에 한 번만 사용)
})
# ② 구독 생성 — 등록된 카드로 서버가 첫 결제(체험이면 생략) 후 구독 생성
sub = call("POST", "/api/v1/subscriptions", {
"external_user_id": EXT_USER,
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trial": False,
})
print(sub["status"], sub["access_allowed"]) # 예: ACTIVE True
# ③ (서버 자동) 만료일이 되면 등록 카드로 자동결제·연장 — 서비스 호출 없음
# ④ 상태가 바뀔 때마다 서버가 '알림 URL'로 POST → 아래 수신 핸들러가 처리
수신 측(서비스 서버)은 13.7.3의 verify_notification으로 서명을 검증한 뒤 자기 DB를 갱신합니다. 단건 결제·취소·구독 취소/재개·내역 조회는 같은 call()로 경로만 바꿔 호출하면 됩니다(13.5·13.6 표 참고).
💡팁: 서명·호출을 바로 실행해볼 수 있는 독립 스크립트가
scripts/api_call.py에 있습니다(표준 라이브러리만 사용 —python scripts/api_call.py --api-key ... --secret ...으로 결제 정보 조회 API를 호출·검증). 동작하는 전체 예제는sample_service/에 있습니다 — 카드 등록 화면,POST /notify(서명 검증 후 저장),/notifications(받은 알림 목록)까지 한 흐름으로 따라갈 수 있습니다.
13.10 모니터링 동기화 API — GET /api/v1/metrics/sync
사내 모니터링 서버가 서비스별 손익(수익)을 집계하기 위해 호출하는 읽기 전용 피드입니다. 일반 서비스 키로는 호출할 수 없고, 어드민 > 서비스 상세 > 모니터링 전용 키를 켠 서비스의 키만 통과합니다(그 키는 반대로 구독·결제 API를 호출할 수 없습니다 — 키 유출 시 결제 권한 유출 차단).
인증(13.2)은 동일합니다. GET이므로 본문 해시는 빈 문자열의 SHA-256(e3b0c442…7852b855)이고, 쿼리 파라미터는 서명 대상이 아닙니다(path까지만). 그래서 허용 IP에 모니터링 서버 IP를 반드시 등록하세요 — 허용 IP가 비어 있으면 원격 호출은 403이고(같은 서버의 루프백만 통과), 운영(prod/stg)에서는 IP 없이 스코프를 켤 수 없습니다.
커서(next_cursor)는 서버가 HMAC으로 서명한 토큰입니다. 변조·손상·다른 키로 만든 커서는 422 VALIDATION_ERROR입니다. /api/v1/metrics/* 전체에 일반 한도(120/분)와 별도로 분당 30회 한도가 있습니다(초과 시 429). 정상 사용(하루 수십 회)에는 영향이 없습니다.
13.10.1 동작 — 커서 하나로 "직전 호출 이후 바뀐 것"만
GET /api/v1/metrics/sync?cursor=<직전 응답의 next_cursor>&limit=500
| 파라미터 | 필수 | 기본 | 설명 |
|---|---|---|---|
cursor |
선택 | 없음 | 직전 응답의 next_cursor. 생략하면 처음부터(전체 히스토리) |
limit |
선택 | 500 | 1회 최대 행 수. 상한 2000(초과 시 422) |
- 첫 호출은
cursor없이 →has_more가false가 될 때까지next_cursor를 물려 반복하면 전체 히스토리가 적재됩니다. - 이후에는 저장해 둔
next_cursor만 보내면 됩니다. 호출 간격은 5분이든 3일이든 무관합니다. - 재적재(모니터링 쪽 로직 변경·데이터 유실)는 커서를 지우고 다시 호출하면 됩니다. 별도 백필 API가 없습니다.
cursor는 불투명 토큰입니다 — 내용을 해석·가공하지 마세요. 손상·구버전이면422 VALIDATION_ERROR→ 커서를 지우고 전체 재적재하세요.
13.10.2 응답 — 일별 집계 행(건별 아님)
행 단위는 KST 날짜 × 서비스 × 요금제 × 결제종류입니다. 모니터링은 이 4개 키로 UPSERT만 하면 되고, 주/월/연·서비스별 합계는 GROUP BY로 얻습니다. 같은 행이 다시 와도 덮어쓰면 되므로 중복 수신은 무해합니다.
{
"rows": [{
"date": "2026-09-01",
"service_id": "8f2c…", "service_name": "chat-prod", "real_service_name": "메디솔브 챗",
"plan_id": "a71e…", "plan_name": "프로", "billing_cycle": "MONTH", "mrr_factor": 1.0,
"kind": "SUBSCRIPTION",
"gross_amount": 1400000, "refund_amount": 29000, "cancel_fee_amount": 0,
"net_amount": 1371000, "currency": "KRW",
"payment_count": 48, "failed_count": 1,
"new_subscriptions": 5, "canceled_subscriptions": 1, "suspended_subscriptions": 0,
"expired_subscriptions": 0, "resumed_subscriptions": 0, "plan_changed_subscriptions": 1,
"renewed_subscriptions": 40,
"revision": "2026-09-02T11:20:44+09:00"
}],
"next_cursor": "eyJ2IjoxLCJyIjoi…",
"has_more": false,
"generated_at": "2026-09-03T18:10:04+09:00"
}
| 필드 | 귀속 기준 | 정의 |
|---|---|---|
gross_amount · payment_count |
승인일(approved_at) |
승인 원금 합 — DONE + CANCELED |
refund_amount |
취소일(canceled_at) |
그날 실제 발생한 환불 합(회차별 취소 원장) |
cancel_fee_amount |
취소일 | 그날 취소에서 보유한 수수료 합 |
net_amount |
— | gross_amount − refund_amount. 환불만 있는 날은 음수 |
failed_count |
요청일(requested_at) |
실패 건수(승인 시각이 없으므로) |
mrr_factor |
— | 요금제 가격 → 월환산 계수(YEAR 1/12, MONTH 1, WEEK 30.4375/7, DAY 30.4375/일수). MINUTE·단건은 null |
new_subscriptions |
구독 생성일 | 그날 생성된 구독 수(어드민 대시보드 '신규 구독'과 동일) |
canceled_subscriptions |
이벤트 발생일 | 사용자·운영자 해지 건수(감사 subscription.cancel/force_cancel) |
suspended_subscriptions |
이벤트 발생일 | 결제 실패로 정지된 건수. 대시보드 '구독 취소' = canceled + suspended |
expired_subscriptions |
이벤트 발생일 | 만료 전환 건수 |
resumed_subscriptions · plan_changed_subscriptions · renewed_subscriptions |
이벤트 발생일 | 해지 철회 · 요금제 변경 확정 · 자동 갱신 성공 건수 |
구독 흐름 컬럼은 kind=SUBSCRIPTION 행에만 값이 있고 단건 행은 0입니다. 결제가 없고 구독 이벤트만 있는 날도 행이 옵니다(금액 0). 구독 이벤트의 요금제는 구독의 현재 요금제입니다.
주의할 점:
- 과거 날짜의 행이 다시 올 수 있습니다. 지연 승인(PENDING→DONE)이나 환불이 생기면 해당 날짜 행이 재전송됩니다. 8/15 결제를 9/2에 환불하면 9/2 행의 refund_amount가 올라가고 8/15 행은 값이 바뀌지 않습니다(정산 화면과 같은 규칙).
- 단건결제(kind=ONE_OFF)는 plan_id가 null입니다. 요금제별 화면에서 null을 버리면 매출이 새는 것처럼 보입니다.
- 키는 id로, 이름은 표시용으로 다루세요. 요금제명이 바뀌어도 과거 행은 재전송되지 않습니다.
- 구독 결제의 요금제는 구독의 현재 요금제입니다(요금제 변경 이력은 결제 행에 없음).
13.10.3 구독 현황 스냅샷 — GET /api/v1/metrics/subscriptions
"지금 구독이 몇 개인가"는 시점 값이라 증분 피드에 넣을 수 없습니다. 같은 모니터링 키로 이 엔드포인트를 매 수집 실행마다 호출해 as_of와 함께 저장하면 일별 시계열이 됩니다. 파라미터 없음.
{
"as_of": "2026-09-04T00:10:02+09:00",
"rows": [{
"service_id": "8f2c…", "service_name": "chat-prod", "real_service_name": "메디솔브 챗",
"plan_id": "a71e…", "plan_name": "프로", "billing_cycle": "MONTH",
"price": 29000, "mrr_factor": 1.0,
"total": 312, "open": 298,
"by_status": {"TRIAL": 4, "ACTIVE": 280, "PAST_DUE": 6, "SUSPENDED": 2,
"CANCELED": 5, "EXTENDED": 1, "EXPIRED": 14},
"mrr_krw": 8323000
}]
}
| 필드 | 정의 |
|---|---|
total / open |
전체(EXPIRED 포함) / 열린 구독(TRIAL·ACTIVE·PAST_DUE·SUSPENDED·CANCELED·EXTENDED) |
by_status |
상태별 구독 수. 7개 키가 항상 존재(0 포함) |
price |
요금제 상시 결제액(상시 할인 반영, KRW) |
mrr_krw |
월환산 반복 매출 = 갱신이 이어지는 상태(ACTIVE·PAST_DUE·EXTENDED) 구독수 × price × mrr_factor. TRIAL(미결제)·CANCELED(갱신 안 함)·MINUTE 요금제 제외 |
서비스별 합계는 real_service_name으로, 서비스구독별은 service_id로 더하면 됩니다. 스냅샷은 합산하지 말고 시점별로 보관하세요 — 어제와 오늘 스냅샷을 더하면 무의미합니다.
13.10.4 건별 결제 피드 — GET /api/v1/metrics/payments (2026-09-09 추가)
💬개인정보(보안 검토 R-1): 건별 피드의
external_user_id(이메일)는 기본null입니다. 대신 서비스별 키드 해시user_ref(24자)가 내려가며, 같은 고객은 같은 값이라 고객별 통계·이력을 묶을 수 있고 원문 복원은 불가합니다. 원문이 꼭 필요하면 어드민 > 서비스 상세 > 모니터링 전용 키 카드의 "고객 이메일 원문 포함"(metrics_pii_scope)을 켭니다(감사 로그). 건별 피드 호출은 매번 감사 로그metrics.items_read(피드·행수·PII 여부·IP)에 남습니다.
일별 집계(13.10.2)는 "그날 얼마"만 알려 주므로, 모니터링이 고객(이메일=병원)별 매출·결제 이력·실패 사유·결제 금액 구간·결제 유형 통계를 내려면 건별 행이 필요합니다. 인증(모니터링 전용 키)·cursor/limit 파라미터·커서 규칙(불투명 토큰, 마지막 페이지 되감기, 손상 시 422)은 13.10.1과 같습니다.
GET /api/v1/metrics/payments?cursor=<직전 응답의 next_cursor>&limit=500
- 행 단위: 결제 1건 = 1행,
status∈DONE·CANCELED·FAILED(승인 대기PENDING은 내리지 않음). - 키:
payment_id. 취소·환불(부분 취소 포함)로 결제 행이 바뀌면 같은payment_id가 새revision으로 다시 옵니다 → UPSERT. - 커서 순서:
(revision = 결제 행 updated_at, payment_id). 커서 토큰에는 피드 이름이 들어 있어 다른 피드(sync·subscription-list)의 커서를 넣으면422. - 개인정보 포함:
external_user_id는 결제한 사용자의 이메일입니다. 모니터링은 암호화·마스킹해 저장하고 관리자만 조회하게 하세요. - 보내지 않는 것: 토스 결제 키·토스 주문 ID·멱등성 키·응답 원문·카드 정보·취소 행위자.
{
"rows": [{
"payment_id": "6c1f…", "order_id": "ord-20260901-001", "order_name": "메디솔브 챗 프로(월)",
"external_user_id": "clinic@hospital.com",
"service_id": "8f2c…", "service_name": "chat-prod", "real_service_name": "메디솔브 챗",
"plan_id": "a71e…", "plan_name": "프로", "billing_cycle": "MONTH", "mrr_factor": 1.0,
"kind": "SUBSCRIPTION", "subscription_id": "e0b3…",
"payment_type": "RENEWAL", "status": "CANCELED",
"amount": 29000, "canceled_amount": 26100, "cancel_fee": 2900, "net_amount": 2900,
"failure_code": null, "failure_message": null,
"requested_at": "2026-09-01T12:00:00+09:00", "approved_at": "2026-09-01T12:00:03+09:00",
"canceled_at": "2026-09-05T09:30:00+09:00",
"revision": "2026-09-05T09:30:00+09:00"
}],
"next_cursor": "eyJ2IjoxLCJ0IjoicGF5bWVudHMiLCJyIjoi…",
"has_more": false,
"generated_at": "2026-09-09T18:10:04+09:00"
}
| 필드 | 정의 |
|---|---|
payment_type |
FIRST 첫 결제 · RENEWAL 정기 갱신 · RETRY 미결제 재시도 · CHANGE 요금제 변경 즉시 결제 · ONE_OFF 단건 |
status |
DONE 승인 · CANCELED 승인 후 취소(전액 또는 원금 소진) · FAILED 승인 실패 |
amount / canceled_amount / cancel_fee |
청구 금액 / 환불 누계(부분 취소 합) / 취소 수수료 누계(서비스 몫) |
net_amount |
amount − canceled_amount. FAILED는 0 |
failure_code · failure_message |
FAILED일 때 토스 실패 코드·메시지(카드 한도·잔액 부족 등) |
plan_id 등 요금제 필드 |
구독 결제는 구독의 현재 요금제(일별 집계와 같은 규칙). 단건은 null |
| 시각 필드 | 모두 KST(+09:00) |
일별 집계와의 대조: 이 피드의 DONE·CANCELED 행을 approved_at의 KST 일자 × service_id × plan_id × kind로 더한 amount 합과 건수는 13.10.2의 gross_amount·payment_count와, FAILED 행을 requested_at 일자로 센 값은 failed_count와 일치합니다(결제서버 테스트 test_payment_items_reconcile_with_daily_sync). 환불은 취소 회차 원장 기준이라 canceled_amount 누계를 날짜별로 나누지 않으므로 refund_amount와는 직접 비교하지 않습니다.
13.10.5 건별 구독 피드 — GET /api/v1/metrics/subscription-list (2026-09-09 추가)
고객별 현재 구독 상태(활성·미결제·정지·해지·만료)와 요금제·다음 결제일을 위한 건별 목록입니다. 13.10.3의 스냅샷이 요금제별 재고 집계라면, 이 피드는 구독 한 건 한 건입니다. 인증·파라미터·커서 규칙은 13.10.4와 같습니다.
GET /api/v1/metrics/subscription-list?cursor=<직전 응답의 next_cursor>&limit=500
- 행 단위: 구독 1건 = 1행,
EXPIRED포함(모니터링이 이력을 보존). 키:subscription_id. - 상태·주기·다음 결제일·재시도 횟수·요금제 변경 예약이 바뀌면 같은
subscription_id가 새revision(구독 행updated_at)으로 다시 옵니다 → UPSERT. external_user_id는 구독한 사용자의 이메일(개인정보). 카드·빌링키 정보는 내리지 않습니다.
{
"rows": [{
"subscription_id": "e0b3…", "external_user_id": "clinic@hospital.com",
"service_id": "8f2c…", "service_name": "chat-prod", "real_service_name": "메디솔브 챗",
"plan_id": "a71e…", "plan_name": "프로", "billing_cycle": "MONTH", "price": 29000, "mrr_factor": 1.0,
"status": "PAST_DUE", "retry_count": 2,
"pending_plan_id": null, "pending_plan_name": null,
"created_at": "2026-03-01T10:00:00+09:00",
"current_period_start": "2026-09-01T10:00:00+09:00", "current_period_end": "2026-10-01T10:00:00+09:00",
"next_billing_at": "2026-09-10T10:00:00+09:00", "suspended_at": null,
"revision": "2026-09-09T10:00:05+09:00"
}],
"next_cursor": "eyJ2IjoxLCJ0Ijoic3Vic2NyaXB0aW9ucyIsInIiOi…",
"has_more": false,
"generated_at": "2026-09-09T18:10:04+09:00"
}
| 필드 | 정의 |
|---|---|
status |
TRIAL 체험 · ACTIVE 활성 · PAST_DUE 미결제(재시도 중, 이용 가능) · SUSPENDED 정지(차단) · CANCELED 해지 예약(만료일까지 이용) · EXTENDED 운영자 연장 · EXPIRED 만료(종단) |
price · mrr_factor |
요금제 상시 결제액(상시 할인 반영) · 월환산 계수 → 고객별 MRR = price × mrr_factor(ACTIVE·PAST_DUE·EXTENDED만) |
current_period_end |
현재 주기 종료 = 이용 만료 시각. CANCELED는 이 시각까지 이용 |
pending_plan_id |
다음 결제일에 전환 예약된 요금제(다운그레이드) |
13.10.6 모니터링 쪽 최소 구현
cursor = db.scalar("SELECT cursor FROM sync_state WHERE source='payment_server'") # 없으면 None
while True:
resp = call("GET", "/api/v1/metrics/sync", params={"cursor": cursor, "limit": 500})
with db.begin(): # UPSERT 와 커서 저장은 같은 트랜잭션
upsert_revenue_daily(resp["rows"]) # (date, service_id, plan_id, kind) 키
save_cursor(resp["next_cursor"])
cursor = resp["next_cursor"]
if not resp["has_more"]:
break
snap = call("GET", "/api/v1/metrics/subscriptions") # 매 실행 1회 — as_of 와 함께 저장
upsert_subscription_snapshot(snap["as_of"], snap["rows"])
- 정시 실행과 수동 새로고침이 겹치지 않게 락을 거세요. 동시에 커서를 갱신하면 유실됩니다.
429·503은 지수 백오프로 재시도,401·403은 재시도 없이 즉시 알림. 재시도할 때도 nonce·timestamp는 새로 만드세요.
🔗함께 보기: 카드 흐름 → 카드 기능 코드 흐름 · 구독 흐름 → 구독 기능 코드 흐름 · 결제 흐름 → 결제 기능 코드 흐름 · 알림 → 서비스 알림