11. 서비스 연동 API
이 문서는 사내 외부 서비스(진료 앱·쇼핑몰 등)가 구독·결제 서버에 직접 호출하는 REST API 전체 레퍼런스입니다. 인증(HMAC 서명)부터 카드·구독·결제·조회, 그리고 서버가 서비스로 보내는 알림 수신까지 한곳에 정리했습니다.
🍎쉽게 말하면, 이 문서는 "내 서비스 코드가 구독·결제 서버와 주고받는 약속(요청·응답 형식)"을 그대로 적어 둔 사전입니다.
함께 보기: 카드 기능 코드 흐름 · 구독 기능 코드 흐름 · 결제 기능 코드 흐름 · 서비스 알림
참고: 모든 외부 API는
/api/v1접두어로 등록됩니다(app/main.py). 예:POST /api/v1/cards.
11.1 공통 규칙
- 요청/응답 형식: JSON. 인증이 필요한 요청에는 아래 4개 서명 헤더를 반드시 포함합니다.
- 금액 보호: 구독 금액은 서버가 요금제(Plan)에서 직접 계산하므로 클라이언트가 보낼 수 없습니다. 단건 결제만 클라이언트가
amount를 지정하며, 이때도 HMAC 본문 서명이 금액 변조를 차단합니다. - 민감 정보 비노출: 빌링키(billingKey) 등 결제 키 원문은 어떤 응답에도 포함되지 않습니다. 카드는 마스킹 정보만 반환됩니다.
- 사용자 기준 키: 대부분의 경로는
{external_user_id}(외부 서비스 측 사용자 식별자)를 사용합니다. (서비스 + 사용자 당 카드 1장·구독 1개 규칙의 기준)
11.2 인증 — HMAC 서명
모든 인증 필요 요청은 API 키 + IP 화이트리스트 + HMAC 서명 3중 검증을 통과해야 합니다(app/api/deps.py:48 authenticate_service). 검증 순서는 ① API 키 해시 대조 → ② IP 화이트리스트 → ③ 처리율 제한 → ④ 타임스탬프 윈도우 → ⑤ HMAC 서명 → ⑥ nonce 1회용 소비입니다.
11.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로 거부됩니다(재전송 방어).
11.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 인코딩
11.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()결과 그대로 전송).
11.3 카드 API
구독·단건 결제 전에 카드를 먼저 등록해야 합니다. 빌링키는 등록된 카드(카드 보관함)에서 서버가 자동 조회하므로, 구독·결제 요청에는 카드 정보를 넣지 않습니다.
| 메서드·경로 | 인증 | 용도 | 라우트 |
|---|---|---|---|
POST /api/v1/cards |
HMAC + 결제 제한 | 카드 등록 또는 교체(빌링키 발급) | app/api/v1/cards.py:40 |
GET /api/v1/cards/{external_user_id} |
HMAC | 등록 카드 마스킹 정보 조회(없으면 404) | app/api/v1/cards.py:80 |
DELETE /api/v1/cards/{external_user_id} |
HMAC | 카드·빌링키 삭제(204) | app/api/v1/cards.py:111 |
11.3.1 카드 등록 / 교체 — POST /api/v1/cards
(service, external_user_id)당 1장을 유지하며, 카드가 이미 있으면 기존 행을 교체하고 이전 빌링키를 best-effort 삭제합니다. 응답에 billingKey는 절대 포함되지 않습니다.
요청 본문 (CardRegisterRequest, app/schemas/api.py:305)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
external_user_id |
string | 1–255자 | 외부 서비스 측 사용자 식별자 |
customer_key |
string | 2–300자 | 토스 customerKey(고객 식별자, 최소 2자) |
auth_key |
string | 1–300자 | 토스 결제창에서 발급받은 1회용 authKey(빌링키 발급에 사용) |
{
"external_user_id": "user-123",
"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:326)
| 필드 | 타입 | 설명 |
|---|---|---|
external_user_id |
string | 외부 서비스 측 사용자 식별자 |
card |
object | null | 카드 마스킹 정보(issuerCode·number 등 표시용). 정보 없으면 null |
{
"external_user_id": "user-123",
"card": {"issuerCode": "61", "number": "123456******1234"}
}
11.3.2 카드 조회 — GET /api/v1/cards/{external_user_id}
응답 형식은 등록과 동일(CardResponse)합니다. 등록된 카드가 없으면 404를 반환합니다.
11.3.3 카드 삭제 — DELETE /api/v1/cards/{external_user_id}
성공 시 본문 없이 204 No Content를 반환합니다.
⚠️주의: billing-active 상태(TRIAL·ACTIVE·PAST_DUE·SUSPENDED·EXTENDED)의 구독이 이 카드를 사용 중이면 409(CONFLICT) 로 삭제가 거부됩니다. 카드가 없으면 404입니다.
11.4 구독 API
⚠️중요: 구독 생성 전에 반드시
POST /api/v1/cards로 카드를 먼저 등록해야 합니다. 구독 요청에는 카드/빌링키 정보를 넣지 않으며, 서버가 등록된 카드에서 빌링키를 조회합니다(app/api/v1/subscriptions.py:80).
| 메서드·경로 | 인증 | 용도 | 라우트 |
|---|---|---|---|
POST /api/v1/subscriptions |
HMAC + 결제 제한 | 구독 생성(trial 가능) | app/api/v1/subscriptions.py:61 |
GET /api/v1/subscriptions/{external_user_id} |
HMAC | 최근 구독 조회(없으면 404) | app/api/v1/subscriptions.py:143 |
POST /api/v1/subscriptions/{external_user_id}/cancel |
HMAC | 취소 예약(만료일에 자동 종료) | app/api/v1/subscriptions.py:167 |
POST /api/v1/subscriptions/{external_user_id}/resume |
HMAC | 취소 예약 철회(재개) | app/api/v1/subscriptions.py:189 |
POST /api/v1/subscriptions/{external_user_id}/pay |
HMAC + 결제 제한 | 정지(SUSPENDED) 구독 수동 결제 복구 | app/api/v1/subscriptions.py:92 |
POST /api/v1/subscriptions/{external_user_id}/add-days |
HMAC | 사용일 추가(만료일·다음 결제일 연장) | app/api/v1/subscriptions.py:118 |
11.4.1 구독 생성 — POST /api/v1/subscriptions
요청 본문 (SubscriptionCreateRequest, app/schemas/api.py:17)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
external_user_id |
string | 1–255자 | 외부 서비스 측 사용자 식별자(서비스+사용자 당 구독 1개 기준) |
plan_id |
UUID | - | 구독할 요금제 ID(요금제 목록 응답의 id) |
trial |
bool | 기본 false | true이면 체험 시작. 요금제 trial_enabled=true일 때만 허용(아니면 422) |
{
"external_user_id": "user-123",
"plan_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"trial": false
}
💡참고: 금액 필드가 없습니다. 서버가 요금제에서 계산하므로 클라이언트가 금액을 조작할 수 없습니다(
app/schemas/api.py:37).
응답 (201) (SubscriptionResponse, app/schemas/api.py:87)
| 필드 | 타입 | 설명 |
|---|---|---|
id |
UUID | 구독 ID |
external_user_id |
string | 외부 서비스 측 사용자 식별자 |
plan_id |
UUID | 구독한 요금제 ID |
plan_name |
string | 구독한 요금제 이름 |
status |
string | TRIAL | ACTIVE | PAST_DUE | SUSPENDED | CANCELED | EXPIRED |
access_allowed |
bool | 서비스 접근 허용 여부. TRIAL/ACTIVE/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 상태에서의 결제 재시도 횟수 |
{
"id": "aabbccdd-1111-2222-3333-444455556666",
"external_user_id": "user-123",
"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-10T02:55:00Z",
"card": {"issuerCode": "61", "number": "123456******1234"},
"retry_count": 0
}
⚠️중요: 외부 서비스는
access_allowed값으로 사용자의 서비스 접근을 판단하세요(app/schemas/api.py:101). 상태 문자열을 직접 해석하기보다 이 불리언 한 개를 쓰는 것이 안전합니다.
11.4.2 조회 / 취소 / 재개
- 조회: 가장 최근 구독을
SubscriptionResponse로 반환합니다. 구독이 없으면 404. - 취소 예약: 즉시 삭제가 아니라 만료일이 되면 자동 종료됩니다. 취소 예약 후에도 만료 전까지는
access_allowed=true(CANCELED). - 재개: 취소 예약을 철회해 원래 상태로 복귀합니다. 셋 다 요청 본문이 없고 응답은
SubscriptionResponse입니다.
11.4.3 수동 결제(정지 구독 복구) — POST /.../pay
정지(SUSPENDED) 구독의 미수금을 즉시 결제합니다. 성공 시 ACTIVE로 복귀하고 기준일이 리셋됩니다. 토스 청구가 발생하므로 결제 전용 처리율 제한이 적용됩니다. 요청 본문 없음, 응답은 갱신된 SubscriptionResponse.
11.4.4 사용일 추가 — POST /.../add-days
이용 중(ACTIVE·EXTENDED·PAST_DUE) 구독의 만료일·다음 결제일을 days만큼 미룹니다(상태는 변경하지 않음). 토스 결제 호출이 없으므로 일반 HMAC 인증으로 충분합니다.
요청 본문 (UsageDaysRequest, app/schemas/api.py:160)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
days |
int | 1–3650 | 추가할 사용일수 |
{ "days": 30 }
⚠️주의: 대상 상태(ACTIVE·EXTENDED·PAST_DUE)가 아니면 409(CONFLICT), 구독이 없으면 404를 반환합니다.
11.5 결제 API (단건/1회성)
구독과 무관한 1회성 결제입니다. 단건 결제도 사전 등록된 카드(POST /cards)를 사용하며 빌링키는 서버가 카드 보관함에서 자동 조회합니다.
| 메서드·경로 | 인증 | 용도 | 라우트 |
|---|---|---|---|
POST /api/v1/payments |
HMAC + 결제 제한 | 단건 결제 생성(즉시 청구) | app/api/v1/payments.py:65 |
POST /api/v1/payments/{order_id}/cancel |
HMAC + 결제 제한 | 단건 결제 취소(환불, 수수료 공제) | app/api/v1/payments.py:102 |
GET /api/v1/payments/{external_user_id} |
HMAC | 결제 내역 조회(최신순 최대 50건) | app/api/v1/payments.py:37 |
11.5.1 단건 결제 생성 — POST /api/v1/payments
요청 본문 (OneOffPaymentRequest, app/schemas/api.py:129)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
external_user_id |
string | 1–255자 | 결제 대상 사용자 식별자 |
order_id |
string | 6–64자 | 주문 ID. 서비스 내 고유(타 서비스와는 중복 가능). 같은 order_id 재시도는 기존 결제 반환(멱등) |
order_name |
string | 1–100자 | 결제창에 표시되는 주문명 |
amount |
int | 0 초과, 1억원 이하 | 결제 금액(원). 클라이언트가 지정하며 HMAC 본문 서명이 변조를 차단 |
{
"external_user_id": "user-123",
"order_id": "order-20260610-0001",
"order_name": "프리미엄 1회 이용권",
"amount": 10000
}
⚠️주의: 카드 미등록 시 404를 반환합니다. 타임아웃(결과 불명) 시 결제는 PENDING으로 유지되어 이중 결제를 방지합니다(
app/api/v1/payments.py:87).
응답은 PaymentResponse(아래 11.5.4)입니다.
11.5.2 단건 결제 취소 — POST /api/v1/payments/{order_id}/cancel
서비스 취소 정책에 따라 환불(수수료 공제)합니다.
요청 본문 (OneOffCancelRequest, app/schemas/api.py:170)
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
reason |
string | 1–200자, 기본 "사용자 취소" | 취소 사유. 토스 취소 API의 cancelReason으로 전달 |
{ "reason": "고객 요청으로 취소" }
⚠️주의: 서비스 정책이 취소 비허용(
cancellation_enabled=false)이거나 결제가 완료(DONE) 상태가 아니면 오류를 반환합니다. 취소 성공 시status=CANCELED와canceled_amount/cancel_fee가 채워진 결과를 반환합니다.
11.5.3 결제 내역 조회 — GET /api/v1/payments/{external_user_id}
해당 사용자의 결제 내역을 최신순 최대 50건 반환합니다. 구독 정기결제와 단건(ONE_OFF) 결제를 모두 포함합니다. Payment.service_id로 범위가 격리되어 다른 서비스의 결제는 보이지 않습니다.
{
"payments": [ /* PaymentResponse 객체 배열 */ ]
}
11.5.4 결제 응답(PaymentResponse)과 취소/환불 필드
PaymentResponse(app/schemas/api.py:183)는 결제 결과 + 서비스 취소 정책에서 만들어집니다. toss_payment_key·raw_response 등 내부 필드는 노출되지 않습니다.
| 필드 | 타입 | 설명 |
|---|---|---|
order_id |
string | 주문 ID |
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 |
cancelable |
bool | 지금 취소 가능 여부. 단건(ONE_OFF)·완료(DONE)·미취소·서비스 취소허용일 때만 true |
cancel_fee_percent |
int | 서비스 취소 수수료율(%) |
cancel_fee |
int | 취소 시 차감 수수료(원). 취소 가능 결제는 예상액, 이미 취소된 결제는 실제 차감액 |
cancel_refund_amount |
int | 환불액(원). 취소 가능 결제는 예상액, (부분/전액) 취소된 결제는 실제 누적 환불액 |
canceled_amount |
int | 실제 환불된 누적 금액(원). 어드민 부분취소 시 status는 DONE이지만 이 값이 0보다 큼 |
net_amount |
int | 실수령(순) 금액(원) = amount − canceled_amount. 부분취소 반영 |
💡참고: 취소 수수료는 실제 취소 처리와 동일한 계산을 공유합니다 →
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라 외부에서 추가 취소할 수 없습니다.
{
"order_id": "order-20260610-0001",
"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",
"cancelable": true,
"cancel_fee_percent": 10,
"cancel_fee": 1000,
"cancel_refund_amount": 9000,
"canceled_amount": 0,
"net_amount": 10000
}
11.6 서비스 알림 수신(아웃고잉 웹훅)
구독·결제·카드·요금제 상태가 바뀌면, 서버가 서비스 상세에 등록된 알림 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는 서명 대상이 아니며 라우팅 편의용 힌트입니다).
11.6.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에 요금제명·상세가 담깁니다.
11.6.2 이벤트 목록(EVENT)
| 상황 | EVENT |
|---|---|
| 새로운 구독자 발생 | subscription.created |
| 구독 상태 변화(취소·재개·미수·정지·만료·수동결제복구) | subscription.status_changed |
| 구독 자동결제 발생 | subscription.renewed |
| 관리자 강제 구독취소 | 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.
11.6.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):
"""서버가 보낸 알림 서명을 검증한다 — 11.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(받은 알림 목록) 화면에 있습니다. 어드민의 '테스트 알림 전송' 버튼으로 연결을 즉시 확인할 수 있습니다.
11.7 오류 응답 형식과 상태 코드
모든 API 오류는 아래 공통 형식으로 반환됩니다(ErrorResponse, app/schemas/api.py:299).
{
"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 | 필드 검증 실패 또는 비즈니스 규칙 위반(trial 불가 등) |
RATE_LIMITED |
429 | 분당 요청 한도 초과(일반 120/분, 결제 20/분) |
SERVER_DISABLED |
503 | 킬스위치 — 어드민에서 서버 비활성화됨 |
DOMAIN_ERROR |
400 | 기타 비즈니스 규칙 위반 |
INTERNAL_ERROR |
500 | 예상하지 못한 서버 오류 |
⚠️주의: 결제 전용 엔드포인트(카드 등록·구독 생성·수동 결제·단건 결제·단건 취소)는 일반 한도(120/분) 위에 결제 전용 추가 한도(20/분)가 더 적용됩니다(
payment_rate_limit,app/api/deps.py:115). 429가 나오면 1분 후 재시도하세요.
11.8 처음부터 끝까지 — 최소 연동 예제
11.2.3의 call() 헬퍼가 있다고 가정하고 카드 등록 → 구독 생성 → 알림 수신까지 잇는 최소 흐름입니다. auth_key는 11.3.1처럼 클라이언트 토스 결제창에서 먼저 받아 둔 값입니다.
EXT_USER = "user-123"
# ① 카드 등록 — 클라이언트 토스 결제창에서 받은 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 → 아래 수신 핸들러가 처리
수신 측(서비스 서버)은 11.6.3의 verify_notification으로 서명을 검증한 뒤 자기 DB를 갱신합니다. 단건 결제·취소·구독 취소/재개·내역 조회는 같은 call()로 경로만 바꿔 호출하면 됩니다(11.4·11.5 표 참고).
💡팁: 동작하는 전체 예제는
sample_service/에 있습니다 — 카드 등록 화면,POST /notify(서명 검증 후 저장),/notifications(받은 알림 목록)까지 한 흐름으로 따라갈 수 있습니다.
🔗함께 보기: 카드 흐름 → 카드 기능 코드 흐름 · 구독 흐름 → 구독 기능 코드 흐름 · 결제 흐름 → 결제 기능 코드 흐름 · 알림 → 서비스 알림