퓨인소프트 통합 결제

개발자 문서 — 결제 연동 API

퓨인소프트 주식회사가 운영하는 서비스(SaaS)를 pay.fuinsoft.com 결제 허브에 연동하기 위한 서버 간 API·웹훅 계약입니다. 연동은 운영자가 테넌트를 등록해 드린 뒤 시작합니다.

개요

연동은 세 가지 요소로 이뤄집니다.

1. 서버 → 허브 API

결제 세션·구독을 생성하고 상태를 조회합니다. API 키는 서버에서만 사용하며 브라우저에 노출하지 않습니다.

2. 결제창 리다이렉트

생성된 checkoutUrl·billingUrl로 사용자를 보내면 허브가 결제창을 띄웁니다. 카드정보는 허브도 서비스도 보관하지 않습니다.

3. 허브 → 서버 웹훅

결제·구독 상태 변화를 서명된 웹훅으로 보냅니다. 이행 확정은 웹훅 수신 후 재조회로만 합니다.

기본 주소는 https://pay.fuinsoft.com 이며, 모든 API 는 /v1 접두사를 씁니다.

가장 중요한 규칙
결제 완료 여부를 리다이렉트(successUrl)로 판단하지 마세요. 사용자가 탭을 닫으면 유실됩니다. 이행(라이선스 발급·크레딧 지급 등)은 웹훅 수신 또는 GET /v1/checkout-sessions/{id} 재조회 결과로만 확정합니다.

인증 · livemode

모든 요청에 테넌트 API 키를 Bearer 토큰으로 담습니다.

Authorization: Bearer fpay_test_xxxxxxxxxxxxxxxx

멱등성

생성 계열 5개 엔드포인트는 Idempotency-Key 헤더가 필수입니다(누락 시 400 idempotency_key_required).

동작결과
같은 키 + 같은 본문 재요청200 — 기존 리소스를 그대로 반환(재실행 없음)
같은 키 + 다른 본문409 idempotency_key_conflict
새 키201 — 새 리소스 생성
세션이 만료된 뒤에는 새 키를 쓰세요. 결제 세션 유효기간은 30분입니다. 멱등 재요청은 세션 상태와 무관하게 기존 세션을 돌려주므로, 만료된 세션의 키로 다시 요청하면 만료된 결제창이 계속 반환됩니다. 재시도는 반드시 Idempotency-Key 로 하세요.

에러 · 레이트리밋

에러 응답은 { "error": "<code>", "detail"?: ... } 형태입니다.

코드상태의미
missing_api_key401Authorization 헤더 없음
invalid_api_key401키가 유효하지 않거나 폐기됨
unauthenticated401테넌트 컨텍스트 없음
not_found404해당 테넌트·livemode 범위에 리소스 없음
invalid_body422detail(zod issues) 유무는 엔드포인트마다 다름

detail(zod 검증 상세)은 엔드포인트마다 다릅니다. invalid_bodydetail있는 곳: 결제 세션 생성 · 구독 생성. 없는 곳: 구독 변경(PATCH) · 카드 교체(billing-key) · 환불. 결제 목록의 쿼리 오류는 invalid_body 가 아니라 invalid_query(detail 포함)입니다. detail 유무에 의존하는 코드를 쓰지 마세요.

레이트리밋: 클라이언트 IP 당 분당 300회. 초과 시 429retry-after 헤더를 반환합니다. 같은 IP 에서 나가는 요청은 테넌트·엔드포인트 구분 없이 한 버킷을 공유합니다. retry-after 를 존중해 지수 백오프로 재시도하세요.

API 엔드포인트

