🛠️ 개발자 매뉴얼

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) 등 결제 키 원문은 어떤 응답에도 포함되지 않습니다. 카드는 마스킹 정보만 반환됩니다.
  • 사용자 기준 키: 대부분의 경로는 {external_user_id}(외부 서비스 측 사용자 식별자)를 사용합니다. (서비스 + 사용자 당 카드 1장·구독 1개 규칙의 기준)
  • external_user_id(이메일)는 반드시 이메일: 전역 룰로 external_user_id(이메일)에는 이메일만 허용합니다. 서버가 받는 즉시 앞뒤 공백 제거 + 소문자로 정규화해 저장·조회하므로(app/core/identifiers.py), 대소문자만 다른 값(User@x.com vs user@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) (PlanListResponsePlanResponse 배열, 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_keyauth_key는 이 API를 호출하기 전에 서비스 클라이언트(앱/웹)에서 토스 결제창(빌링 인증)으로 얻습니다. 결제 서버가 발급하는 값이 아닙니다. 흐름은 다음과 같습니다.

  1. 서비스가 사용자별로 정한 customerKey(중복 없는 고객 식별자)로 토스 SDK 빌링 인증창(requestBillingAuth)을 띄웁니다.
  2. 사용자가 카드 인증을 마치면, 토스가 지정한 successUrl로 1회용 authKey를 돌려줍니다.
  3. 서비스 서버가 이 customerKey/authKey를 받아 POST /api/v1/cards로 전달하면, 결제 서버가 토스에 빌링키 발급을 요청해 카드 보관함에 암호화 저장합니다.

authKey1회용이라 빌링키 발급에 한 번 쓰면 재사용할 수 없습니다(재발급은 인증창부터 다시). 토스 결제창(빌링) 연동 자체는 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/CANCELED=true, SUSPENDED/EXPIRED=false
current_period_start datetime 현재 결제 주기 시작 시각
current_period_end datetime 현재 결제 주기 종료(만료) 시각
next_billing_at datetime | null 다음 자동결제 예정 시각. 해지 예약·만료 시 null
card object | null 등록 카드 마스킹 정보. 미등록 시 null
retry_count int PAST_DUE 상태에서의 결제 재시도 횟수
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,
  "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).
  • 재개: 취소 예약을 철회해 원래 상태로 복귀합니다. 셋 다 요청 본문이 없고 응답은 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를 반환합니다.

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 이상, 생략 가능 업그레이드 시 환불할 금액(원). 생략하면 서버가 미사용 기간을 일할계산(초 비례, 원 단위 내림). 잔여 환불가능액 초과 시 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
💬

모바일에서는 iframe/frame 위에서 결제창을 호출하면 안 됩니다(특정 간편결제 미동작 + 페이지 이동 필수 — 토스 공식 가이드). Redirect 방식을 그대로 쓰세요.

리다이렉트 쿼리 파라미터

목적지 쿼리 설명
successUrl paymentKey · orderId · amount orderId=toss_order_id. amount대조용으로만 사용(신뢰 금지) — 그대로 confirm의 대조 amount로 넘기면 서버가 검증
failUrl code · messageorderId) 사용자가 창을 닫으면 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/{external_user_id} HMAC 결제 내역 조회(최신순 최대 50건) app/api/v1/payments.py

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_name string 1–100자 결제창에 표시되는 주문명
amount int 0 초과, 상한 이하 결제 금액(원). 승인은 항상 이 값(서버 저장 금액)으로 수행 — 결제창·successUrl에서 금액이 위변조되면 토스/서버가 거절

같은 order_id로 다시 요청했을 때

기존 주문 상태동작
대기(PENDING) · 완료(DONE) 기존 주문을 그대로 반환합니다(멱등 — 같은 요청을 여러 번 보내도 한 번만 처리).
실패(FAILED) 같은 주문번호로 재결제할 수 있습니다. 주문이 다시 대기 상태가 되고 toss_order_id새 값으로 발급됩니다 — 결제창에는 반드시 응답으로 받은 새 toss_order_id를 쓰세요. 직전 실패 이력은 감사 로그에 보존됩니다.
취소(CANCELED) 409로 거절합니다. 환불 이력이 있는 주문번호를 재사용하면 같은 번호가 두 사건을 가리키게 되어 정산이 어긋납니다 — 새 주문번호로 요청하세요.
💡

카드 한도초과 등으로 결제가 실패한 뒤 다른 카드로 재시도하는 것은 가장 흔한 흐름입니다. 자사 주문번호를 그대로 쓰는 서비스도 이 경우 주문번호를 바꿀 필요가 없습니다. 단, 재요청 응답의 toss_order_id를 반드시 새로 읽어 결제창에 넘겨야 합니다(이전 값은 토스에서 이미 소진된 상태입니다).

{
  "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. 에러 테스트

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.pyresolve_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원 부분취소 → 사용자가 다시 취소 시도 시 { "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 배열)을 포함합니다. 기본값이면 cancellationsnull입니다.
💡

참고: 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. 따라서 결제 내역만으로 "지금 취소하면 얼마가 빠지고 얼마가 환불되는지"를 화면에 미리 안내할 수 있습니다.

중요(부분취소 반영): 관리자가 어드민에서 단건 결제를 부분취소하면 statusDONE을 유지한 채 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_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/email이 빈값이고 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_servicePOST /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 재사용
PAYMENT_FAILED 402 토스 결제 승인 실패
FORBIDDEN 403 IP 화이트리스트 미포함
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분 후 재시도하세요.


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 표 참고).

💡

팁: 동작하는 전체 예제는 sample_service/에 있습니다 — 카드 등록 화면, POST /notify(서명 검증 후 저장), /notifications(받은 알림 목록)까지 한 흐름으로 따라갈 수 있습니다.


🔗

함께 보기: 카드 흐름 → 카드 기능 코드 흐름 · 구독 흐름 → 구독 기능 코드 흐름 · 결제 흐름 → 결제 기능 코드 흐름 · 알림 → 서비스 알림