Membership
유료 구독 준비와 서명된 청구 결과 전달.
SDK 기본과 자격 증명부터 준비하세요. 아래 결제 단계는 신뢰할 수 있는 앱 서버에서 실행합니다.
현재 ready인 Sandbox에서도 Live와 동일하게 위임 결제 연결, 청구·조회와 빌링키 암호문 보관을 사용할 수 있습니다. 별도로 발급한 test 워크스페이스와 이전 세대·준비되지 않은 환경은 계속 차단됩니다.
Console은 위임 결제의 모드를 검증하지 않습니다. LIVE 키를 사용하면 Sandbox에서도 실제 카드에 실과금됩니다. 테스트 전에 앱 서버의 TEST 키 설정과 결제사 계정을 확인하세요. 결제 모드 선택·확인은 워크스페이스 관리자의 책임입니다.
Sandbox 초기화는 새 워크스페이스를 만들며 결제 연결·암호문을 복사하지 않습니다. 초기화 당시 대기 중이거나 진행 중이던 청구는 이전 세대에서 자동 조회로 조정되지 않습니다. 이미 전송된 요청은 취소되지 않으며, 이후 도착한 서명된 결과나 이미 진행 중인 응답은 기존 결제 증거 규칙에 따라 반영될 수 있습니다.
보존된 결제·감사 기록은 이전 세대의 영구 삭제를 막을 수 있습니다. 이 세대는 미정리 보관 세대 상한 7개에 계속 포함되어 이후 초기화가 차단될 수 있습니다. 초기화 전에 청구의 정산 완료를 확인하세요. 고객 이메일과 일반 웹훅 발송은 기존 Sandbox capture 정책을 유지합니다.
결제 키 암호화 보관
앱 서버가 결제 키를 암호화하고 server.memberships.billingCustody로
암호화 값을 보관·조회합니다. 준비한 Membership의 현재 복구 세대는 콘솔에서 받습니다.
이 부분 예제는 서버 클라이언트, Membership ID, 웹훅 비밀키가 준비되어 있다고 가정합니다.
const { generation } = await server.memberships.billingCustodyContext({
membershipId,
webhookSecret,
})
const stored = await server.memberships.billingCustody({
action: 'read',
membershipId,
webhookSecret,
generation,
})이 조회를 바탕으로 값을 갱신할 때는 받은 generation과 stored.version을
계속 사용하세요. 최초 암호화 값을 올릴 때는 expectedVersion: 0인 put 직전에
새 context를 받으세요. 서버 API 키와 위임 연결 시크릿은 같은 워크스페이스와 API
주소에 맞춰 설정합니다. context 요청은 두 인증 정보와 Membership의 활성 결제 수단을 검사합니다.
Membership·CLUB 템플릿은 이 값을 자동으로 받습니다. 복구 세대를 직접 설정하는 기존 앱도 계속 사용할 수 있습니다. context를 지원하는 콘솔을 배포한 뒤 SDK와 템플릿을 갱신하세요.
세대가 다르다는 오류가 나면 청구 전에 멈추세요. 플랫폼 복구 후에는 운영자의 재개 공지를 받은 뒤 결제 제공자에게 결제 수단을 다시 승인받고 새 결제 키를 암호화해 업로드·조회하세요. 대기 중인 청구 근거는 결과 대조를 위해 보존합니다. context를 받지 못하면 플랫폼 운영자에게 문의하세요. 과거 갱신 요청을 새 세대로 자동 전환하면 오래된 결제 정보가 복원될 수 있습니다.
유료 Membership 구독
이 흐름에는 customers와 membership을 켜세요. 상품과 주문도 필요하면 ecommerce를 따로 선택합니다.
server.memberships.subscribe()는 유료 멤버십을 준비합니다. 결제사 결제 식별자를 받지 않으며 멤버십을 활성화하지도 않습니다. Membership이 적용된(선택했고 플랜이 허용하는) 워크스페이스에서 쓰기 권한이 있는 서버 보관 Secret Key를 사용하고, 응답 유형에 따라 분기하세요.
- 유료 결과는
incomplete이며, 정확한 금액, 통화, 기간 시작일, 만료 시각, 워크스페이스 PSP용firstCharge.idempotencyKey가 들어 있습니다. 그 키로 PSP에서 결제하고 결과를 기존의 서명된 결제 결과 웹훅으로 보내세요. - 체험 결과는 바로
trialing이며 첫 결제 안내가 없습니다. - 갱신은 UTC 기준 결제일을 유지합니다. 결제일은 첫 유료 기간 시작일이고, 체험이면 체험 종료일입니다. 그 날짜가 없는 짧은 달에는 그 달 마지막 날에 기간이 끝나고, 다음 달에는 결제일로 돌아옵니다(1/31 → 2/28 → 3/31).
중단된 가입 시도를 끝내려면 신뢰할 수 있는 서버에서 server.memberships.endSubscribeIntent({ customerId, subscribeIntentKey })를 호출할 수 있습니다.
status: 'ended'일 때만 그 식별자를 이후 가입에 다시 쓸 수 없다는 것이 확인됩니다.existing이면 기존 멤버십을 복구하거나 확인하세요.- 멤버십이나 결제를 취소하지 않습니다.
- 개인정보가 없는 불투명한 무작위 키를 쓰세요. 공백 없는 보이는 ASCII 문자 1–512자이며, 가입 헤더와 같은 식별자입니다.
- 지원하지 않는 버전, 시간 초과, 형식이 잘못된 응답, 오류는 종료가 확인되지 않은 것으로 처리하세요.
- 콘솔 마이그레이션과 서버를 SDK와 그 사용처보다 먼저 배포하세요.
체험 자격. 같은 워크스페이스에서 고객은 플랜마다 체험을 최대 한 번 받으며, 즉시 해지하거나 휴지통으로 보낸 뒤에도 마찬가지입니다.
- 체험 스냅숏이 양수이고 기간이 시작된 경우 자격을 쓴 것으로 봅니다. 기간이 시작된 레거시 행에 스냅숏이 null이거나 없으면 보수적으로 쓴 것으로 봅니다.
- 기간이 시작되지 않은 미완료 설정과, 유료 전용으로 0인 스냅숏은 자격을 쓰지 않습니다.
- 카탈로그의 체험 일수와 무료 체험 배지는 플랜 설명일 뿐입니다. 이 고객의 실제 결과는 가입 응답으로 확인하세요.
- 이 플랜의 체험 자격을 이미 쓴 고객은 유료
incomplete응답을 받습니다. 활성 위임 결제 연결이 없으면 수명 주기 기록 없이409 conflict입니다.
PSP 호출 전. 항상 firstCharge의 결제 금액, 통화, 기간 시작일을 분명히 보여 주고 고객의 확인을 받으세요. 응답에는 기간 종료일이 없으므로, 선택한 결제 주기를 보여 주되 종료일 필드를 지어내지 마세요. 고객이 거절하면 PSP를 호출하지 마세요.
준비 응답은 결제나 동의의 증거가 아닙니다. 고객의 확인은 앱에서 직접 기록하고, 활성화는 아래 me() 확인으로 판단하세요.
결제 연결. 유료 가입 전에 tenant-admin이 콘솔에서 워크스페이스를 선택한 뒤 설정 → 커머스 → 결제 → 멤버십 결제 연결을 열어 멤버십 위임 결제 연결 패널에서 활성 연결을 설정합니다. 멤버십 기능이 켜져 있고 해당 워크스페이스의 관리자 권한이 있어야 항목이 표시됩니다. 연결을 만들거나 재발급할 때 한 번만 표시되는 webhookSecret은 앱 서버의 비밀값 저장소에 보관하세요. 모든 결과 callback을 서명하는 값이며, 가입용 Secret Key와 별개이고 브라우저에 절대 전달하면 안 됩니다. 활성 연결이 없으면 콘솔은 수명 주기 기록 없이 409 conflict를 반환합니다.
연결 패널에서 요청이 거절되면 표시된 이유를 확인하세요. 대기 중인 멤버십 청구는 결제 결과를 확인하고 정리한 뒤 다시 시도하세요. 활성 연결이 이미 있다는 안내가 나오면 화면을 새로고침해 현재 연결을 확인하세요.
PSP를 호출하기 전에 retain을 내구성 있는 앱 저장소로 구현하세요. 준비 응답 전체(membershipId와 모든 firstCharge 필드)와 PSP 식별자로의 손실 없는 매핑을 보관합니다. 가입 intent 키는 가입 요청을, firstCharge.idempotencyKey는 결제를 식별하며, charge 어댑터는 후자로 PSP 실행의 중복을 막습니다.
import { createServerClient } from '@01.software/sdk/server'
type Server = ReturnType<typeof createServerClient>
type Prepared = Awaited<ReturnType<Server['memberships']['subscribe']>>
type FirstCharge = Extract<Prepared, { status: 'incomplete' }>['firstCharge']
export async function prepareSubscription(
server: Server,
input: Parameters<Server['memberships']['subscribe']>[0],
tenantPsp: {
retain: (prepared: Prepared) => Promise<void>
confirm: (charge: FirstCharge) => Promise<boolean>
charge: (charge: FirstCharge) => Promise<void>
},
) {
const prepared = await server.memberships.subscribe(input)
await tenantPsp.retain(prepared)
if (prepared.status === 'incomplete') {
// Present amount, currency and periodStart; obtain explicit paid consent.
if (!(await tenantPsp.confirm(prepared.firstCharge))) return prepared
// Confirmation may take time. Recheck absolute expiry before PSP dispatch.
const expiresAt = Date.parse(prepared.firstCharge.expiresAt)
if (!Number.isFinite(expiresAt) || Date.now() >= expiresAt) {
throw new Error('First charge instruction expired or invalid')
}
await tenantPsp.charge(prepared.firstCharge)
}
return prepared
}활성화는 서명된 결과 웹훅이나 그 기존 대사 조회로만 결정됩니다.
- 첫 결제가 대기 중인 동안에는 같은 가입 멱등 키를 다시 쓰세요. 첫 결제 결과(paid 또는 failed)가 확정된 뒤나
expiresAt이후에 다시 보내면 새 결제 안내 대신409를 받습니다. expiresAt이 유효하지 않거나 현재 시각이 그 시각 이후이면 PSP 결제를 시작하지 마세요.- 워크스페이스 PSP는
firstCharge.idempotencyKey로 중복을 막아야 합니다. 늦게 도착한 결제 완료 결과는 증거로 보관되지만 Membership을 활성화하지 않으므로, 그에 따른 환불은 이 API 밖에서 워크스페이스가 처리해야 합니다.
재요청과 중복:
- 기존 intent 스냅숏은 바뀌지 않으며, 완료된 체험 intent는 캐시된 체험 응답을 유지합니다.
- 기존 payload 검사와 이용 중 중복 검사가 먼저 실행됩니다. 이용 중(
trialing,active,past_due)인 다른 Membership이 있으면409 duplicate_subscription을 반환합니다. - 다른 intent가 이미 체험을 받은 상태에서 미완료 체험 intent를 다시 보내 이 검사를 통과하면, 수명 주기 기록 없이
409 idempotency_conflict를 반환하고 유료 intent로 바뀌지 않습니다. 고객이 확인한 새 유료 intent로 시작하세요. - 미완료 설정은 기존 만료 작업이
expired로 바꿉니다. 만료 뒤 72시간이 지나고 만료 이후 조회가 3회 이상 pending이면, 미반영 결제 완료 증거가 없는 첫 청구는voided로 닫힙니다. 갱신은 제외하며 멤버십과 결제 증거는 보관합니다.
첫 결제 결과 회신
서버가 PSP 결과를 검증하면 그 결과를 준비 정보와 함께 보관하세요. 확인된 paid나 확정된 failed만 보고합니다. 시간 초과, 응답 없음, 알 수 없는 결과는 알 수 없는 상태로 두고, 같은 보관 결제와 PSP 식별자로 PSP를 조회해 대사하세요. 결과가 불확실하다고 failed로 보고하지 마세요. 첫 결제가 확정적으로 실패하면 미완료 Membership은 만료되며, 이후에 결제 완료가 관찰돼도 활성화되지 않을 수 있습니다.
이 앱 서버용 Node 모듈은 현재 HTTP 프로토콜로 결과를 보냅니다. 보관한 준비 정보와 검증된 결과를 넘기세요. PSP를 호출하지는 않습니다. 결제 완료 결과에는 정확한 Money와 pgPaymentId를 보관하세요. 실패 결과에는 Money가 없으며, pgPaymentId는 알 때만 포함합니다.
import { createHmac, randomUUID } from 'node:crypto'
import type { createServerClient } from '@01.software/sdk/server'
type Server = ReturnType<typeof createServerClient>
type Prepared = Awaited<ReturnType<Server['memberships']['subscribe']>>
type PaidPreparation = Extract<Prepared, { status: 'incomplete' }>
// Supply only outcomes verified by your trusted server against the PSP.
export type VerifiedOutcome =
| { status: 'unknown' }
| { status: 'paid'; amount: number; currency: string; pgPaymentId: string }
| { status: 'failed'; pgPaymentId?: string }
export async function reportFirstCharge(
prepared: PaidPreparation,
outcome: VerifiedOutcome,
connection: { apiUrl: string; webhookSecret: string },
) {
if (outcome.status === 'unknown') return { status: 'unconfirmed' as const }
if (
outcome.status === 'paid' &&
(outcome.amount !== prepared.firstCharge.amount ||
outcome.currency !== prepared.firstCharge.currency)
) {
throw new Error('Reconcile PSP Money against the retained first charge')
}
const event =
outcome.status === 'paid'
? {
kind: 'charge-result',
membershipId: prepared.membershipId,
idempotencyKey: prepared.firstCharge.idempotencyKey,
status: 'paid',
amount: outcome.amount,
currency: outcome.currency,
pgPaymentId: outcome.pgPaymentId,
}
: {
kind: 'charge-result',
membershipId: prepared.membershipId,
idempotencyKey: prepared.firstCharge.idempotencyKey,
status: 'failed',
pgPaymentId: outcome.pgPaymentId,
}
const rawBody = JSON.stringify(event)
const timestamp = Date.now().toString()
const deliveryId = randomUUID()
const signature = createHmac('sha256', connection.webhookSecret)
.update(`${timestamp}.${deliveryId}.${rawBody}`, 'utf8')
.digest('hex')
const response = await fetch(
new URL(
`/api/memberships/${encodeURIComponent(prepared.membershipId)}/charge-result`,
connection.apiUrl,
),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-membership-signature': signature,
'x-membership-timestamp': timestamp,
'x-membership-delivery-id': deliveryId,
},
body: rawBody,
},
)
const receipt = await response.json().catch(() => null)
if (response.status !== 200 || receipt?.received !== true) {
throw Object.assign(
new Error('Charge result not acknowledged; retain for reconciliation'),
{
status: response.status,
receipt,
},
)
}
return { status: 'received' as const, duplicate: receipt.duplicate === true }
}요청은 POST /api/memberships/:id/charge-result이며, 본문의 membershipId는 경로의 ID와 같아야 합니다.
- 서명은 그 워크스페이스 연결의 비밀값으로
${timestamp}.${deliveryId}.${rawBody}에 대해 계산한 HMAC-SHA256 hex입니다.timestamp는 밀리초 문자열이며, 서명한 UTF-8 JSON 바이트를 그대로 보내세요.x-membership-*헤더 세 개가 모두 필요합니다. - ±300초 범위는 요청이 최신인지만 확인하며, 그것만으로 재전송을 막지는 않습니다. 서버는 delivery ID만이 아니라 결제와 결과의 논리적 식별자로 중복을 거릅니다.
- HMAC은 보낸 쪽을 인증할 뿐 PSP 결제를 검증하지 않습니다. Money는 보관한 결제와 Membership에 맞아야 하고,
pgPaymentId는 저장된 결제사 결제 식별자와 일치해야 합니다.
| 결과 | 대응 |
|---|---|
200 { received: true, duplicate?: true } | 접수만 뜻합니다. 활성화는 아래에서 따로 확인하세요. |
401 bad_signature | 경로와 본문의 식별자, 워크스페이스 연결 비밀값, 헤더, 시계, 바이트를 확인하세요. 서명된 JSON의 형식이 잘못돼도 401입니다. |
검증 후 400 | 본문을 결제 완료 또는 실패 형식에 맞게 고치세요. 필드를 추가하지 마세요. |
500이나 유실·불분명한 응답 | 같은 검증 결과를 보관하고 대사하거나 다시 보내세요. 500에는 Money·결제사 식별자 불일치와 charge_not_found도 포함되므로 일시적 저장 실패로 단정하지 마세요. |
다시 보낼 때는 같은 보관 준비 정보와 결과로 reportFirstCharge를 호출하세요. 타임스탬프, delivery ID, 서명이 새로 생겨도 새 결제가 만들어지지는 않습니다. 새 Membership, 결제 키, PSP 결제를 만들어 복구하지 마세요. 같은 결과가 계속 거부되면 워크스페이스 지원에 문의하고 보관한 증거로 대사하세요. 설정 만료나 최종 상태 이후에 도착한 결제 완료 증거는 활성화 없이 보관될 수 있습니다. voided 청구에 늦게 온 완료도 같은 키의 미반영 증거로 보관하며, 환불은 이 API 밖에서 앱이 책임집니다.
같은 Membership 활성화 확인
워크스페이스 Publishable Key와 이 고객의 JWT를 사용하세요(고객 세션 참고). me()는 현재 혜택을 주는 Membership이나 가장 최근에 볼 수 있는 Membership을 돌려주며, 준비한 ID로 조회하지 않습니다. ID가 보관한 membershipId와 같고 상태가 active일 때만 이 유료 활성화를 완료로 표시하세요.
import { createClient } from '@01.software/sdk'
export async function confirmFirstCharge(input: {
apiUrl: string
publishableKey: string
customerJwt: string
membershipId: string
}) {
const client = createClient({
apiUrl: input.apiUrl,
publishableKey: input.publishableKey,
customer: { persist: false },
})
client.customer.auth.setToken(input.customerJwt)
const membership = await client.memberships.me()
return {
activated:
membership?.id === input.membershipId && membership.status === 'active',
membership,
}
}ID가 다르거나, null이거나, 다른 상태이거나, 조회 오류가 나면 활성화가 확인되지 않은 것입니다. Admin Panel에서 보관한 그 Membership ID를 열어 확인하세요. 결제가 실패했다고 단정하거나 다른 결제를 시작하지 마세요.
고객 탈퇴 뒤 미제공 유료 기간
Admin Panel에서 워크스페이스가 처리할 delegated 미제공 유료 기간을 확인합니다. 항목에는 보존된 결제·환불 사실, 실제 이용 종료 시각과 미제공 구간을 탈퇴 효력 시각과 별도로 표시합니다. 이용 자격이 시작되지 않은 첫 가입은 실제 종료 시각이 없으며 결제 대상 기간 전체가 미제공입니다. 금액 근거가 부족하면 확인 필요로 표시합니다.
서버에서 server.memberships.listWithdrawalTasks({ status: 'open' })로 조회하고
server.memberships.updateWithdrawalTask(id, { status: 'processing', assignee: null })로 수정합니다.
read/write scope와 역할이 있는 자격증명의 tenant-admin 권한이 필요합니다.
담당과 처리 상태만 수정할 수 있습니다. 변경은 워크스페이스 범위의
membership-withdrawal-tasks collection 이벤트로 멤버십·결제 참조와 상태만 전달하며
고객 연락처나 영수증 토큰을 포함하지 않습니다. 이벤트는 상태 재조회의 신호이며 commit 증거가 아닙니다.
처리 완료로 바꿔도 플랫폼 환불이나 멤버십·결제 장부 변경은 일어나지 않습니다.
delegated 환불은 계약한 제공자에서 별도로 처리하세요.
처리 항목 이벤트는 커밋 뒤 발행됩니다. tenant-viewer SSE 구독자는 스칼라 참조만 받으며, 역할이 있는 처리 API 호출자는 tenant-admin이어야 합니다. 전용 membership 메서드만 지원합니다. 서버 전용 컬렉션이며 일반 SDK 타입에는 포함되지만, 일반 컬렉션 API의 sk 요청은 403을 반환합니다.