메서드 / 경로용도요청성공 · 응답고유 에러
POST
/v1/plans
요금제 생성 — **test 모드 전용**(live 는 운영자 승인)
Idempotency-Key 필수
code · name · amount · interval · trialDays · metadata
code 는 소문자 시작·소문자/숫자/하이픈/언더스코어 2~64자. amount 는 0 이상 정수이며 상한 1,818,182원(PG 계약 건당 200만 — invoice 는 amount+VAT 가 승인 대상). trialDays 는 **카드 구독 전용**(invoice 는 체험을 무시하고 즉시 첫 청구). metadata 는 8KiB·키 50개·깊이 5 이내. tenant·livemode 는 API 키가 결정하므로 **본문에 넣으면 422**
201 / 200
id · code · name · amount · currency · interval · trialDays · status · metadata · version · createdAt
같은 Idempotency-Key + 같은 본문 = 200 재생(기존 요금제 반환·보관됐어도 200)
idempotency_key_required 400
Idempotency-Key 헤더 누락
idempotency_key_conflict 409
같은 키·다른 본문
live_plan_requires_approval 403
live 요금제 생성·변경·보관은 허브 운영자가 수행한다(조회는 허용)
plan_code_conflict 409
같은 모드에 이미 있는 code
invalid_plan_code 422
code 형식 위반
invalid_plan_amount 422
정수 아님·음수·상한 초과
invalid_plan_name 422
trim 후 1~200자 위반
plan_quota_exceeded 422
active 200개 또는 총 1,000개(보관 포함) 초과 — 기다려도 해소되지 않으므로 429 가 아니다
GET
/v1/plans
요금제 목록 — 자기 테넌트·키의 livemode(**live 조회 허용**) limit · cursor · status
쿼리스트링. limit 기본 50·최대 200(초과는 200 으로 클램프) · status=active|archived · cursor 는 이전 응답의 nextCursor 를 그대로 전달(최신순)
200
plans · nextCursor
nextCursor 가 null 이면 마지막 페이지
missing_api_key 401
Authorization 헤더 없음
invalid_api_key 401
키가 유효하지 않거나 폐기됨
unauthenticated 401
테넌트 컨텍스트 없음
not_found 404
해당 테넌트·livemode 범위에 리소스 없음
invalid_body 422
detail(zod issues) 유무는 엔드포인트마다 다름
GET
/v1/plans/{code}
요금제 단건 — **live 조회 허용**
경로 파라미터 code. 타 테넌트 요금제도 404(존재 여부를 노출하지 않는다)
200
id · code · name · amount · currency · interval · trialDays · status · metadata · version · createdAt
missing_api_key 401
Authorization 헤더 없음
invalid_api_key 401
키가 유효하지 않거나 폐기됨
unauthenticated 401
테넌트 컨텍스트 없음
not_found 404
해당 테넌트·livemode 범위에 리소스 없음
invalid_body 422
detail(zod issues) 유무는 엔드포인트마다 다름
plan_not_found 404
없음 또는 타 테넌트
PATCH
/v1/plans/{code}
요금제 변경 — **test 전용** · 변경은 신규 구독부터 적용 expectedVersion · name · amount · metadata
`expectedVersion` **필수**(낙관적 동시성 — 최근 조회한 version 을 그대로). 변경 가능 필드는 name·amount·metadata 뿐이고 **code·currency·interval·trialDays 는 변경 불가**(주기가 다르면 새 요금제를 만드세요). 변경 필드가 하나도 없으면 422 empty_patch
200
id · code · name · amount · currency · interval · trialDays · status · metadata · version · createdAt · appliesTo · unaffected
**금액 변경은 신규 구독부터 적용**된다 — 진행 중 구독과 열린 결제 세션은 개시 시점 금액을 유지한다(unaffected 로 그 수를 함께 반환).
missing_api_key 401
Authorization 헤더 없음
invalid_api_key 401
키가 유효하지 않거나 폐기됨
unauthenticated 401
테넌트 컨텍스트 없음
not_found 404
해당 테넌트·livemode 범위에 리소스 없음
invalid_body 422
detail(zod issues) 유무는 엔드포인트마다 다름
live_plan_requires_approval 403
live 요금제 생성·변경·보관은 허브 운영자가 수행한다(조회는 허용)
plan_not_found 404
없음 또는 타 테넌트
plan_version_conflict 409
expectedVersion 이 낡음
plan_not_active 409
보관된 요금제는 변경 불가
plan_immutable_field 422
code·currency·interval·trialDays 변경 시도
invalid_plan_amount 422
정수 아님·음수·상한 초과
invalid_plan_name 422
trim 후 1~200자 위반
empty_patch 422
변경할 필드 없음
POST
/v1/plans/{code}/archive
요금제 보관 — **test 전용** · 신규 구독만 차단 expectedVersion
`expectedVersion` 필수
200
id · code · name · amount · currency · interval · trialDays · status · metadata · version · createdAt
보관 후 **신규 구독은 404**. 진행 중 구독의 갱신·청구·카드 교체는 그대로 유지된다.
missing_api_key 401
Authorization 헤더 없음
invalid_api_key 401
키가 유효하지 않거나 폐기됨
unauthenticated 401
테넌트 컨텍스트 없음
not_found 404
해당 테넌트·livemode 범위에 리소스 없음
invalid_body 422
detail(zod issues) 유무는 엔드포인트마다 다름
live_plan_requires_approval 403
live 요금제 생성·변경·보관은 허브 운영자가 수행한다(조회는 허용)
plan_not_found 404
없음 또는 타 테넌트
plan_version_conflict 409
expectedVersion 이 낡음
plan_not_active 409
이미 보관됨
POST
/v1/checkout-sessions
일회성 결제 세션 생성
Idempotency-Key 필수
amount · orderName · customer · successUrl · failUrl · payMethod
amount=정수(원) · orderName≤200자 · customer{id, email?, name?≤30, phone?≤40}(전체 선택) · payMethod 는 card 만 지원. 실패 시 failUrl 에 reason=failed|error 만 덧붙는다(PG 원문 미전달 — 원문은 허브 서버 기록 전용)
201 / 200
id · status · type · amount · orderName · payMethod · checkoutUrl · expiresAt · createdAt
신규 201 · 동일 키+동일 본문 재요청은 200(기존 세션 반환)
idempotency_key_required 400
Idempotency-Key 헤더 누락
idempotency_key_conflict 409
같은 키·다른 본문
redirect_url_not_allowed 422
allowedRedirectOrigins 화이트리스트 밖
GET
/v1/checkout-sessions/:id
결제 세션 조회 — 이행 확정의 정본 경로
경로 파라미터 id
200
id · status · type · amount · orderName · payMethod · checkoutUrl · expiresAt · createdAt · paid · payment
paid=true 이면 결제 확정. payment 는 paid 결제만 채워진다(실패 건은 조회되지 않음)
공통 에러만
POST
/v1/subscriptions
구독 생성 — card(빌링키 발급 세션) 또는 invoice(정기 인보이스)
Idempotency-Key 필수
billingMode · planCode · customer · successUrl · failUrl
billingMode 미지정 = card. card 는 customer 의 email·name·phone 이 사실상 필수(카드등록 페이지 가드). invoice 는 successUrl/failUrl 대신 invoiceRecipient{brn, corpName, ceo, address?, email} 필수. 실패 시 failUrl 에 reason=failed|error 만 덧붙는다(PG 원문 미전달)
201 / 200
id · billingUrl
⚠️ card 응답의 id 는 **구독 ID 가 아니라 빌링키 발급 세션 ID** 다 — 이 값으로 GET /v1/subscriptions/{id} 를 호출하면 404. 구독 ID 는 카드 등록이 끝난 뒤 subscription.activated 이벤트의 data.subscriptionId 로 전달된다. · invoice → {subscriptionId, status, billingMode, nextBillingAt}(이쪽은 즉시 구독 ID)
idempotency_key_required 400
Idempotency-Key 헤더 누락
idempotency_key_conflict 409
같은 키·다른 본문
redirect_url_not_allowed 422
allowedRedirectOrigins 화이트리스트 밖
plan_not_found 404
요금제 미등록 또는 비활성
invoice_billing_gated 422
invoice 모드는 test 전용(live 차단)
invoice_free_plan_unsupported 422
0원 플랜은 invoice 모드 불가
GET
/v1/subscriptions/:id
구독 상태 조회
경로 파라미터 id
200
id · status · billingMode · planId · currentPeriodStart · currentPeriodEnd · nextBillingAt · cancelAtPeriodEnd · retryCount
기간 필드는 개시 전 null
공통 에러만
PATCH
/v1/subscriptions/:id
해지 예약(기간말) 또는 즉시 해지 cancelAtPeriodEnd · cancelImmediately
cancelAtPeriodEnd=false 로 예약 취소. invalid_body 에 detail 없음
200
ok
{ok:true}
공통 에러만
POST
/v1/subscriptions/:id/billing-key
카드 교체 세션 발급 — past_due 면 등록 성공 시 즉시 재청구·복귀
Idempotency-Key 필수
successUrl · failUrl
invalid_body 에 detail 없음. 실패 시 failUrl 에 reason=failed|error 만 덧붙는다(PG 원문 미전달 — 원문은 허브 서버 기록 전용)
201 / 200
billingUrl
{billingUrl}
idempotency_key_required 400
Idempotency-Key 헤더 누락
idempotency_key_conflict 409
같은 키·다른 본문
redirect_url_not_allowed 422
allowedRedirectOrigins 화이트리스트 밖
subscription_canceled 409
해지된 구독은 교체 불가
GET
/v1/payments
결제 목록(기간·상태 필터) status · from · to · limit
from/to=ISO8601(createdAt 기준) · limit 1~200(기본 50) · 최신순. 커서 페이지네이션 미제공
200
items
{items:[결제 객체]}
invalid_query 422
detail(zod issues) 포함
GET
/v1/payments/:id
결제 단건 조회 — 팬아웃 수신측 재조회 정본
경로 파라미터 id
200
id · type · status · amount · currency · paidAt · createdAt · failedReason · refund
refund.totalRefunded 는 완료된 환불 합계
공통 에러만
POST
/v1/payments/:id/refunds
환불(전액/부분)
Idempotency-Key 필수
amount · reason · refundAccount
amount 생략 = 잔여 전액 · 가상계좌 결제는 refundAccount{bank, number, holderName, holderPhoneNumber?} 필수 · invalid_body 에 detail 없음
201 / 200
id · amount · fullyRefunded
신규 201 · 동일 키 재요청 200(재실행 없이 기존 결과)
idempotency_key_required 400
Idempotency-Key 헤더 누락
idempotency_key_conflict 409
같은 키·다른 본문
not_refundable 409
결제 상태가 환불 가능 범위 밖(status 동봉)
refund_account_required 422
가상계좌 환불에 환불계좌 누락
invalid_refund_amount 422
잔여 초과(remaining 동봉)
pg_cancel_failed 502
PG 취소 실패 — 재시도 가능
refund_recorded_inconsistent 500
PG 취소는 성공했으나 기록 실패 — 운영 확인 필요
GET
/v1/invoices/:id
정기 인보이스 조회 — 청구·가상계좌 스냅샷·세금계산서 연계
경로 파라미터 id
200
id · status · amount · supplyAmount · taxAmount · dueAt · periodStart · periodEnd · paidAt · taxInvoiceId · vaBank · vaAccountNumber · vaHolder · vaDueAt · checkoutUrl
va* 는 가상계좌 미발급 시 null
공통 에러만
GET
/v1/tax-invoices/:id
세금계산서 조회 (발급은 운영 CLI 전용)
경로 파라미터 id
200
id · status · providerStatus · purposeType · supplyAmount · taxAmount · totalAmount · issuedAt · writeDate · portoneTaxInvoiceId · paymentId · invoiceId
공급자/공급받는자 상호·주소·대표자 등 PII 는 응답에 포함하지 않는다
공통 에러만
GET
/v1/analytics/summary
기간 결제 요약 — 금액은 원장 기준, 건수는 결제 기준 from · to
from·to 는 YYYY-MM-DD(KST) · 둘 다 선택이며 기본은 최근 30일(to=오늘, from=to−29일) · 양끝 포함 · 최대 1년. **알 수 없는 파라미터는 422 unknown_parameter**(다른 /v1 과 다른 정책)
200
period · livemode · payments
period 에 실제 적용값을 에코. refundRate 는 100 을 넘을 수 있다(기간 밖 결제가 기간 안에 환불된 경우) — 자르지 않는다
invalid_period 422
from/to 형식 오류 또는 from > to
period_too_long 422
조회 기간 1년 초과
interval_range_exceeded 422
interval=day 로 92일 초과 조회
unknown_parameter 422
알 수 없는 쿼리 파라미터 — **통계 계열만** 거부한다(다른 /v1 은 무시)
missing_api_key 401
Authorization 헤더 없음
invalid_api_key 401
키가 유효하지 않거나 폐기됨
unauthenticated 401
테넌트 컨텍스트 없음
not_found 404
해당 테넌트·livemode 범위에 리소스 없음
invalid_body 422
detail(zod issues) 유무는 엔드포인트마다 다름
GET
/v1/analytics/timeseries
결제 시계열 — 일·주·월 버킷 from · to · interval
interval=day|week|month(기본 day) · **interval 은 이 엔드포인트에서만 유효**하다 · day 는 최대 92일 · 주는 KST 월요일 시작
200
period · livemode · interval · points
빈 버킷도 0 으로 채우고 bucket 오름차순. 버킷은 자연 달력 기준이라 첫 라벨이 from 보다 앞설 수 있고, 그때 partial=true 이며 금액은 요청 경계로 잘린 값이다
invalid_period 422
from/to 형식 오류 또는 from > to
period_too_long 422
조회 기간 1년 초과
interval_range_exceeded 422
interval=day 로 92일 초과 조회
unknown_parameter 422
알 수 없는 쿼리 파라미터 — **통계 계열만** 거부한다(다른 /v1 은 무시)
missing_api_key 401
Authorization 헤더 없음
invalid_api_key 401
키가 유효하지 않거나 폐기됨
unauthenticated 401
테넌트 컨텍스트 없음
not_found 404
해당 테넌트·livemode 범위에 리소스 없음
invalid_body 422
detail(zod issues) 유무는 엔드포인트마다 다름
GET
/v1/analytics/subscriptions
구독 현황·MRR — counts·mrr 은 조회 시점 현재값 from · to
period 는 newInPeriod 에만 적용된다
200
period · livemode · asOf · counts · mrr · newInPeriod
counts·mrr 은 asOf 시점의 현재값(과거 시점 재현 불가 — 상태 이력 미보유). mrr.basis=billed_total(카드는 청구총액, invoice 는 공급가+VAT) · trialing·past_due 제외 · **해지율(churn)은 제공하지 않는다**(정확히 계산할 수 없어 근사치를 주지 않는다)
invalid_period 422
from/to 형식 오류 또는 from > to
period_too_long 422
조회 기간 1년 초과
interval_range_exceeded 422
interval=day 로 92일 초과 조회
unknown_parameter 422
알 수 없는 쿼리 파라미터 — **통계 계열만** 거부한다(다른 /v1 은 무시)
missing_api_key 401
Authorization 헤더 없음
invalid_api_key 401
키가 유효하지 않거나 폐기됨
unauthenticated 401
테넌트 컨텍스트 없음
not_found 404
해당 테넌트·livemode 범위에 리소스 없음
invalid_body 422
detail(zod issues) 유무는 엔드포인트마다 다름
GET
/v1/analytics/breakdown
결제 분해 — 유형·결제수단·요금제별 from · to
성공 결제(paid_at) 기준
200
period · livemode · byType · byMethod · byPlan
각 배열은 chargedAmount 내림차순(동률이면 key 오름차순) · 데이터가 없으면 빈 배열 · byPlan.key 는 planCode · 세션·구독 어디에도 귀속되지 않는 결제는 "unattributed" 로 모으며, 세 축의 합은 항상 같다
invalid_period 422
from/to 형식 오류 또는 from > to
period_too_long 422
조회 기간 1년 초과
interval_range_exceeded 422
interval=day 로 92일 초과 조회
unknown_parameter 422
알 수 없는 쿼리 파라미터 — **통계 계열만** 거부한다(다른 /v1 은 무시)
missing_api_key 401
Authorization 헤더 없음
invalid_api_key 401
키가 유효하지 않거나 폐기됨
unauthenticated 401
테넌트 컨텍스트 없음
not_found 404
해당 테넌트·livemode 범위에 리소스 없음
invalid_body 422
detail(zod issues) 유무는 엔드포인트마다 다름

