개발자 문서 — 결제 연동 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
- 키는 test / live 두 종류이며, 접두사로 구분됩니다(
fpay_test_·fpay_live_). - 키의 모드가 생성되는 모든 리소스에 전파됩니다. test 키로 만든 결제는 test 원장에만 기록되고 실 매출에 섞이지 않습니다.
- 조회도 같은 모드로만 됩니다. live 이벤트를 test 키로 재조회하면
404 not_found가 납니다 — 리소스가 없는 게 아니라 키 모드가 틀린 것입니다. - 키는 발급 시 1회만 표시되며 다시 확인할 수 없습니다. 분실 시 재발급합니다.
- 키는 서버 환경변수로 보관하고 브라우저·모바일 앱에 넣지 않습니다.
멱등성
생성 계열 5개 엔드포인트는 Idempotency-Key 헤더가 필수입니다(누락 시 400 idempotency_key_required).
| 동작 | 결과 |
|---|---|
| 같은 키 + 같은 본문 재요청 | 200 — 기존 리소스를 그대로 반환(재실행 없음) |
| 같은 키 + 다른 본문 | 409 idempotency_key_conflict |
| 새 키 | 201 — 새 리소스 생성 |
- 키 범위는 (테넌트, 모드, 키) 입니다. test·live 는 서로 간섭하지 않습니다.
- 구독은 card·invoice 저장소가 분리돼 있어 모드를 바꿔 같은 키를 재사용해도 409 입니다.
- 주문번호처럼 업무적으로 유일한 값을 키로 쓰는 것을 권장합니다.
Idempotency-Key 로 하세요.에러 · 레이트리밋
에러 응답은 { "error": "<code>", "detail"?: ... } 형태입니다.
| 코드 | 상태 | 의미 |
|---|---|---|
missing_api_key | 401 | Authorization 헤더 없음 |
invalid_api_key | 401 | 키가 유효하지 않거나 폐기됨 |
unauthenticated | 401 | 테넌트 컨텍스트 없음 |
not_found | 404 | 해당 테넌트·livemode 범위에 리소스 없음 |
invalid_body | 422 | detail(zod issues) 유무는 엔드포인트마다 다름 |
detail(zod 검증 상세)은 엔드포인트마다 다릅니다.
invalid_body 에 detail 이 있는 곳: 결제 세션 생성 · 구독 생성.
없는 곳: 구독 변경(PATCH) · 카드 교체(billing-key) · 환불.
결제 목록의 쿼리 오류는 invalid_body 가 아니라 invalid_query(detail 포함)입니다.
detail 유무에 의존하는 코드를 쓰지 마세요.
레이트리밋: 클라이언트 IP 당 분당 300회. 초과 시 429 와 retry-after 헤더를 반환합니다.
같은 IP 에서 나가는 요청은 테넌트·엔드포인트 구분 없이 한 버킷을 공유합니다. retry-after 를 존중해 지수 백오프로 재시도하세요.
API 엔드포인트
| 메서드 / 경로 | 용도 | 요청 | 성공 · 응답 | 고유 에러 |
|---|---|---|---|---|
POST/v1/plans |
요금제 생성 — **test 모드 전용**(live 는 운영자 승인) Idempotency-Key 필수 |
code · name · amount · interval · trialDays · metadatacode 는 소문자 시작·소문자/숫자/하이픈/언더스코어 2~64자. amount 는 0 이상 정수이며 상한 1,818,182원(PG 계약 건당 200만 — invoice 는 amount+VAT 가 승인 대상). trialDays 는 **카드 구독 전용**(invoice 는 체험을 무시하고 즉시 첫 청구). metadata 는 8KiB·키 50개·깊이 5 이내. tenant·livemode 는 API 키가 결정하므로 **본문에 넣으면 422** |
201 / 200id · code · name · amount · currency · interval · trialDays · status · metadata · version · createdAt같은 Idempotency-Key + 같은 본문 = 200 재생(기존 요금제 반환·보관됐어도 200) |
idempotency_key_required 400Idempotency-Key 헤더 누락 idempotency_key_conflict 409같은 키·다른 본문 live_plan_requires_approval 403live 요금제 생성·변경·보관은 허브 운영자가 수행한다(조회는 허용) plan_code_conflict 409같은 모드에 이미 있는 code invalid_plan_code 422code 형식 위반 invalid_plan_amount 422정수 아님·음수·상한 초과 invalid_plan_name 422trim 후 1~200자 위반 plan_quota_exceeded 422active 200개 또는 총 1,000개(보관 포함) 초과 — 기다려도 해소되지 않으므로 429 가 아니다 |
GET/v1/plans |
요금제 목록 — 자기 테넌트·키의 livemode(**live 조회 허용**) | limit · cursor · status쿼리스트링. limit 기본 50·최대 200(초과는 200 으로 클램프) · status=active|archived · cursor 는 이전 응답의 nextCursor 를 그대로 전달(최신순) |
200plans · nextCursornextCursor 가 null 이면 마지막 페이지 |
missing_api_key 401Authorization 헤더 없음 invalid_api_key 401키가 유효하지 않거나 폐기됨 unauthenticated 401테넌트 컨텍스트 없음 not_found 404해당 테넌트·livemode 범위에 리소스 없음 invalid_body 422detail(zod issues) 유무는 엔드포인트마다 다름 |
GET/v1/plans/{code} |
요금제 단건 — **live 조회 허용** | — 경로 파라미터 code. 타 테넌트 요금제도 404(존재 여부를 노출하지 않는다) |
200id · code · name · amount · currency · interval · trialDays · status · metadata · version · createdAt |
missing_api_key 401Authorization 헤더 없음 invalid_api_key 401키가 유효하지 않거나 폐기됨 unauthenticated 401테넌트 컨텍스트 없음 not_found 404해당 테넌트·livemode 범위에 리소스 없음 invalid_body 422detail(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 |
200id · code · name · amount · currency · interval · trialDays · status · metadata · version · createdAt · appliesTo · unaffected**금액 변경은 신규 구독부터 적용**된다 — 진행 중 구독과 열린 결제 세션은 개시 시점 금액을 유지한다(unaffected 로 그 수를 함께 반환). |
missing_api_key 401Authorization 헤더 없음 invalid_api_key 401키가 유효하지 않거나 폐기됨 unauthenticated 401테넌트 컨텍스트 없음 not_found 404해당 테넌트·livemode 범위에 리소스 없음 invalid_body 422detail(zod issues) 유무는 엔드포인트마다 다름 live_plan_requires_approval 403live 요금제 생성·변경·보관은 허브 운영자가 수행한다(조회는 허용) plan_not_found 404없음 또는 타 테넌트 plan_version_conflict 409expectedVersion 이 낡음 plan_not_active 409보관된 요금제는 변경 불가 plan_immutable_field 422code·currency·interval·trialDays 변경 시도 invalid_plan_amount 422정수 아님·음수·상한 초과 invalid_plan_name 422trim 후 1~200자 위반 empty_patch 422변경할 필드 없음 |
POST/v1/plans/{code}/archive |
요금제 보관 — **test 전용** · 신규 구독만 차단 | expectedVersion`expectedVersion` 필수 |
200id · code · name · amount · currency · interval · trialDays · status · metadata · version · createdAt보관 후 **신규 구독은 404**. 진행 중 구독의 갱신·청구·카드 교체는 그대로 유지된다. |
missing_api_key 401Authorization 헤더 없음 invalid_api_key 401키가 유효하지 않거나 폐기됨 unauthenticated 401테넌트 컨텍스트 없음 not_found 404해당 테넌트·livemode 범위에 리소스 없음 invalid_body 422detail(zod issues) 유무는 엔드포인트마다 다름 live_plan_requires_approval 403live 요금제 생성·변경·보관은 허브 운영자가 수행한다(조회는 허용) plan_not_found 404없음 또는 타 테넌트 plan_version_conflict 409expectedVersion 이 낡음 plan_not_active 409이미 보관됨 |
POST/v1/checkout-sessions |
일회성 결제 세션 생성 Idempotency-Key 필수 |
amount · orderName · customer · successUrl · failUrl · payMethodamount=정수(원) · orderName≤200자 · customer{id, email?, name?≤30, phone?≤40}(전체 선택) · payMethod 는 card 만 지원. 실패 시 failUrl 에 reason=failed|error 만 덧붙는다(PG 원문 미전달 — 원문은 허브 서버 기록 전용) |
201 / 200id · status · type · amount · orderName · payMethod · checkoutUrl · expiresAt · createdAt신규 201 · 동일 키+동일 본문 재요청은 200(기존 세션 반환) |
idempotency_key_required 400Idempotency-Key 헤더 누락 idempotency_key_conflict 409같은 키·다른 본문 redirect_url_not_allowed 422allowedRedirectOrigins 화이트리스트 밖 |
GET/v1/checkout-sessions/:id |
결제 세션 조회 — 이행 확정의 정본 경로 | — 경로 파라미터 id |
200id · status · type · amount · orderName · payMethod · checkoutUrl · expiresAt · createdAt · paid · paymentpaid=true 이면 결제 확정. payment 는 paid 결제만 채워진다(실패 건은 조회되지 않음) |
공통 에러만 |
POST/v1/subscriptions |
구독 생성 — card(빌링키 발급 세션) 또는 invoice(정기 인보이스) Idempotency-Key 필수 |
billingMode · planCode · customer · successUrl · failUrlbillingMode 미지정 = card. card 는 customer 의 email·name·phone 이 사실상 필수(카드등록 페이지 가드). invoice 는 successUrl/failUrl 대신 invoiceRecipient{brn, corpName, ceo, address?, email} 필수. 실패 시 failUrl 에 reason=failed|error 만 덧붙는다(PG 원문 미전달) |
201 / 200id · billingUrl⚠️ card 응답의 id 는 **구독 ID 가 아니라 빌링키 발급 세션 ID** 다 — 이 값으로 GET /v1/subscriptions/{id} 를 호출하면 404. 구독 ID 는 카드 등록이 끝난 뒤 subscription.activated 이벤트의 data.subscriptionId 로 전달된다. · invoice → {subscriptionId, status, billingMode, nextBillingAt}(이쪽은 즉시 구독 ID) |
idempotency_key_required 400Idempotency-Key 헤더 누락 idempotency_key_conflict 409같은 키·다른 본문 redirect_url_not_allowed 422allowedRedirectOrigins 화이트리스트 밖 plan_not_found 404요금제 미등록 또는 비활성 invoice_billing_gated 422invoice 모드는 test 전용(live 차단) invoice_free_plan_unsupported 4220원 플랜은 invoice 모드 불가 |
GET/v1/subscriptions/:id |
구독 상태 조회 | — 경로 파라미터 id |
200id · status · billingMode · planId · currentPeriodStart · currentPeriodEnd · nextBillingAt · cancelAtPeriodEnd · retryCount기간 필드는 개시 전 null |
공통 에러만 |
PATCH/v1/subscriptions/:id |
해지 예약(기간말) 또는 즉시 해지 | cancelAtPeriodEnd · cancelImmediatelycancelAtPeriodEnd=false 로 예약 취소. invalid_body 에 detail 없음 |
200ok{ok:true} |
공통 에러만 |
POST/v1/subscriptions/:id/billing-key |
카드 교체 세션 발급 — past_due 면 등록 성공 시 즉시 재청구·복귀 Idempotency-Key 필수 |
successUrl · failUrlinvalid_body 에 detail 없음. 실패 시 failUrl 에 reason=failed|error 만 덧붙는다(PG 원문 미전달 — 원문은 허브 서버 기록 전용) |
201 / 200billingUrl{billingUrl} |
idempotency_key_required 400Idempotency-Key 헤더 누락 idempotency_key_conflict 409같은 키·다른 본문 redirect_url_not_allowed 422allowedRedirectOrigins 화이트리스트 밖 subscription_canceled 409해지된 구독은 교체 불가 |
GET/v1/payments |
결제 목록(기간·상태 필터) | status · from · to · limitfrom/to=ISO8601(createdAt 기준) · limit 1~200(기본 50) · 최신순. 커서 페이지네이션 미제공 |
200items{items:[결제 객체]} |
invalid_query 422detail(zod issues) 포함 |
GET/v1/payments/:id |
결제 단건 조회 — 팬아웃 수신측 재조회 정본 | — 경로 파라미터 id |
200id · type · status · amount · currency · paidAt · createdAt · failedReason · refundrefund.totalRefunded 는 완료된 환불 합계 |
공통 에러만 |
POST/v1/payments/:id/refunds |
환불(전액/부분) Idempotency-Key 필수 |
amount · reason · refundAccountamount 생략 = 잔여 전액 · 가상계좌 결제는 refundAccount{bank, number, holderName, holderPhoneNumber?} 필수 · invalid_body 에 detail 없음 |
201 / 200id · amount · fullyRefunded신규 201 · 동일 키 재요청 200(재실행 없이 기존 결과) |
idempotency_key_required 400Idempotency-Key 헤더 누락 idempotency_key_conflict 409같은 키·다른 본문 not_refundable 409결제 상태가 환불 가능 범위 밖(status 동봉) refund_account_required 422가상계좌 환불에 환불계좌 누락 invalid_refund_amount 422잔여 초과(remaining 동봉) pg_cancel_failed 502PG 취소 실패 — 재시도 가능 refund_recorded_inconsistent 500PG 취소는 성공했으나 기록 실패 — 운영 확인 필요 |
GET/v1/invoices/:id |
정기 인보이스 조회 — 청구·가상계좌 스냅샷·세금계산서 연계 | — 경로 파라미터 id |
200id · status · amount · supplyAmount · taxAmount · dueAt · periodStart · periodEnd · paidAt · taxInvoiceId · vaBank · vaAccountNumber · vaHolder · vaDueAt · checkoutUrlva* 는 가상계좌 미발급 시 null |
공통 에러만 |
GET/v1/tax-invoices/:id |
세금계산서 조회 (발급은 운영 CLI 전용) | — 경로 파라미터 id |
200id · status · providerStatus · purposeType · supplyAmount · taxAmount · totalAmount · issuedAt · writeDate · portoneTaxInvoiceId · paymentId · invoiceId공급자/공급받는자 상호·주소·대표자 등 PII 는 응답에 포함하지 않는다 |
공통 에러만 |
GET/v1/analytics/summary |
기간 결제 요약 — 금액은 원장 기준, 건수는 결제 기준 | from · tofrom·to 는 YYYY-MM-DD(KST) · 둘 다 선택이며 기본은 최근 30일(to=오늘, from=to−29일) · 양끝 포함 · 최대 1년. **알 수 없는 파라미터는 422 unknown_parameter**(다른 /v1 과 다른 정책) |
200period · livemode · paymentsperiod 에 실제 적용값을 에코. refundRate 는 100 을 넘을 수 있다(기간 밖 결제가 기간 안에 환불된 경우) — 자르지 않는다 |
invalid_period 422from/to 형식 오류 또는 from > to period_too_long 422조회 기간 1년 초과 interval_range_exceeded 422interval=day 로 92일 초과 조회 unknown_parameter 422알 수 없는 쿼리 파라미터 — **통계 계열만** 거부한다(다른 /v1 은 무시) missing_api_key 401Authorization 헤더 없음 invalid_api_key 401키가 유효하지 않거나 폐기됨 unauthenticated 401테넌트 컨텍스트 없음 not_found 404해당 테넌트·livemode 범위에 리소스 없음 invalid_body 422detail(zod issues) 유무는 엔드포인트마다 다름 |
GET/v1/analytics/timeseries |
결제 시계열 — 일·주·월 버킷 | from · to · intervalinterval=day|week|month(기본 day) · **interval 은 이 엔드포인트에서만 유효**하다 · day 는 최대 92일 · 주는 KST 월요일 시작 |
200period · livemode · interval · points빈 버킷도 0 으로 채우고 bucket 오름차순. 버킷은 자연 달력 기준이라 첫 라벨이 from 보다 앞설 수 있고, 그때 partial=true 이며 금액은 요청 경계로 잘린 값이다 |
invalid_period 422from/to 형식 오류 또는 from > to period_too_long 422조회 기간 1년 초과 interval_range_exceeded 422interval=day 로 92일 초과 조회 unknown_parameter 422알 수 없는 쿼리 파라미터 — **통계 계열만** 거부한다(다른 /v1 은 무시) missing_api_key 401Authorization 헤더 없음 invalid_api_key 401키가 유효하지 않거나 폐기됨 unauthenticated 401테넌트 컨텍스트 없음 not_found 404해당 테넌트·livemode 범위에 리소스 없음 invalid_body 422detail(zod issues) 유무는 엔드포인트마다 다름 |
GET/v1/analytics/subscriptions |
구독 현황·MRR — counts·mrr 은 조회 시점 현재값 | from · toperiod 는 newInPeriod 에만 적용된다 |
200period · livemode · asOf · counts · mrr · newInPeriodcounts·mrr 은 asOf 시점의 현재값(과거 시점 재현 불가 — 상태 이력 미보유). mrr.basis=billed_total(카드는 청구총액, invoice 는 공급가+VAT) · trialing·past_due 제외 · **해지율(churn)은 제공하지 않는다**(정확히 계산할 수 없어 근사치를 주지 않는다) |
invalid_period 422from/to 형식 오류 또는 from > to period_too_long 422조회 기간 1년 초과 interval_range_exceeded 422interval=day 로 92일 초과 조회 unknown_parameter 422알 수 없는 쿼리 파라미터 — **통계 계열만** 거부한다(다른 /v1 은 무시) missing_api_key 401Authorization 헤더 없음 invalid_api_key 401키가 유효하지 않거나 폐기됨 unauthenticated 401테넌트 컨텍스트 없음 not_found 404해당 테넌트·livemode 범위에 리소스 없음 invalid_body 422detail(zod issues) 유무는 엔드포인트마다 다름 |
GET/v1/analytics/breakdown |
결제 분해 — 유형·결제수단·요금제별 | from · to성공 결제(paid_at) 기준 |
200period · livemode · byType · byMethod · byPlan각 배열은 chargedAmount 내림차순(동률이면 key 오름차순) · 데이터가 없으면 빈 배열 · byPlan.key 는 planCode · 세션·구독 어디에도 귀속되지 않는 결제는 "unattributed" 로 모으며, 세 축의 합은 항상 같다 |
invalid_period 422from/to 형식 오류 또는 from > to period_too_long 422조회 기간 1년 초과 interval_range_exceeded 422interval=day 로 92일 초과 조회 unknown_parameter 422알 수 없는 쿼리 파라미터 — **통계 계열만** 거부한다(다른 /v1 은 무시) missing_api_key 401Authorization 헤더 없음 invalid_api_key 401키가 유효하지 않거나 폐기됨 unauthenticated 401테넌트 컨텍스트 없음 not_found 404해당 테넌트·livemode 범위에 리소스 없음 invalid_body 422detail(zod issues) 유무는 엔드포인트마다 다름 |
타입 규약
- 금액(
amount·supplyAmount·taxAmount·totalAmount·totalRefunded) = 정수, 원 단위. 소수점 없음. - 시각(
*At·periodStart/End·dueAt) = ISO8601 UTC 문자열. 한국 시각 해석은 수신측에서 하세요. writeDate(세금계산서 작성일)만 예외로yyyy-MM-dd문자열입니다.- null 이 올 수 있는 필드: 세션
amount(카드 등록 전용 세션),payment·payment.paidAt, 구독 기간 필드(개시 전), 결제paidAt·failedReason, 인보이스의 가상계좌·납기·세금계산서 연계 필드, 세금계산서의issuedAt·providerStatus등.
주요 enum
| 필드 | 값 |
|---|---|
결제 status | ready paid failed cancelled partial_cancelled |
결제 type | one_time subscription_cycle |
세션 status | open awaiting_deposit completed expired |
세션 type | one_time billing_key_issue invoice_payment |
구독 status | trialing active past_due canceled suspended |
구독 billingMode | card invoice |
인보이스 status | draft issued paid overdue void |
세금계산서 status | draft requested issued cancelled failed |
세금계산서 purposeType | INVOICE 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 에 쓸 모든 오리진을 운영자에게 전달해 등록하세요.failUrl 에 reason 이 붙습니다.허브는 실패 화면을 먼저 보여준 뒤 사용자가 돌아가기를 누르면
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 스키마상 선택값이라 세 값이 없어도
201 과 billingUrl 이 돌아오지만,
그 URL 을 연 카드 등록 페이지가 세 값을 모두 요구해 오류 화면으로 막힙니다. 구독 개시가 불가능해집니다.- 리다이렉트 URL 화이트리스트:
successUrl·failUrl의 오리진이 테넌트에 등록된 목록에 없으면422 redirect_url_not_allowed. 도메인이 바뀌면 운영자에게 등록을 요청하세요. - 결제 세션 유효기간 30분 — 만료 후 재시도는 새
Idempotency-Key로. - 일회성 결제의
customer는 선택입니다(구독과 달리 없어도 결제창이 열립니다). - 정기 인보이스(
billingMode: invoice) 구독은 test 모드 전용입니다. live 요청은422 invoice_billing_gated로 막힙니다.
결제 · 구독 플로우
일회성 결제
세션 생성 → 결제창 → 웹훅 또는 재조회로 확정.
# 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)
- 청구 실패 시 구독은
past_due가 되고subscription.payment_failed가 발송됩니다. 연체 중 서비스 이용을 막을지 유예할지는 각 서비스가 정하는 정책이며 허브는 관여하지 않습니다. - 재시도는 원 납기 기준 +1일 · +3일 · +5일 로 진행됩니다.
- 재시도를 모두 소진하면 구독이 자동 해지되고
subscription.canceled(reason: dunning_exhausted) 가 발송됩니다. - 재청구가 성공해 연체에서 벗어나면
subscription.renewed가 아니라subscription.recovered가 옵니다. - 카드 교체는
POST /v1/subscriptions/{id}/billing-key로 새 등록 URL 을 발급합니다.past_due상태에서 등록이 성공하면 즉시 재청구되어 복귀합니다.
해지
PATCH /v1/subscriptions/{id} 에 cancelAtPeriodEnd: true(기간말 해지 예약) 또는
cancelImmediately: true(즉시 해지)를 보냅니다. 예약은 cancelAtPeriodEnd: false 로 취소할 수 있습니다.
웹훅(팬아웃)
허브가 테넌트의 webhook_url 로 POST 합니다. 전송 헤더는 다음과 같습니다.
| 헤더 | 값 |
|---|---|
content-type | application/json |
X-Pay-Event-Id | 이벤트 고유 id — 본문 id 와 같아야 합니다 |
X-Pay-Livemode | "true" / "false" — 본문 livemode 와 같아야 합니다 |
X-Pay-Signature | t=<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 이벤트로만 알 수 있어
그 이벤트를 끝내 받지 못하면 스스로 대사할 수단이 없습니다(아래 참조)."처리했는지 조회 → 처리 → 완료 기록" 순서는 동일
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 만 재시도합니다. 서명이 맞지 않는데
200 을 반환하면 그 이벤트는 영구히 유실됩니다(secret 회전 구간에서 특히 위험).livemode: true 이벤트는 live 키로, false 는 test 키로 조회합니다. 어긋나면 404 not_found 입니다.- 순서는 보장되지 않습니다.
seq·occurredAt는 힌트일 뿐이며, 정본은 수신 시점의 재조회 결과입니다. - 리플레이 방지: 서명의
t와 현재 시각 차이가 5분을 넘으면 거부하세요. - 원문(raw body)으로 검증하세요. JSON 을 파싱한 뒤 다시 문자열로 만들면 서명이 어긋납니다.
- 응답은 5초 안에 반환하세요(타임아웃 = 실패). 무거운 처리는 큐에 넣고 먼저 응답합니다.
- 재시도 간격: 1분 → 5분 → 30분 → 2시간 → 6시간 → 24시간(6회, 약 33시간).
webhook secret 회전
절차: ① 회전 실행 → 새 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 가 옵니다 (예: 카드 갱신과 인보이스 입금의
subscription.renewed, 인보이스 조회에 실패한 축약 형태).
수신 코드는 없을 수 있는 필드를 옵셔널로 다루고, 필요한 값은 재조회로 채우세요.
특정 필드의 존재를 전제로 구조 분해하면 축약 payload 에서 깨집니다.중복 억제 열은 허브 내부에서 같은 이벤트를 중복 생성하지 않기 위한 장치이며, 전달이 한 번만 된다는 보장이 아닙니다. 수신측 멱등 처리는 어느 이벤트에서든 필수입니다.
온보딩 절차
테넌트 등록은 운영자가 직접 진행합니다(셀프 가입은 제공하지 않습니다).
- 연동 요청 —
ydnam@fuinsoft.com로 서비스명·도메인·결제 형태(일회성/구독)를 알려주세요. - 테넌트 등록 · API 키 발급 — test 키를 먼저 발급합니다(1회 표시).
- 설정 등록 — 웹훅 수신 URL, 리다이렉트 허용 오리진(
successUrl·failUrl도메인)을 전달해 주세요. 오리진이 등록되지 않으면 결제 세션 생성이 전부422로 막힙니다. - 요금제 등록 — test 모드는
POST /v1/plans로 직접 등록·변경·보관할 수 있습니다. live 요금제만 운영자 승인 대상입니다 — 상품명·금액·주기를 전달해 주시면 운영자가 등록 후planCode를 알려드립니다 (실청구 금액을 단독으로 정하지 못하게 하는 게이트입니다 — 조회(GET /v1/plans)는 두 모드 모두 가능). 미등록 상태로 구독을 생성하면404 plan_not_found입니다.
· 금액 변경은 신규 구독부터 적용됩니다 — 진행 중 구독과 열린 결제 세션은 개시 시점 금액을 그대로 유지합니다.
·interval·trialDays는 변경할 수 없습니다 — 주기나 체험기간이 다르면 새 요금제를 만드세요.
· 체험기간(trial)은 카드 구독에만 적용됩니다 —billingMode: invoice구독은 체험을 무시하고 즉시 첫 청구합니다.
· 요금제 금액 상한은 1,818,182원입니다(결제대행사 계약상 건당 승인한도 200만원 · 정기 인보이스는 부가세를 더한 금액이 승인 대상이라 공급가 상한이 더 낮습니다). - test 모드 연동·검증 — 결제·웹훅·재조회까지 확인합니다.
- 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