Customers SDK
고객 identity, hosted 세션과 출처 인식형 그룹 참조.
완결된 작업은 Customers 가이드에서 시작하세요. 이 페이지는 공개 Customers SDK를 설명합니다. import와 peer는 SDK 기본을 참고하세요.
출처 인식형 고객 그룹
신뢰할 수 있는 서버 작업은 고객이 고객 그룹에 속한 이유를 조회하고, 수동 출처만 추가하거나 철회할 수 있습니다.
import { createServerClient } from '@01.software/sdk/server'
type Server = ReturnType<typeof createServerClient>
export async function grantManualMembership(
server: Server,
input: { groupId: string; customerId: string; reason: string },
) {
await server.customerGroups.grantMember(input)
return server.customerGroups.listMembers({
groupId: input.groupId,
limit: 100,
})
}
export async function revokeManualMembership(
server: Server,
input: { groupId: string; customerId: string },
) {
await server.customerGroups.revokeMember(input)
return server.customerGroups.listMembers({
groupId: input.groupId,
limit: 100,
})
}수동 출처를 유지하려면 grantManualMembership을 호출하세요. 그 출처를 없애기로 결정한 경우에만 revokeManualMembership을 따로 호출합니다. 추가한다고 철회되지는 않습니다. 수동 출처를 철회해도 활성 Membership 출처는 계속 적용됩니다. customers.groups는 호환을 위해 읽기만 하는 데이터로 다루세요. 출처 기반 소속이 활성화된 뒤에는 직접 쓰지 마세요.
고객 인증
새 스토어프론트의 기본은 호스티드 고객 OAuth입니다. 브라우저에서 써도 안전한 클라이언트를 워크스페이스의 Publishable Key, 고객 인증 issuer, client id로 설정하세요.
import { createClient } from '@01.software/sdk'
// Consume this client in trusted routes with a server-owned token store.
export function createHostedCustomerClient(
publishableKey: string,
issuer: string,
clientId: string,
) {
return createClient({
publishableKey,
customer: { persist: false, oauth: { issuer, clientId } },
})
}신뢰할 수 있는 라우트 핸들러에서 먼저 client.customer.oauth가 설정됐는지 확인하고, createAuthorizationUrl()로 PKCE를 시작하세요. callback code는 서버에서 교환하고, 액세스 토큰, 리프레시 토큰, 임시 OAuth state는 HttpOnly 쿠키나 서버가 관리하는 세션 저장소에 둡니다. 호스티드 고객 OAuth 토큰을 브라우저 JavaScript, localStorage, 클라이언트가 읽을 수 있는 쿠키에 넣지 마세요.
client.customer.auth.login()과 register()는 legacy direct 이메일·비밀번호 로그인을 위해 남아 있습니다. 새 이커머스 템플릿은 명시적인 legacy 경로 뒤에서만 이 기능을 노출해야 합니다.
await client.customer.auth.logoutAsync()는 direct 세션의 refresh token family를 철회합니다. 철회에 실패해도 로컬 자격 증명은 지우므로, promise가 거부되면 서버 로그아웃이 끝나지 않은 것으로 처리하세요.- 이전의 동기
logout()은 로컬 상태만 지웁니다. useCustomerLogout()은 철회를 기다리고, 결과와 관계없이 인증된 고객의 쿼리 데이터를 지웁니다.- 이미 발급된 액세스 토큰은 최대 15분까지 유효할 수 있습니다.
워크스페이스 관리자는 Console의 고객 인증 설정에서 새 비밀번호 가입을 끌 수 있습니다. 그러면 register()가 403 registration_disabled를 반환하며, 기존 비밀번호 로그인과 재설정은 계속 됩니다.
기존 로컬 비밀번호 재설정 완료
이 흐름은 기존 앱에서, 이미 로컬 비밀번호가 있는 고객(로컬 비밀번호가 있는 연결된 소셜 계정 포함)을 위한 것입니다. 외부 인증 제공자의 비밀번호를 바꾸거나 비밀번호 없는 계정에 비밀번호를 추가하지 않습니다. 로그인 세션, Secret Key, PAT, 고객 ID는 필요 없습니다. 워크스페이스의 Publishable Key와 사용자에게 전달된 재설정 토큰을 사용하세요.
Console의 고객 인증 설정에서 platform 발송을 고르고, 스토어프론트의 절대 HTTPS 재설정 URL을 설정하세요. 플랫폼 이메일은 token 쿼리 매개변수를 붙이고, 가능하면 검증된 워크스페이스 발신자를 씁니다. SDK의 forgotPassword(email)는 이메일만 받으며 리디렉션 인자는 없습니다. 호출이 끝나면 일반적인 접수 안내를 보여 주세요. 이 결과로는 계정이 있는지, 메일이 도착했는지, 비밀번호가 재설정됐는지 알 수 없습니다. platform과 webhook 발송은 둘 중 하나를 고르는 방식이며, platform을 골라도 워커가 메일을 함께 보내지 않습니다.
다음 환경에서는 메일 전달이 제한됩니다. 저장된 재설정 토큰을 꺼내 이를 우회하지 마세요.
- Sandbox 워크스페이스는 플랫폼 이메일을 그 Sandbox 멤버 본인 주소로만 보냅니다. 멤버는 현재 이메일을 인증해 둬야 하며, 메일은 플랫폼 발신자와
[Sandbox]제목으로 갑니다. 다른 주소로 가는 메일과 재설정 웹훅은 기록(capture)만 남습니다. tenantPurpose: test워크스페이스는 외부 효과가 거부됩니다. 두 방식 모두 외부로 보내지 않으며, 웹훅 endpoint 조회에서도 제외됩니다.
아래 전체 코드는 앱의 기존 URL·정리 어댑터를 사용합니다.
replaceUrl은 URL에서 토큰을 즉시 지워야 합니다. 예를 들어 다른 스크립트가 로드되기 전에 브라우저 history를 교체합니다.- 반환된 토큰은 제출할 때까지 재설정 양식의 메모리에만 두고 브라우저 저장소에 넣지 마세요. 대신 기존의 신뢰할 수 있는 서버 임시 상태로 옮긴다면, 그 상태와 검증된 양식 제출을 앱의 신뢰할 수 있는 세션에 묶으세요. 새 세션 엔진은 필요 없습니다.
clearLocalSession은 기존 앱 어댑터로 앱의 고객 쿠키와 캐시를 지웁니다. 서버 세션을 철회하지는 않습니다.
import { createClient } from '@01.software/sdk'
import {
getSDKErrorReason,
isNetworkError,
isSDKError,
isTimeoutError,
} from '@01.software/sdk/errors'
type ResetClient = ReturnType<typeof createClient>
type ResetFailure = {
kind: 'validation_failed' | 'invalid_token' | 'unconfirmed' | 'unavailable'
}
export type ResetPagePort = {
// Remove the token from the visible URL before analytics or other scripts run.
replaceUrl(cleanUrl: string): void
// Clear app-owned customer cookies/cache; this does not attest server revocation.
clearLocalSession(): Promise<void>
}
export function createPasswordResetClient(
publishableKey: string,
apiUrl?: string,
) {
return createClient({ publishableKey, apiUrl, customer: { persist: false } })
}
export function takePasswordResetToken(
currentUrl: string,
page: ResetPagePort,
) {
const url = new URL(currentUrl)
const tokens = url.searchParams.getAll('token')
url.searchParams.delete('token')
page.replaceUrl(url.toString())
return tokens.length === 1 ? tokens[0] : null
}
function resetFailure(error: unknown): ResetFailure {
if (isNetworkError(error) || isTimeoutError(error)) {
return { kind: 'unconfirmed' }
}
if (isSDKError(error)) {
const reason = getSDKErrorReason(error)
if (reason === 'validation_failed' || reason === 'invalid_token') {
return { kind: reason }
}
if (
error.status === undefined ||
error.status < 400 ||
error.status >= 500
) {
return { kind: 'unconfirmed' }
}
return { kind: 'unavailable' }
}
return { kind: 'unconfirmed' }
}
export async function requestPasswordReset(client: ResetClient, email: string) {
try {
await client.customer.auth.forgotPassword(email)
return { kind: 'accepted' } as const
} catch (error) {
return resetFailure(error)
}
}
export async function submitPasswordReset(
client: ResetClient,
token: string,
password: string,
page: ResetPagePort,
) {
let recovery: Awaited<
ReturnType<ResetClient['customer']['auth']['resetPassword']>
>
try {
// Preserve exactly what the user entered, including leading/trailing spaces.
recovery = await client.customer.auth.resetPassword(token, password)
} catch (error) {
return resetFailure(error)
}
try {
await page.clearLocalSession()
} catch {
return { kind: 'password_reset_cleanup_required' } as const
}
return { kind: 'password_reset', ...recovery } as const
}양식은 다음 순서로 연결하세요.
- 워크스페이스 Publishable Key로 새
createPasswordResetClient를 만듭니다. 이메일을requestPasswordReset에 제출하고,accepted일 때만 일반 접수 안내를 보여 줍니다. - 설정한 재설정 페이지에서 다른 연동을 렌더링하기 전에
takePasswordResetToken을 한 번 호출합니다. 토큰이 없거나 비었거나 중복이면 재설정을 다시 요청해야 합니다. 새 비밀번호는 기존 양식으로 받습니다. - 그 토큰과 입력한 비밀번호 그대로
submitPasswordReset을 호출합니다. 비밀번호를 trim하거나 정규화하지 마세요. 처리 중에는 중복 제출을 막고, 흐름이 끝나면 양식의 비밀값을 버리세요. password_reset일 때만 재설정과 앱 정리가 모두 끝난 것입니다. 정리된 URL에서 기존 로그인 입구를 안내하세요.password_reset_cleanup_required는 비밀번호는 바뀌었지만 앱 정리를 복구해야 한다는 뜻이므로, 정리를 마친 뒤 진행하세요. 어느 결과도 로그인이나 새 세션·JWT 발급이 아닙니다.
정리된 페이지뿐 아니라 첫 요청도 보호하세요. proxy, 서버, 접근, 오류 로그에서 token을 가리고, Referrer-Policy: no-referrer와 Cache-Control: no-store를 설정하고, 분석 도구, 추적 리디렉션, 서드파티 스크립트를 이 흐름에서 빼세요. URL을 정리해도 첫 요청의 로그는 되돌릴 수 없습니다. 토큰이나 비밀번호 값, 원본 SDK 오류를 로그에 남기거나 화면에 표시하지 마세요. 앱의 기존 로그인 입구는 기존 앱의 direct legacy 호환을 따르고, 세션 구현을 복제하지 마세요.
| 결과 | 의미 | 화면 |
|---|---|---|
validation_failed | 비었거나 너무 긴 토큰, 형식이나 길이가 잘못된 비밀번호(현재 토큰 1–128자, 비밀번호 8–128자) | 비밀번호: 올바른 비밀번호를 요청합니다. 토큰: 재설정 재요청을 안내합니다. |
invalid_token | 잘못됐거나 만료됐거나 이미 쓴 토큰. API는 이 경우들을 구분하지 않습니다. | "더 이상 쓸 수 없는 링크" 안내 하나와 재설정 재요청. 유효성 조회, 만료·사용 구분, 계정 존재 확인을 만들지 마세요. |
unavailable | 기능·워크스페이스 거부를 포함한 그 밖의 명시적 SDK 거부 | 오류. 성공으로 보여 주지 마세요. |
unconfirmed | 네트워크·시간 초과, 서버 오류나 읽을 수 없는 응답, 알 수 없는 실패 | 비밀번호가 이미 바뀌고 토큰이 사용됐을 수 있습니다. 기존 로그인, 재설정 재요청, 계정 지원을 안내하세요. |
인증 호출은 자동으로 재시도하지 않습니다. 반복하거나 무작정 다시 제출하지 마세요. 재설정에 성공하면 비밀번호 변경은 기록되지만, 서버 세션 철회는 아직 끝나지 않았을 수 있습니다. 로컬 정리와 서버 철회는 별개입니다.
재설정과 앱 정리를 마친 뒤 recoveryLoginPermit을 다음 로그인까지 메모리에만 보관하세요. 이메일과 새 비밀번호를 함께 client.customer.auth.login({ email, password, recoveryLoginPermit })으로 보내세요. 허가증은 5분 후 만료되며 비밀번호를 틀린 경우에도 검증 시도 한 번에 소비됩니다. 고객별 요청 제한과 비밀번호 잠금을 우회하지만 워크스페이스 제한, 정지 상태, 세션 저장소 검사는 거칩니다. 만료되거나 검증에 실패하면 재설정을 다시 요청하세요. 재요청해도 미사용 링크는 원래 만료 시각까지 유효하며, 재설정에 성공하면 나머지 링크가 폐기됩니다. 이전 서버는 복구 필드를 생략할 수 있습니다.
선택: 서명을 검증하는 웹훅 워커
웹훅 발송은 워크스페이스의 모든 활성 수신자를 검토한 뒤에만 고르세요. 비밀번호 재설정은 시스템 이벤트라서, 원본 재설정 토큰, 이메일, 이름이 구독 설정과 관계없이 모든 활성 웹훅 endpoint로 전달되며 구독 필터로도 줄일 수 없습니다. 신뢰할 수 있는 수신자만 남기고, 나머지 수신자는 이 이벤트를 unhandled로 무시하며 토큰, 이메일, 이름, 이벤트 전체를 저장하거나 로그에 남기지 않아야 합니다.
기존 서명된 수신기와 endpoint 설정을 사용하세요. 아래 워커는 신뢰할 수 있는 endpoint용 전체 코드입니다. configuredResetUrl은 같은 보호된 HTTPS 재설정 페이지를 가리키는 앱 설정이며, 이벤트에서 받은 리디렉션이 아닙니다. endpoint 자신의 서명 비밀값과 읽지 않은 원래 Request를 넘기세요. 서명 검증은 CustomerAuth factory가 비밀번호 재설정 형식을 확인하기 전에 원본 바이트 그대로를 대상으로 합니다.
import {
createCustomerAuthWebhookHandler,
handleSignedWebhook,
} from '@01.software/sdk/webhook'
export type ResetDeliveryPort = {
// Atomically dedupe by deliveryId in this endpoint's namespace using the
// existing mail adapter. Resolve only after it accepts responsibility.
// Never log these fields or persist the token/link in a general event log.
sendOnce(input: {
deliveryId: string
to: string
name: string
resetUrl: string
expiresAt: string
}): Promise<void>
}
export function receivePasswordReset(
request: Request,
secret: string,
configuredResetUrl: string,
delivery: ResetDeliveryPort,
) {
const resetUrl = new URL(configuredResetUrl)
if (
resetUrl.protocol !== 'https:' ||
resetUrl.username ||
resetUrl.password ||
resetUrl.hash
) {
throw new Error('Configure a trusted HTTPS reset page')
}
return handleSignedWebhook(
request,
createCustomerAuthWebhookHandler({
// The SDK factory calls this only after its password-reset shape guard.
passwordReset: async (data, event) => {
const expiry = Date.parse(data.resetPasswordExpiresAt)
if (!Number.isFinite(expiry) || expiry <= Date.now()) return
const deliveryId = request.headers.get('x-webhook-delivery-id')
if (!deliveryId || event.deliveryId !== deliveryId) {
throw new Error('Reset delivery identity is missing or inconsistent')
}
resetUrl.searchParams.set('token', data.resetPasswordToken)
try {
await delivery.sendOnce({
deliveryId,
to: data.email,
name: data.name,
resetUrl: resetUrl.toString(),
expiresAt: data.resetPasswordExpiresAt,
})
} catch {
// The SDK receiver logs thrown errors: never propagate provider PII.
throw new Error('Password reset delivery unavailable')
}
},
// Other receivers must ignore this event without storing its raw data.
unhandled: () => {},
}),
{ secret, toleranceSeconds: 300 },
)
}sendOnce를 앱의 기존 메일 어댑터에 연결하세요.
- 이 endpoint 범위의 안정적인 delivery ID로 동시 중복을 처리해야 하며, 발송이 접수된 뒤에는 다시 보내지 않아야 합니다.
- 중복 제거에 필요한 최소한의 식별자와 결과만 보관하고, 이벤트나 재설정 링크를 통째로 복사해 두지 마세요. 일시적인 메일 데이터는 앱의 기존 보호된 보존 규칙을 따릅니다.
- 어댑터가 발송을 맡았을 때만 완료로 처리하세요. 메일 제공자의 결과가 불확실하면 무작정 다시 보내지 말고 같은 delivery 식별자로 대사하세요.
- catch는 SDK 수신기가 로그에 남기기 전에 제공자 오류 정보를 지웁니다. 요청과 SDK 오류 로그도 본문, 자격 증명, 개인정보를 가리도록 설정하세요.
수신기의 성공 응답이나 제공자의 접수는 메일이 받은편지함에 도착했다는 근거가 아닙니다.
서명 허용 범위(여기서는 300초)는 발송 타임스탬프가 최신인지, resetPasswordExpiresAt은 토큰이 만료됐는지 확인하고, deliveryId는 sendOnce가 중복을 걸러 내는 키입니다. 서로 다른 검사이므로, 서명이 최신이어도 만료된 토큰이 연장되지 않습니다. 이 예제는 만료됐거나 만료 시각이 잘못된 이벤트, 관련 없거나 형식이 잘못된 비밀번호 재설정 이벤트를 메일 발송 없이 무시합니다. 서명 정보가 없거나 잘못되면 401로 거부하며, 서명 없이 처리하는 대체 경로는 없습니다.
재설정 발송은 일반 컬렉션 웹훅과 별개라서, 그 outbox 보존, 재전달, 서명 보장이 적용되지 않습니다. 플랫폼 이메일 로그는 토큰 저장소, 영구 개인정보 보관소, 메일 도착의 근거, 재발송 허가가 아닙니다. 기존 이메일 결과, 보존, 고객 정보 파기 규칙을 그대로 지키세요.
고객 셀프 탈퇴
로컬 비밀번호가 있는 로그인 고객이
client.customer.auth.withdraw(currentPassword)를 호출하면 { withdrawnAt }을
반환합니다. 성공하면 계정이 즉시 보관 처리되고 SDK 인증 정보가 지워집니다. 앱의
세션 쿠키와 고객 캐시도 지우세요. 보관된 계정은 기존 세션을 쓰거나 다시 로그인할 수
없습니다.
로그인한 고객 본인만 탈퇴할 수 있으며 Secret Key로 고객을 대신해 탈퇴시킬 수 없습니다. 탈퇴하면 계정 개인정보를 바로 파기하며 되돌릴 수 없습니다. 주문, 결제, 멤버십 결제 기록은 유지됩니다. 파기 후에는 같은 이메일로 새 고객으로 다시 가입할 수 있습니다. 자세한 내용은 고객 셀프 탈퇴를 참고하세요.
SDKError.apiReason | 상태 | 안내 |
|---|---|---|
active_membership_exists | 409 | 멤버십을 먼저 종료해야 합니다. 해지 예약 상태도 멤버십 상태가 끝날 때까지 탈퇴를 막습니다. |
reauthentication_unavailable | 409 | 로컬 비밀번호가 없습니다. 탈퇴 이메일 코드를 요청하세요. |
password_reauthentication_required | 409 | 로컬 비밀번호가 있는 계정입니다. 현재 비밀번호로 확인하세요. |
withdrawal_session_refresh_required | 409 | 코드 요청 전에 현재 세션을 한 번 refresh하세요. |
account_locked | 423 | 비밀번호를 여러 번 틀렸습니다. 로그인 시도 횟수와 공유되며, 임시 잠금이 끝난 뒤 다시 시도합니다. |
탈퇴가 거절되면 SDK는 고객의 로그인 상태를 유지합니다.
소셜 전용 계정 등 로컬 비밀번호가 없는 고객은 저장된 이메일로 탈퇴 전용 코드를
요청한 뒤 같은 refresh-token family 세션에서 제출합니다. 6자리 코드는 10분 안에 한 번만
사용할 수 있고, 다시 요청하면 이전 코드는 무효가 됩니다. 검증에 10번 실패하면
15분 동안 차단되며 재발급으로 실패 횟수가 초기화되지 않습니다. 같은 family에서
refresh해도 코드는 유효하며 새 로그인/family에서는 새 코드가 필요합니다. 코드 요청은
JSON {}을 보내며 워크스페이스 내 고객당 분당 5회, 시간당 10회로 제한합니다.
기존 토큰에 안정적인 세션 claim이 없으면 코드 발급 전에 한 번 refresh해야 합니다.
직접 세션은 customer.auth.refreshToken(), hosted 세션은 customer.oauth.refresh()를
사용하고 반환된 토큰 쌍을 저장한 뒤 다시 요청하세요. 이 코드 발급 전용 409 reason은
CustomerWithdrawalCodeReauthenticationReason 타입으로 제공합니다. 새 약관 동의나 로그인 세션 발급은 필요하지 않습니다. 발송 실패·거부와 증명 저장소
장애는 503 service_unavailable로 응답합니다. Sandbox capture는 기존 규칙을 따릅니다.
import { createClient } from '@01.software/sdk'
type WithdrawalClient = ReturnType<typeof createClient>
export async function requestWithdrawalCode(client: WithdrawalClient) {
await client.customer.auth.requestWithdrawalCode()
}
export async function withdrawWithEmailCode(
client: WithdrawalClient,
emailCode: string,
) {
const { withdrawnAt } = await client.customer.auth.withdraw({ emailCode })
return withdrawnAt
}지금 탈퇴와 예약 탈퇴
위의 mode 없는 호출은 기존 응답과 멤버십 거절을 유지합니다. 직접 선택하려면 withdraw({ mode: 'now' | 'scheduled', currentPassword })를 호출하세요. 지금 탈퇴 전에 이용권 종료를 설명하고, 워크스페이스의 일반 환불 정책과 법정 권리 접수 연락처를 각각 안내하세요. 탈퇴가 환불이나 청약철회 권리의 포기를 뜻하지 않습니다.
예약 신청의 executeAt은 남은 이용권 중 마지막 종료 시각으로 고정됩니다. 남은 이용권이 없으면 appliedMode은 now입니다. getWithdrawalReservation()으로 상태와 신청·실행 예정 시각만 조회합니다. cancelWithdrawalReservation({ currentPassword })로 취소하면 renewalResumed: false를 반환하며 자동 갱신을 재개하지 않습니다.
비밀번호가 없는 계정은 requestWithdrawalCode({ purpose: 'reservation' })로 코드를 발급하고, 신청·취소에 currentPassword 대신 emailCode를 넣으세요. 이 코드와 기존 즉시 탈퇴 코드는 서로의 목적에 쓸 수 없습니다. 발급 제한과 증명 실패 횟수는 두 목적이 공유합니다. 재발급은 같은 목적의 이전 코드를 무효화하며, 소비한 코드는 이후 DB 쓰기가 rollback돼도 다시 쓸 수 없습니다.
| 결과 | 의미 | SDK 자격증명 |
|---|---|---|
HTTP 202, scheduled 또는 accepted | 예약이 commit됐고 보관 완료는 확인되지 않았습니다. | 로컬에서 유지합니다. |
HTTP 200, withdrawn | withdrawnAt에 보관 전이가 commit됐습니다. erasureStatus의 erased, pending, unknown은 따로 확인합니다. | 로컬에서 삭제합니다. |
HTTP 503, retryable: true, Retry-After | 잠금 획득 실패의 rollback이 확인됐습니다. | 로컬에서 유지합니다. |
HTTP 503, details.stateConfirmed: false | commit 결과가 불명확합니다. retryable 생략은 false이며, 다음 동작 전에 예약이나 me()를 조회하세요. | 로컬에서 유지합니다. |
SDK는 이 POST들을 자동 재시도하지 않습니다. 취소 conflict가 나면 예약 상태를 다시 조회하세요. me() 조회 실패만으로 탈퇴 완료 여부를 단정하지 마세요.
non-commerce SDK 오류의 HTTP envelope는 error.details.body에 보존됩니다.
불명확한 결과는 그 안의 body.details.stateConfirmed === false로 구분하세요.
상태 불확실 응답에는 receiptToken이 없습니다. 기존 예약을 다시 신청해도 null이므로 커밋된 응답과 함께 잃은 토큰은 복구할 수 없습니다. 이메일 코드가 소비된 뒤 retryable: true인 503 응답을 받을 수 있습니다. 이때도 새 코드를 발급받아 재시도하세요.
반환된 receiptToken은 비공개로 보관하세요. 재신청으로 기존 토큰을 다시 받을 수 없습니다. getWithdrawalReceipt(receiptToken)은 고객 로그인 없이 클라이언트의 Publishable Key가 가리키는 워크스페이스와 토큰을 함께 검증합니다. 응답에는 처리 상태와 시각만 있으며, 없는 영수증·만료·취소는 같은 404입니다. 응답은 캐시하지 않으며 ingress 기준 조회 제한의 429에는 Retry-After가 있습니다. 영수증은 마지막 상태 변경 뒤 30일에 만료됩니다. 토큰을 URL이나 로그에 넣지 마세요.
Hosted 세션 메서드
customer.oauth는 선택 사항이므로 createAuthorizationUrl, parseCallback, exchangeCode, refresh, logout을 호출하기 전에 설정됐는지 확인하세요. parseCallback에는 저장한 expectedState를 넘기고, 교환에는 원래 PKCE verifier와 등록한 redirect URI를 씁니다. 교환한 뒤 신뢰할 수 있는 클라이언트에 액세스 토큰을 설정하고 customer.auth.me()로 사용자를 읽으세요. 토큰 교체는 직렬화하고, 서버가 관리하는 액세스·리프레시 토큰 쌍을 하나만 유지하세요.
호스티드 로그아웃의 success는 요청을 수락했다는 뜻이며, 정리가 실제로 일어났는지는 앱에 보이지 않습니다. 같은 워크스페이스와 클라이언트에 발급된 만료되지 않은 리프레시 토큰으로 인식되면, 이미 교체됐거나 family가 철회된 뒤에도 정리합니다. 토큰이 없거나 만료됐거나 다른 클라이언트의 것이면 정리하지 않으며, 앱은 이 경우들을 구분할 수 없습니다. 정리는 워크스페이스 안에서 그 고객의 모든 클라이언트(legacy-direct 포함)를 대상으로 하며, 그 로그아웃의 기준 시점 이전에 발급된 family를 없앱니다. 같은 로그아웃을 내부에서 재시도해도 기준 시점 이후에 발급된 family는 철회하지 않으므로, 로그아웃 뒤에 시작한 세션은 그 재시도에서 살아남습니다. 앱이 새로 보낸 로그아웃 요청은 별개입니다. 서비스가 그 토큰을 인식하면 이전 로그아웃 뒤에 시작한 세션까지 다시 정리합니다. 쿠키 정리와 revocation_unconfirmed 처리는 로그인·로그아웃 전체 작업을 따르고, 정리 실패가 원래 오류를 덮지 않게 하세요.
고객 적립금
Console → 적립금에서 tenant-admin은 기본 유효기간(1–1825일), 사용 한도(1–100%), 고정·범위형 지급 템플릿을 정합니다. 템플릿을 편집하면 바뀌지 않는 새 버전이 만들어집니다.
화면에서는 지급 방식·금액·유효기간으로 템플릿을 구분하고, 버전 옆의 템플릿 ID를 펼쳐 서버 호출에 사용할 수 있습니다. 고객 적립금 조회 → 고객 선택에서 이름이나 이메일로 고객을 찾아 선택한 뒤 조회를 누르세요. 조회 시각과 만료 시각은 브라우저의 로컬 시간대로 표시됩니다.
고객 선택 목록은 보관·탈퇴·익명화된 고객을 제외합니다. 이런 고객이나 목록에 없는 고객의 보존된 적립금 내역은 ID로 조회를 펼쳐 고객 ID를 입력한 뒤 조회로 확인하세요. 고객 기능이 꺼져 목록을 사용할 수 없어도 이 보조 경로를 쓸 수 있습니다. 서버는 같은 워크스페이스와 적립금 읽기 권한을 다시 확인하며, 이 조회가 고객 정보나 계정을 복원하지는 않습니다.
다른 워크스페이스 역할은 설정과 고객 내역을 읽을 수 있습니다. 템플릿을 보관하면 새 지급만 멈추고 기존 지급 기록은 그대로 둡니다.
적립금 설정은 꺼진 상태로 시작합니다. 끄면 새 지급과 적립금 예약이 막히지만, 기존 사용, 반환, 환불 복원, 만료, 회수, 조회는 계속 됩니다. 이 설정으로 이커머스 기능 요건을 건너뛸 수는 없습니다.
server.credits.grant, getGrant, getAccount, revoke는 sk01_ 자격 증명을 쓰는 신뢰할 수 있는 워크스페이스 서버에서 호출하세요. 지급과 회수에는 쓰기 권한, 조회에는 읽기 권한이 필요합니다. 브라우저 클라이언트에는 로그인한 고객 세션과 Publishable Key로 쓰는 client.credits.mine(), client.credits.history({ page })만 있습니다.
- 한 페이지에는 최대 50건이 있습니다.
- 잔액과 내역 금액은 KRW 정수 값의 십진수 문자열이며, JavaScript의 안전한 정수 범위를 넘는 합계도 포함합니다. 이런 합계를
Number로 바꾸지 마세요. - 잔액 묶음은 원래 만료일과 복원 유예 기간을 구분하며, 유예 기간은 원금 부분마다 최대 한 번 주어집니다. 유예 기간은 168시간이며 연장되지 않습니다.
연납 회원의 월별 지급 예시
스케줄러, 멤버십 자격, 해지 정책은 워크스페이스가 정합니다. 이 함수를 실행하기 전에 회원이 선택한 달의 지급 대상인지 확인하세요. 연간 결제만으로 앞으로의 월별 지급이 생기지 않습니다.
import { createServerClient } from '@01.software/sdk/server'
type CreditServer = ReturnType<typeof createServerClient>
export async function grantMembershipMonth(
server: CreditServer,
input: {
customerId: string
fixedTemplateId: string
membershipId: string
month: string
},
) {
const source = {
sourceNamespace: 'membership-month',
sourceRef: `${input.membershipId}:${input.month}`, // month: YYYY-MM in the tenant's calendar
}
await server.credits.grant({
...source,
customerId: input.customerId,
templateId: input.fixedTemplateId,
})
return server.credits.getGrant(source)
}
export async function revokeMembershipMonth(
server: CreditServer,
input: { membershipId: string; month: string },
) {
return server.credits.revoke({
sourceNamespace: 'membership-month',
sourceRef: `${input.membershipId}:${input.month}`,
})
}- 놓친 대상 월은 원래의 월 식별자로 실행하세요. 결과가 불확실한 요청은 템플릿, 고객, 출처 식별자를 정확히 같게 해서 재시도합니다.
- 같은 내용으로 다시 보내면 설정이 바뀐 뒤에도 한 번만 지급됩니다. 같은 식별자에 다른 내용을 보내면 충돌합니다.
- 고정 템플릿은
amount를 받지 않고, 범위형 템플릿은 최대값 이하의 정확한 양의 정수amount가 필요합니다. - 회수는
revoked,pending,spent를 따로 알려 주고, 빚을 만들지 않으며, 이후의 반환이나 복원은 회수된 금액으로 보냅니다. - 멤버십 해지나 환불 때 회수할지는 워크스페이스가 결정합니다. 플랫폼 cron, 캠페인 엔진, 현금 충전, 고객 간 양도, 현금 인출은 없습니다.
기존 Commerce Checkout에서 사용
기존 Checkout 생성 요청과 원래의 멱등 키에 정확한 양의 안전한 정수 creditAmount를 함께 보내세요. 적립금을 쓰려면 검증된 고객 증명, 활성 이커머스, 켜진 적립금 설정, 사용 가능한 잔액, 사용 한도 이내의 금액이 필요합니다. 브라우저에 보이는 잔액이나 Accept 헤더는 결제를 허가하지 않습니다.
SDK는 Checkout 생성·상세·조회의 성공 응답에 application/vnd.01software.credit-funding.v1+json을 요청합니다. 이 Accept 값 전체(앞뒤 공백 제거, 대소문자 무시)일 때만 해당하며, 목록, 와일드카드, 매개변수는 해당하지 않습니다. 이때 응답은 그 Content-Type과 Cache-Control: private, no-store를 쓰고, 오류는 일반 JSON 형식을 유지합니다. 공개 funding 객체에는 creditAmount와 providerPayableAmount만 있습니다. 구매 합계(checkout.amounts.totalMinor)는 적립금·PG 금액과 따로 보여 주세요. 전액 적립금 결제는 PaymentSession이 필요 없고, 혼합 결제는 고정된 결제사 결제 금액을 청구합니다. 환불해도 원래 구매의 적립금·결제사 배분 기록은 바뀌지 않으며, 그 환불에 귀속된 적립금만 복원합니다.
이전 서버는 새 적립금 입력을 업무 데이터를 쓰기 전에 거부합니다. SDK는 이 입력을 빼거나 0으로 바꾸거나, 다른 endpoint를 시험하거나, 적립금 없이 재시도하지 않습니다. funding이 없는 응답은 적립금 지원 여부를 알 수 없는 이전 응답이며, 적립금이 0이라는 뜻이 아닙니다. 적립금으로 생성한 뒤 funding이 없거나 맞지 않으면 결과가 불확실한 것입니다. Checkout을 조회하거나 원래 멱등 키로 정확히 같은 요청을 재시도하세요. 전액 PG 결제로 대체하지 마세요.
server.commerce.orders.returnWithRefund에서는 원래 주문의 고정된 provider_payable이 0인 경우(전액 적립금 주문)에만 pgPaymentId를 생략하세요. 혼합 주문은 이번 환불이 적립금만 복원하고 결제사 금액이 0이어도 원래 매입(capture) 식별자가 필요합니다. 전액 적립금 주문은 pgPaymentId나 paymentKey를 모두 거부하므로 식별자를 지어내지 마세요. 명시적인 빈 값은 여전히 유효하지 않습니다. 적립금 결제가 없는 주문은 pgPaymentId를 생략하면 계속 400/validation_failed를 반환합니다. 환불 금액, 자격 증명, 워크스페이스, 이커머스 권한 규칙은 바뀌지 않습니다.
선택적 핸들 비밀번호 로그인
워크스페이스 관리자는 기존 Admin Panel 고객 인증 설정에서 customerAuth.handleLoginEnabled를 켤 수 있습니다. 기본값은 꺼짐입니다. 별도 비공개 로그인 ID가 아닌 현재 계정 핸들을 쓰며, 핸들 migration과 서버 배포가 끝난 뒤 활성화합니다.
기존 이메일·비밀번호 요청은 유지됩니다. 활성화된 워크스페이스에서는 직접 client.customer.auth.login({ handle, password })에 이메일 대신 핸들 하나를 보냅니다. 둘 다 보내거나 둘 다 없으면 검증 오류입니다. CustomerLoginData의 기존 이메일 인터페이스는 유지되고 CustomerPasswordLoginData가 핸들 분기를 추가합니다. 저장된 이메일과 로컬 비밀번호가 있는 계정만 핸들 로그인을 사용할 수 있습니다. 설정을 꺼도 이메일 로그인, 공개 핸들, 기존 세션은 유지됩니다.
신규 스토어는 hosted OAuth를 권장합니다. hosted 서버의 비밀번호 endpoint도 핸들 입력을 받지만 hosted 로그인 화면에는 아직 핸들 입력이 없습니다. 비밀번호 없는 이메일 코드와 비밀번호 재설정은 이메일을 계속 사용합니다. 변경하거나 지운 이전 핸들은 로그인 별칭으로 남지 않습니다. 이메일과 핸들에는 같은 로그인 오류·잠금 규칙이 적용됩니다.