타입 규약

주요 enum

필드
결제 statusready paid failed cancelled partial_cancelled
결제 typeone_time subscription_cycle
세션 statusopen awaiting_deposit completed expired
세션 typeone_time billing_key_issue invoice_payment
구독 statustrialing active past_due canceled suspended
구독 billingModecard invoice
인보이스 statusdraft issued paid overdue void
세금계산서 statusdraft requested issued cancelled failed
세금계산서 purposeTypeINVOICE RECEIPT NONE

필수 조건 · 제약

일회성 결제는 현재 카드(card)만 지원합니다.
요청 스키마는 easy_pay·virtual_account 값을 받아들이지만, 결제창에 필요한 추가 파라미터를 전달하지 않아 호출하면 결제창이 열리지 않습니다. 두 수단은 별도 구현·검증 후 제공될 예정이니 payMethod 는 생략하거나 card 로 두세요.
결제 금액에는 계약상 한도가 있습니다.
건당 승인한도 200만원(요금제 금액 상한 1,818,182원 — 정기 인보이스는 부가세 포함 총액이 승인 대상). 이와 별개로 월 누적한도가 있습니다: 정기과금 100만원 + 일반결제 100만원. 요금제 등록은 건당 한도만 보장하며, 월 누적 초과는 운영 모니터링·정산 대사로 관리됩니다 (한도 상향은 보증보험 증액이 선행되어야 합니다). 대량·고액 청구를 계획 중이라면 사전에 문의해 주세요.
리다이렉트 허용 오리진을 등록하기 전에는 결제 세션을 만들 수 없습니다.
허용 목록이 비어 있으면 모든 리다이렉트가 거부되어 POST /v1/checkout-sessions 가 곧바로 422 redirect_url_not_allowed 를 반환합니다(비어 있음 = 전체 허용이 아닙니다). 연동 시작 전에 successUrl·failUrl 에 쓸 모든 오리진을 운영자에게 전달해 등록하세요.
결제가 실패하면 failUrlreason 이 붙습니다.
허브는 실패 화면을 먼저 보여준 뒤 사용자가 돌아가기를 누르면 failUrl 로 이동시킵니다. 이때 쿼리에 reason=failed(결제가 완료되지 않음) 또는 reason=error(결제창을 열지 못함) 가 실립니다. 결제대행사 원문 오류 메시지는 전달하지 않습니다 — 고객에게 보일 글이 아니고, 허브 설정 문제를 고객 잘못처럼 보이게 하기 때문입니다(원문은 허브 서버에만 기록됩니다).
서비스 화면에서 reason 이 있으면 실패 안내를 표시하세요. 성공 여부는 여전히 successUrl 이 아니라 웹훅·재조회로 확정합니다.
카드 구독 생성 응답의 id 는 구독 ID 가 아닙니다.
POST /v1/subscriptions(card)가 돌려주는 id빌링키 발급 세션의 ID 이고, 구독은 카드 등록이 끝난 뒤에 만들어집니다. 이 값으로 GET /v1/subscriptions/{id} 를 호출하면 404 not_found 입니다. 구독 ID 는 subscription.activated 이벤트의 data.subscriptionId 로 전달되므로 그 값을 저장해 조회·해지·카드 교체에 사용하세요. (billingMode: invoice 는 예외로 생성 응답이 곧바로 subscriptionId 를 줍니다.)
⚠️ 그 이벤트를 끝내 받지 못하면(재시도 소진 후 중단) 구독 ID 를 알 방법이 없습니다. 웹훅 수신이 확실히 동작하는지 test 모드에서 먼저 검증하시고, 놓친 경우에는 운영자에게 문의해 주세요 — 해당 이벤트 재발송으로 복구합니다. (외부 고객 ID 로 구독을 찾는 API 는 아직 제공되지 않습니다.)
카드 구독 생성 시 customer.email·customer.name·customer.phone 을 반드시 포함하세요.
API 스키마상 선택값이라 세 값이 없어도 201billingUrl 이 돌아오지만, 그 URL 을 연 카드 등록 페이지가 세 값을 모두 요구해 오류 화면으로 막힙니다. 구독 개시가 불가능해집니다.

결제 · 구독 플로우

일회성 결제

세션 생성 → 결제창 → 웹훅 또는 재조회로 확정.

# 1) 결제 세션 생성 (서버 → 허브)
curl -X POST https://pay.fuinsoft.com/v1/checkout-sessions \
  -H "Authorization: Bearer fpay_test_xxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: order-2026-0823-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 33000,
    "orderName": "MassCurve 단건 이용료",
    "customer": { "id": "user_1024", "email": "buyer@example.com" },
    "successUrl": "https://myapp.example.com/pay/done",
    "failUrl": "https://myapp.example.com/pay/fail"
  }'

# → 201 { "id": "...", "checkoutUrl": "https://pay.fuinsoft.com/checkout/<token>", ... }
# 2) 사용자를 checkoutUrl 로 보낸다(결제창)
# 3) 이행 확정은 successUrl 리다이렉트가 아니라 웹훅 수신 또는 아래 재조회로만 한다
curl https://pay.fuinsoft.com/v1/checkout-sessions/<id> \
  -H "Authorization: Bearer fpay_test_xxxxxxxxxxxxxxxx"
# → { "paid": true, "payment": { "id": "...", "status": "paid", "paidAt": "..." } }

카드 구독

구독 생성 → 카드 등록(빌링키 발급) → 개시 → 주기마다 자동 청구.

# 카드 구독 — customer 의 email·name·phone 을 반드시 포함한다(카드등록 페이지가 요구)
curl -X POST https://pay.fuinsoft.com/v1/subscriptions \
  -H "Authorization: Bearer fpay_test_xxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: sub-user_1024-pro" \
  -H "Content-Type: application/json" \
  -d '{
    "planCode": "pro-monthly",
    "customer": {
      "id": "user_1024",
      "email": "buyer@example.com",
      "name": "홍길동",
      "phone": "01012345678"
    },
    "successUrl": "https://myapp.example.com/billing/done",
    "failUrl": "https://myapp.example.com/billing/fail"
  }'

# → 201 { "id": "...", "billingUrl": "https://pay.fuinsoft.com/billing/<token>" }
#
# ⚠️ 이 응답의 "id" 는 **구독 ID 가 아니다** — 빌링키 발급 세션의 ID 다.
#    이 값으로 GET /v1/subscriptions/{id} 를 호출하면 404 가 난다.
#    구독은 카드 등록이 끝난 시점에 만들어지고, 그 구독 ID 는
#    subscription.activated 이벤트의 data.subscriptionId 로 전달된다. 그 값을 저장해서
#    이후 조회(GET)·해지(PATCH)·카드 교체(billing-key)에 사용한다.
#
# 사용자를 billingUrl 로 보내 카드를 등록시키면 구독이 개시되고 subscription.activated 가 발송된다.

결제 실패와 연체(dunning)

해지

PATCH /v1/subscriptions/{id}cancelAtPeriodEnd: true(기간말 해지 예약) 또는 cancelImmediately: true(즉시 해지)를 보냅니다. 예약은 cancelAtPeriodEnd: false 로 취소할 수 있습니다.

웹훅(팬아웃)

허브가 테넌트의 webhook_urlPOST 합니다. 전송 헤더는 다음과 같습니다.

헤더
content-typeapplication/json
X-Pay-Event-Id이벤트 고유 id — 본문 id 와 같아야 합니다
X-Pay-Livemode"true" / "false" — 본문 livemode 와 같아야 합니다
X-Pay-Signaturet=<unix초>,v1=<HMAC-SHA256(secret, "t.본문원문") hex>

본문(envelope)은 다음 구조입니다.

{
  "id": "01J...",             // 이벤트 id — 멱등 키로 사용
  "type": "payment.paid",
  "livemode": false,
  "seq": 42,                   // 테넌트별 단조 증가 — 순서 "힌트"일 뿐
  "occurredAt": "2026-08-23T04:05:06.000Z",
  "data": { }                  // 이벤트별 페이로드(아래 카탈로그)
}

검증 코드 (Node / TypeScript)

// Node 22 / TypeScript — 팬아웃 웹훅 수신 (Fastify 예시)
import { createHmac, timingSafeEqual } from 'node:crypto'

const SECRET = process.env.FUINPAY_WEBHOOK_SECRET!   // 허브에서 발급받은 테넌트 webhook_secret
const BASE = 'https://pay.fuinsoft.com'

// ⚠️ 원문(raw body)으로 검증해야 한다. JSON 을 파싱했다가 다시 문자열로 만들면 서명이 어긋난다.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => {
  req.rawBody = body as string
  done(null, JSON.parse(body as string))
})

/** 우리가 실제로 처리하는 이벤트만 나열한다. 나머지는 "의도적 무시"로 기록만 한다. */
const HANDLED = new Set(['payment.paid', 'refund.completed', 'subscription.activated'])

app.post('/webhooks/fuinpay', async (req, reply) => {
  const header = req.headers['x-pay-signature'] as string | undefined
  const eventId = req.headers['x-pay-event-id'] as string | undefined
  const livemodeHeader = req.headers['x-pay-livemode'] === 'true'

  // ── 검증 실패는 전부 비-2xx. 200 을 주면 허브가 delivered 로 확정해 이벤트가 영구 유실된다. ──
  if (!header || !eventId) return reply.code(400).send()

  // 1) 서명 파싱 — t=<unix>,v1=<hex>
  const parts = new Map(header.split(',').map((kv) => {
    const i = kv.indexOf('=')
    return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()] as const
  }))
  const t = Number(parts.get('t'))
  const v1 = parts.get('v1')
  if (!Number.isFinite(t) || !v1) return reply.code(400).send()

  // 2) 리플레이 방지 — 5분 skew
  if (Math.abs(Date.now() - t * 1000) > 5 * 60 * 1000) return reply.code(400).send()

  // 3) 상수시간 비교
  const expected = createHmac('sha256', SECRET).update(t + '.' + req.rawBody).digest('hex')
  const a = Buffer.from(expected), b = Buffer.from(v1)
  if (a.length !== b.length || !timingSafeEqual(a, b)) return reply.code(400).send()

  // 4) 헤더와 본문 일치 확인(변조 신호)
  const ev = req.body as { id: string; type: string; livemode: boolean; data: Record<string, any> }
  if (ev.id !== eventId || ev.livemode !== livemodeHeader) return reply.code(400).send()

  // 5) 멱등 — 같은 id 가 **동시에** 중복 도착할 수 있다. "조회 후 처리" 는 경합에 안전하지 않으므로
  //    UNIQUE(event_id) inbox 에 원자적으로 선점한다. 이때 **반드시 lease(만료)** 를 함께 둔다 —
  //    선점 직후 프로세스가 죽으면 lease 없이는 그 이벤트가 영원히 'processing' 으로 고착되고,
  //    이후 재시도가 전부 409 로 돌아가 결국 허브에서 dead 처리된다.
  //      INSERT INTO webhook_inbox (event_id, status, owner, lease_until)
  //        VALUES ($1, 'processing', $2, now() + interval '5 minutes')
  //      ON CONFLICT (event_id) DO UPDATE
  //        SET owner = excluded.owner, lease_until = excluded.lease_until
  //        WHERE webhook_inbox.status = 'processing'
  //          AND webhook_inbox.lease_until < now()        -- 만료된 선점만 회수(CAS)
  //      RETURNING status
  //    → 행이 반환되면 내가 소유자('claimed'). 아니면 기존 status 를 읽어 판단한다.
  const owner = crypto.randomUUID()                        // fencing token
  const claim = await claimEvent(ev.id, owner)             // 'claimed' | 'processing' | 'completed'
  if (claim === 'completed') return reply.code(200).send()   // 이미 끝남 → 조용히 성공
  if (claim === 'processing') return reply.code(409).send()  // 다른 워커가 lease 보유 중 → 재시도 받기
  // ⚠️ 완료·해제는 **현재 owner 일 때만** 수행한다(WHERE owner = $2). 그래야 lease 를 회수해 간
  //    다른 워커의 처리 결과를 뒤늦게 깨어난 이전 워커가 덮어쓰지 않는다.

  // 6) 처리하지 않는 이벤트도 **완료로 전이**한 뒤 200. (비-2xx 를 주면 33시간 재시도 후 dead 가 되어
  //    운영 알림만 쌓인다. 단 "처리 대상인데 실패한" 경우와 반드시 구분해야 한다.)
  //    ⚠️ 여기서 completed 로 넘기지 않고 200 만 반환하면 inbox 행이 'processing' 으로 남아
  //       같은 id 의 후속 중복이 영원히 409 가 된다("완료된 중복만 200" 규칙과도 모순).
  if (!HANDLED.has(ev.type)) {
    try {
      // 기록과 완료 전이는 한 트랜잭션으로 묶는 것이 안전하다.
      await logIgnoredEventAndComplete(ev, owner)
    } catch {
      await releaseEvent(ev.id, owner)                      // 기록 실패 → 선점 풀고 재시도 받기
      return reply.code(500).send()
    }
    return reply.code(200).send()
  }

  // 7) 상태는 반드시 재조회로 확정한다. 순서는 보장되지 않는다.
  //    이벤트의 livemode 와 같은 모드의 키를 써야 한다(다르면 404).
  const key = ev.livemode ? process.env.FUINPAY_LIVE_KEY! : process.env.FUINPAY_TEST_KEY!
  const auth = { authorization: 'Bearer ' + key }

  // 아래 실패 경로에서는 선점을 반드시 풀어야 재시도가 실제로 처리된다.
  const giveUp = async (code: number) => {
    // DELETE FROM webhook_inbox WHERE event_id = $1 AND owner = $2
    await releaseEvent(ev.id, owner)
    return reply.code(code).send()
  }

  // ⚠️ lease 는 만료될 수 있다. 재조회·처리에 시간이 걸린 사이 다른 워커가 회수했을 수 있으므로
  //    **부수효과 직전에 소유권을 재확인**한다(UPDATE … SET lease_until = now() + interval '5 minutes'
  //    WHERE event_id = $1 AND owner = $2 AND status = 'processing' RETURNING 1).
  //    0행이면 내 것이 아니므로 아무것도 실행하지 않고 물러난다 — 이중 이행 방지의 핵심이다.
  const stillMine = async (): Promise<boolean> => renewLease(ev.id, owner)

  try {
    if (ev.type === 'payment.paid' || ev.type === 'refund.completed') {
      const res = await fetch(BASE + '/v1/payments/' + ev.data.paymentId, { headers: auth })
      if (!res.ok) return giveUp(502)                     // 재조회 실패 → 재시도 받기
      const payment = await res.json()
      if (!(await stillMine())) return reply.code(409).send()   // lease 상실 → 실행하지 않고 물러남
      // ⚠️ 가능한 status 를 전부 처리한다. 빠뜨린 상태를 조용히 넘기면 그 이벤트는 유실된다.
      // 부수효과 자체도 event id 로 멱등화한다(재시도가 두 번 적용되지 않도록).
      // ⚠️ lease 재확인은 창을 좁힐 뿐 완전한 배제가 아니다. 확실한 방지는 **부수효과 저장소에서**
      //    event_id UNIQUE 로 한 번 더 막는 것이다 — 아래 함수들은 그렇게 구현한다.
      switch (payment.status) {
        case 'paid':               await fulfill(payment, ev.id); break
        case 'partial_cancelled':  await syncPartialRefund(payment, ev.id); break  // 부분 환불
        case 'cancelled':          await revoke(payment, ev.id); break             // 전액 환불
        case 'failed':             await markFailed(payment, ev.id); break
        case 'ready':              return giveUp(409)            // 아직 확정 전 → 재시도
        default:                   return giveUp(500)            // 모르는 상태 → 재시도+점검
      }
    } else if (ev.type === 'subscription.activated') {
      const res = await fetch(BASE + '/v1/subscriptions/' + ev.data.subscriptionId, { headers: auth })
      if (!res.ok) return giveUp(502)
      const sub = await res.json()
      if (!(await stillMine())) return reply.code(409).send()   // lease 상실 → 실행하지 않고 물러남
      switch (sub.status) {
        case 'active':
        case 'trialing':           await grantAccess(sub, ev.id); break  // 체험도 접근 부여 대상이다
        case 'past_due':
          // 연체 중 이용을 막을지 유예할지는 **각 서비스가 정할 정책**이며 허브는 관여하지 않는다.
          // (순서 무보장이라, 늦게 도착한 activated 를 재조회하면 이미 past_due 일 수 있다.)
          // 정책을 결정하지 못하면 완료 처리하지 말고 재시도를 받아야 한다.
          if (!(await applyPastDuePolicy(sub, ev.id))) return giveUp(500)
          break
        case 'suspended':
        case 'canceled':           await revokeAccess(sub, ev.id); break
        default:                   return giveUp(500)
      }
    }
  } catch {
    return giveUp(502)                                     // 처리 실패 → 재시도 받기
  }

  // 8) 여기까지 왔을 때만 completed 로 확정한다(내가 소유자일 때만).
  //      UPDATE webhook_inbox SET status = 'completed' WHERE event_id = $1 AND owner = $2 RETURNING 1
  const completed = await completeEvent(ev.id, owner)
  if (!completed) {
    // lease 를 뺏긴 뒤 완료했다는 뜻이다. **상태를 확인하지 않고 200 을 주면 안 된다** —
    // 다른 워커가 아직 처리 중일 수 있고, 그때 200 을 주면 허브가 전달 완료로 확정해 유실된다.
    const status = await readEventStatus(ev.id)   // 'completed' | 'processing' | null
    if (status !== 'completed') return reply.code(409).send()   // 아직 미완 → 재시도 받기
  }
  return reply.code(200).send()   // 5초 안에 2xx — 무거운 후속 작업은 큐로
})

수신측 필수 계약

① 전달은 "중복 가능 + 최종 미전달 가능" 입니다.
같은 id 의 이벤트가 두 번 이상 올 수 있고, 반대로 끝내 오지 않을 수도 있습니다 (재시도 6회를 모두 소진하면 전송이 중단되고 운영자 수동 복구 대상이 됩니다). 따라서 id 기준 멱등 처리와 함께 정기적인 REST 재조회(대사)로 상태를 보정하세요. 웹훅만 믿고 상태를 확정하면 안 됩니다.
단, REST 대사는 이미 ID 를 확보한 리소스에만 가능합니다. 조회 API 는 모두 리소스 ID 를 받고, 외부 고객 ID·세션 ID 로 구독을 찾는 API 는 제공되지 않습니다. 결제(checkoutUrl 발급 시 세션 ID 보유)와 인보이스는 대사가 가능하지만, 카드 구독은 구독 ID 를 subscription.activated 이벤트로만 알 수 있어 그 이벤트를 끝내 받지 못하면 스스로 대사할 수단이 없습니다(아래 참조).
② 같은 이벤트가 동시에 도착할 수 있습니다 — 조회 후 처리(check-then-act)는 안전하지 않습니다.
"처리했는지 조회 → 처리 → 완료 기록" 순서는 동일 id 가 동시에 두 번 오면 양쪽 다 미처리로 판단해 이행을 중복 실행합니다. UNIQUE(event_id) inbox 에 원자적으로 선점하고 processing/completed 를 구분하세요. 완료된 중복만 200, 처리 중 중복은 비-2xx로 돌려보내 재시도를 받게 합니다. 부수효과 자체도 event_id 로 멱등화하는 것이 안전합니다.
선점에는 반드시 만료(lease)를 두세요. 선점 직후 프로세스가 죽으면 그 이벤트는 영원히 processing 으로 남고, 이후 재시도가 모두 비-2xx 가 되어 결국 dead 로 유실됩니다. lease_until 이 지난 선점만 회수하도록 ON CONFLICT … DO UPDATE … WHERE lease_until < now() 로 재선점하고, 완료·해제는 현재 소유자(owner)일 때만 수행하세요.
처리하지 않기로 한 이벤트도 완료로 전이해야 합니다 — 200 만 반환하고 processing 으로 두면 같은 id 의 후속 중복이 영구히 막힙니다.
lease 재확인만으로는 이중 이행을 완전히 막지 못합니다. 재조회 도중 lease 가 만료돼 다른 워커가 회수했을 수 있으므로 부수효과 직전에 소유권을 재확인하고, 그와 별개로 부수효과 저장소에서도 event_id UNIQUE 로 한 번 더 막으세요. 완료 갱신이 0행이면(=lease 상실) 상태를 확인해 실제 completed 일 때만 200 을 반환하세요 — 확인 없이 200 을 주면 아직 처리 중인 이벤트가 전달 완료로 확정되어 유실됩니다.
③ 검증·처리에 실패하면 반드시 2xx 가 아닌 응답을 반환하세요.
허브는 2xx 를 받는 즉시 전송 완료로 확정하고 비-2xx 만 재시도합니다. 서명이 맞지 않는데 200 을 반환하면 그 이벤트는 영구히 유실됩니다(secret 회전 구간에서 특히 위험).
④ 재조회는 이벤트와 같은 모드의 키로 하세요.
livemode: true 이벤트는 live 키로, false 는 test 키로 조회합니다. 어긋나면 404 not_found 입니다.

webhook secret 회전

무중단 회전은 지원되지 않습니다. 새 secret 은 회전 실행 시점에 생성되어 즉시 기존 값을 대체한 뒤 1회 표시됩니다. 즉 수신측이 미리 새 값을 알 수 없어 신·구 병행 검증을 준비해 둘 수 없습니다.
절차: ① 회전 실행 → 새 secret 확인 → ② 즉시 수신측 검증 키 교체·배포 → ③ 그 사이 도착해 서명 실패한 이벤트는 비-2xx 를 반환했다면 재시도로 복구됩니다. 트래픽이 적은 시간대에 수행하시고, 재시도 창(약 33시간)을 넘기지 마세요.

이벤트 카탈로그

현재 발송되는 이벤트는 다음 15종입니다.

type발생 조건data재조회중복 억제
payment.paid 일회성 결제 확정(포트원 REST 재조회 대조 후) paymentId · checkoutSessionId · portonePaymentId · amount GET /v1/payments/{paymentId} 없음 — 수신측 id 멱등 필수
payment.failed 일회성·구독사이클 결제 실패 checkoutSessionId · portonePaymentId ⚠️ 재조회로 확정 불가 — 세션 status(open 유지=재시도 가능)로만 판단 없음 — 수신측 id 멱등 필수
refund.completed 환불 성공(전액/부분) paymentId · refundId · amount · fullyRefunded GET /v1/payments/{paymentId} 없음 — 수신측 id 멱등 필수
subscription.activated 최초 청구 성공 / trial·free 구독 개시 체험·무료 개시
subscriptionId · trial
또는
유료 첫 청구
subscriptionId · periodStart · periodEnd
GET /v1/subscriptions/{subscriptionId} 없음 — 수신측 id 멱등 필수
subscription.renewed 갱신 청구 성공(직전 status ≠ past_due) 카드 갱신
subscriptionId · periodStart · periodEnd
또는
invoice 입금 확정
invoiceId · subscriptionId · paymentId · periodStart · periodEnd · amount
GET /v1/subscriptions/{subscriptionId} invoice 모드만 있음(invoice:{invoiceId}:renewed) · 카드 모드 없음
subscription.recovered past_due 상태에서 청구 성공(연체 복귀) subscriptionId · periodStart · periodEnd GET /v1/subscriptions/{subscriptionId} 없음 — 수신측 id 멱등 필수
subscription.payment_failed 청구 실패 — dunning 재시도 예약(원 due 기준 +1/+3/+5일) subscriptionId · retryCount GET /v1/subscriptions/{subscriptionId} 없음 — 수신측 id 멱등 필수
subscription.canceled 해지(즉시·기간말·dunning 소진·정지 정규화) subscriptionId · reason GET /v1/subscriptions/{subscriptionId} 즉시해지·정지정규화·invoice 기간말 = 있음(subscription:{id}:canceled) · 카드 기간말·dunning 소진 = 없음
subscription.suspended 인보이스 미납 누적 → 구독 정지 정상
invoiceId · subscriptionId · amount · supplyAmount · taxAmount · dueAt · periodStart · periodEnd · vaBank · vaAccountNumber · vaHolder · vaDueAt · checkoutUrl · requeryUrl · paidAt
또는
축약 — 인보이스 조회 실패 시
subscriptionId · invoiceId
GET /v1/subscriptions/{subscriptionId} 있음(invoice:{invoiceId}:suspended)
billing_key.deleted 빌링키 삭제(해지 등) — 카드 정보 참조 해제 subscriptionId GET /v1/subscriptions/{subscriptionId} 없음 — 수신측 id 멱등 필수
invoice.issued 정기 인보이스 발행(가상계좌 발급 완료) invoiceId · subscriptionId · amount · supplyAmount · taxAmount · dueAt · periodStart · periodEnd · vaBank · vaAccountNumber · vaHolder · vaDueAt · checkoutUrl · requeryUrl · paidAt GET /v1/invoices/{invoiceId} 있음(invoice:{invoiceId}:issued)
invoice.paid 인보이스 입금 확정 정상
invoiceId · subscriptionId · amount · supplyAmount · taxAmount · dueAt · periodStart · periodEnd · vaBank · vaAccountNumber · vaHolder · vaDueAt · checkoutUrl · requeryUrl · paidAt · paymentId
또는
축약 — 인보이스 조회 실패 시
invoiceId · subscriptionId · paymentId · amount · periodStart · periodEnd · paidAt · requeryUrl
GET /v1/invoices/{invoiceId} 있음(invoice:{invoiceId}:paid)
invoice.overdue 납기(dueAt) 초과 정상
invoiceId · subscriptionId · amount · supplyAmount · taxAmount · dueAt · periodStart · periodEnd · vaBank · vaAccountNumber · vaHolder · vaDueAt · checkoutUrl · requeryUrl · paidAt
또는
축약 — 인보이스 조회 실패 시
invoiceId · subscriptionId · dueAt
GET /v1/invoices/{invoiceId} 있음(invoice:{invoiceId}:overdue)
invoice.void 인보이스 무효화(구독 해지 등) 정상
invoiceId · subscriptionId · amount · supplyAmount · taxAmount · dueAt · periodStart · periodEnd · vaBank · vaAccountNumber · vaHolder · vaDueAt · checkoutUrl · requeryUrl · paidAt
또는
축약 — 인보이스 조회 실패 시
invoiceId · subscriptionId
GET /v1/invoices/{invoiceId} 있음(invoice:{invoiceId}:void)
invoice.paid_after_void 무효화 이후 입금 도착 — 운영 확인 필요(환불 대상일 수 있음) 정상
invoiceId · subscriptionId · amount · supplyAmount · taxAmount · dueAt · periodStart · periodEnd · vaBank · vaAccountNumber · vaHolder · vaDueAt · checkoutUrl · requeryUrl · paidAt · paymentId
또는
축약 — 인보이스 조회 실패 시
invoiceId · subscriptionId · paymentId · amount
GET /v1/invoices/{invoiceId} 있음(invoice:{invoiceId}:paid_after_void)
일부 이벤트는 payload 형태가 하나가 아닙니다.
위 표에서 "또는" 으로 나뉜 이벤트는 상황에 따라 필드 구성이 다른 payload 가 옵니다 (예: 카드 갱신과 인보이스 입금의 subscription.renewed, 인보이스 조회에 실패한 축약 형태). 수신 코드는 없을 수 있는 필드를 옵셔널로 다루고, 필요한 값은 재조회로 채우세요. 특정 필드의 존재를 전제로 구조 분해하면 축약 payload 에서 깨집니다.

중복 억제 열은 허브 내부에서 같은 이벤트를 중복 생성하지 않기 위한 장치이며, 전달이 한 번만 된다는 보장이 아닙니다. 수신측 멱등 처리는 어느 이벤트에서든 필수입니다.

온보딩 절차

테넌트 등록은 운영자가 직접 진행합니다(셀프 가입은 제공하지 않습니다).

  1. 연동 요청ydnam@fuinsoft.com 로 서비스명·도메인·결제 형태(일회성/구독)를 알려주세요.
  2. 테넌트 등록 · API 키 발급 — test 키를 먼저 발급합니다(1회 표시).
  3. 설정 등록 — 웹훅 수신 URL, 리다이렉트 허용 오리진(successUrl·failUrl 도메인)을 전달해 주세요. 오리진이 등록되지 않으면 결제 세션 생성이 전부 422 로 막힙니다.
  4. 요금제 등록test 모드는 POST /v1/plans 로 직접 등록·변경·보관할 수 있습니다. live 요금제만 운영자 승인 대상입니다 — 상품명·금액·주기를 전달해 주시면 운영자가 등록 후 planCode 를 알려드립니다 (실청구 금액을 단독으로 정하지 못하게 하는 게이트입니다 — 조회(GET /v1/plans)는 두 모드 모두 가능). 미등록 상태로 구독을 생성하면 404 plan_not_found 입니다.
    · 금액 변경은 신규 구독부터 적용됩니다 — 진행 중 구독과 열린 결제 세션은 개시 시점 금액을 그대로 유지합니다.
    · interval·trialDays 는 변경할 수 없습니다 — 주기나 체험기간이 다르면 새 요금제를 만드세요.
    · 체험기간(trial)은 카드 구독에만 적용됩니다 — billingMode: invoice 구독은 체험을 무시하고 즉시 첫 청구합니다.
    · 요금제 금액 상한은 1,818,182원입니다(결제대행사 계약상 건당 승인한도 200만원 · 정기 인보이스는 부가세를 더한 금액이 승인 대상이라 공급가 상한이 더 낮습니다).
  5. test 모드 연동·검증 — 결제·웹훅·재조회까지 확인합니다.
  6. live 전환 — 검증 완료 후 live 키를 발급합니다.

현재 미제공

설계에는 있으나 아직 제공되지 않는 항목입니다. 연동 설계 시 참고하세요.

항목현재 상태
일회성 결제의 easy_pay · virtual_account 결제수단결제창에 필수 파라미터(easyPay.easyPayProvider / virtualAccount)를 전달하지 않아 호출 시 실패한다. 현재 지원 수단은 card 뿐이다.
GET /v1/customers/{external_id}서비스측 id 로 고객·구독을 조회하는 엔드포인트는 아직 등록되지 않았다.
세금계산서 발급 API발급은 운영 CLI 전용이며 HTTP 엔드포인트가 없다. 조회만 제공한다.
invoice(정기 인보이스) 구독의 live 모드계약 게이트로 test 모드에서만 개시할 수 있다(live 요청은 invoice_billing_gated).
live 요금제 셀프 등록·변경test 모드 요금제는 `/v1/plans` 로 직접 만들고 바꿀 수 있다. **live 요금제만** 운영자 승인 대상 — 상품명·금액·주기를 전달하면 운영자가 CLI·운영 콘솔에서 등록하고 planCode 를 전달한다(실청구 금액을 테넌트가 단독 결정하지 못하게 하는 게이트).
웹훅 secret 의 무중단 회전신·구 secret 병행 유예 검증이 없어 회전 직후 짧은 서명 실패 구간이 발생한다(재시도로 복구).

이 문서는 구현 코드에서 직접 생성됩니다 · 기준 a1b06c9 · 갱신 2026-08-23 · 문의 ydnam@fuinsoft.com