Payments
결제 action 재사용, reconcile과 서버 전용 custom bridge.
각 메서드는 절마다 적힌 호출 위치에서만 쓰세요. 먼저 자격 증명을 설정하고, import와 peer 의존성은 SDK를 참고하세요.
관리형 결제사
Checkout paymentState.activePaymentSession.provider와 PaymentSession 응답은 닫힌 결제사 집합 하나를 씁니다.
| 결제사 | 사용 가능 여부 |
|---|---|
toss | 사용 가능 |
portone | 사용 가능 |
stripe | 예약됨. Stripe를 사용할 수 있게 된 뒤에만 서버가 이 값을 보냅니다. |
Stripe를 사용할 수 있게 되기 전에 Checkout을 읽는 모든 스토어프런트와 서버를 @01.software/sdk 0.72.1 이상으로 올리세요. 0.72.1부터는 provider: 'stripe'인 Checkout reference를 정상적으로 읽고, 이전에 발행된 SDK는 이를 잘못된 응답으로 거부합니다. PaymentSession 메서드는 0.72.1에서도 Stripe 세션을 거부합니다.
결제 연결 환경 선택
Admin Panel에서 관리형 결제 연결을 만들 때 Live에서는 라이브, Sandbox에서는 테스트를 선택하세요. 테스트 결제 연결이 필요하면 사이드바의 프로젝트/환경 선택기에서 Sandbox로 전환한 뒤 연결을 만드세요. Sandbox가 없으면 선택기에서 먼저 만드세요.
서버 취소의 시작 주체 기록
서버 commerce.paymentSessions.cancel과 cancelPortOne은 선택 필드 initiatedBy: 'customer' | 'operator'를 받습니다. 생략하면 operator를 쓰며, SDK는 호출자가 지정한 경우에만 이 필드를 전송합니다. 브라우저와 공개 키 요청은 이 필드를 거부하고, 서버 요청도 다른 값은 거부합니다.
인증된 워크스페이스 서버에서 고객의 명시적인 취소나 예약 해제 요청을 확인했을 때 customer를 쓰세요. 콘솔은 서버가 보고한 시작 주체를 기록하고, 같은 서버 자격 증명, 쓰기 권한, 워크스페이스 소유권과 취소 가능 상태를 검사합니다. PortOne Standard의 취소 제한은 유지됩니다.
원래 멱등 키로 재시도하세요. cancellationReason은 먼저 저장된 취소 귀속을 보존하며, 최종 무캡처 확인 방식이나 실제 고객 이탈 원인을 뜻하지 않습니다. USER_CANCEL, 카드사 거절과 잘못된 파라미터가 섞인 정리 경로에서는 결과를 구분한 뒤 고객 취소를 보고하세요.
PortOne Hosted close 성공 후에도 콘솔이 무캡처를 확인할 때까지 세션은 열려 있고 매입 가능성이 남아 있습니다. close 후 해제가 지연되더라도 이후 만료 조회·webhook·reconcile의 해제는 저장된 취소 귀속을 유지합니다. initiatedBy를 생략한 구 SDK 서버 호출과 브라우저 취소(customer)에도 적용됩니다.
만료, 결제사 취소 또는 실패(failed)로 종결된 Hosted 세션에 멱등 키의 close 결과가 저장되어 있지 않으면, 취소 요청은 409 payment_session_not_cancelable을 반환하고 종결 상태를 유지합니다. 콘솔이 해당 키의 close 결과를 저장한 경우에만 같은 키로 저장된 결과를 받을 수 있습니다. 이 경우 다른 키는 409 idempotency_conflict를 반환합니다.
저장된 사유가 없거나 null인 경우 콘솔 해제는 기존 expired·provider 분류를 적용합니다. 템플릿 공통 실패 정리는 initiatedBy 없이 서버 취소를 호출하므로 저장된 취소 귀속이 없으면 operator로 기록합니다. 과거 기록은 백필하지 않습니다.
콘솔 지원 배포, SDK 게시, 템플릿이나 앱에서 새 필드 사용 순서로 적용하세요. 구 콘솔은 새 필드를 400 validation_failed로 거부하며 취소나 슬롯 해제를 실행하지 않습니다. 필드를 생략한 호출은 기존 요청 형태를 유지하지만, 이후 공개되는 cancellationReason 값은 expired·provider 대신 operator·customer를 유지할 수 있습니다.
PortOne Standard V2 취소와 재시도
portone_sdk 다음 action은 cancelable: false와 retryPolicy: 'same_action_only'를 선언합니다. PaymentSession 취소 메서드를 호출하기 전에 이 값을 확인하세요. 브라우저 실패, 이탈, 로컬 만료가 있거나 결제사에서 성공 결과를 받지 못해도 Checkout의 결제 자리가 비거나 새 결제 ID를 쓸 수 있게 되지 않습니다.
SDK는 Standard 세션을 만들 때와, 프로필을 모르는 상태에서 브라우저나 서버에서 PortOne 세션을 조회할 때 이 capability 필드를 요청합니다. Hosted 생성과 취소 호출은 요청하지 않습니다. SDK는 배포된 Console이 기본으로 돌려주는 action도 받아 같은 고정값을 채우므로, SDK와 Console을 따로 업그레이드할 수 있습니다.
HTTP를 직접 호출하면 기본 action 형태를 받습니다. 생성이나 조회에서 capability 필드를 받으려면 Accept: application/vnd.01software.portone-action-capabilities.v1+json을 보내세요.
- 헤더에는 이 미디어 타입 하나만 있어야 합니다. 앞뒤 공백은 허용합니다. 목록, 와일드카드, 매개변수, q-value가 있으면 기본 형태를 받습니다.
- 응답은 비공개
no-store입니다. - 이 요청에 성공해 Standard capability action을 받을 때만
Content-Type이 이 미디어 타입이 됩니다. Hosted, Toss, 취소, 확정, 오류 응답은application/json그대로입니다.
import { createClient } from '@01.software/sdk'
type Client = ReturnType<typeof createClient>
type Session = Awaited<
ReturnType<Client['commerce']['paymentSessions']['retrievePortOne']>
>
export async function retryStandardAction(
session: Session,
requestPayment: (request: unknown) => Promise<unknown>,
) {
if (
session.presentationStatus === 'actionable' &&
session.nextAction.type === 'portone_sdk'
) {
const { cancelable, retryPolicy, ...paymentRequest } = session.nextAction
if (!cancelable && retryPolicy === 'same_action_only') {
// Adapter calls PortOne.requestPayment with this exact request.
return requestPayment(paymentRequest)
}
}
return null
}브라우저 결과가 나온 뒤에는 같은 PaymentSession을 조회하세요. 결제가 최종 확정됐는지는 Console만 인증된 PortOne 결제 조회로 판단합니다. 웹훅은 Console이 그 조회를 하도록 알릴 뿐이며, 웹훅 자체는 결제의 근거가 아닙니다.
서버 전용 PortOne identity reconcile
createServerClient().commerce.paymentSessions.findPortOne/reconcilePortOne은 관리형 PortOne 결제를 전체 식별자 provider + providerAccountKey + environment + providerPaymentId로 찾습니다. 워크스페이스는 서버 자격 증명에서만 정해지며, 요청 본문에 워크스페이스 ID를 넣으면 거부합니다.
import { createServerClient } from '@01.software/sdk/server'
type Server = ReturnType<typeof createServerClient>
export async function reconcilePayment(
server: Server,
selector: Parameters<Server['commerce']['paymentSessions']['findPortOne']>[0],
idempotencyKey: string,
) {
const local = await server.commerce.paymentSessions.findPortOne(selector)
const reconciled = await server.commerce.paymentSessions.reconcilePortOne({
...selector,
idempotencyKey,
})
return { local, reconciled }
}findPortOne은 로컬 데이터만 읽습니다.reconcilePortOne은 쓰기 권한과 내구성 있는 멱등 키가 필요하며, 기존 관리형 대사 처리를 실행합니다. 응답이 중단됐다면 같은 키로 다시 호출하세요. Console은 결제사를 다시 호출하지 않고 처음 커밋한 결과를 돌려줍니다. 처리 결과는applied,observed,recovered_after_ambiguous_result,requires_attention중 하나입니다.- 로컬에서 찾지 못하면 HTTP와 SDK 모두
{ found: false }를 반환합니다. 대사 처리는 모르는 결제사 결제를 가져가거나 추측으로 Checkout이나 주문에 연결하지 않습니다. - 두 메서드 모두 브라우저 클라이언트에는 없습니다.
서버 전용 커스텀 결제 bridge
createServerClient().commerce.paymentSessions.custom.create/retrieve/confirm은 내장 어댑터가 없는 결제사를 지원합니다. 이 네임스페이스와 타입은 @01.software/sdk/server에만 있으므로 신뢰할 수 있는 서버에서만 쓰세요. bridge는 앱이 직접 검증한 매입(capture)을 기록합니다. 관리형 PSP 어댑터, 범용 "결제 완료 처리" API, 결제를 대신 실행하는 연동, 환불 인터페이스, 수기·오프라인 대기 주문 흐름이 아닙니다.
시작하기 전에
- 서버가 이미 권한을 확인한 앱 세션과 구매에 id와 토큰을 연결해 둔, 조건에 맞고 열려 있으며 만료되지 않은 Checkout
- Admin Panel에서 선택한 워크스페이스의 Custom Payment Providers에 이 Checkout의 마켓과 지원 통화용 결제사가 활성화돼 있을 것
- 그 워크스페이스의 Publishable Key와 Secret Key를 함께 쓰고 필요한 쓰기 권한이 있는 서버 클라이언트
커스텀 결제사 slug가 관리형 결제사 이름과 같아도 관리형 세션이 되거나 관리형 어댑터의 확인을 건너뛰지 않습니다.
앱이 제공할 것
아래 custom.ts 모듈은 이 신뢰할 수 있는 입력을 받습니다. 구매 흐름에서 원래의 유효한 UUIDv4(또는 지원되는 ik_ 키), 정규화한 결제사, 원래의 결제사 결제 매핑을 넘기세요. 모듈의 원자적·내구성 있는 확보 단계가 SDK 요청 전에 생성 키, 본문, 결제 매핑을 고정하며, 동시 실행이나 중단된 시도도 같은 값을 다시 써야 합니다. 결제사 작업 전에 생성 결과의 id, provider, providerSessionId, money만 저장하세요. 저장소는 바뀐 연결, 세션, 매입, 영수증을 덮어쓰지 말고 거부해야 하며, Checkout 권한 토큰을 암호화하는 등 저장 중에도 보호해야 합니다.
저장소, 결제사 검증기, 비밀값 읽기는 앱이 구현하는 타입 포트이며 SDK나 플랫폼 서비스가 아닙니다.
- 저장소는 사용하는 저장 엔진에서 내구성 있는 원자적 고유성과 compare-and-set을 제공합니다.
- 검증기는 결제사 API 응답이나 서명된 알림을 인증합니다. 예상한 워크스페이스·결제사·계정·환경, 정확한 세션·결제·이벤트 식별자, 매입 금액을 확인하고 검증한 매입만 돌려줍니다. 저장된 결제를 관찰할 뿐 새 결제를 시작할 수 없습니다.
- 결제사에서 결제를 실행하는 일과 그 원래 결제 매핑은 이 bridge 밖, 앱의 신뢰할 수 있는 흐름이 맡습니다. 리디렉션, 클라이언트의 성공 표시, HMAC,
retrieve상태만으로는 매입을 증명하지 못합니다.
HMAC 증명
hmacRequired는 신뢰할 수 있는 연동 설정에서 정하세요. Console 실행 환경이 NODE_ENV === 'production'이고 세션 마켓이 US이면 증명이 필요합니다. 이는 워크스페이스의 Production·Sandbox environmentKind와 다릅니다. 호스팅된 스테이징 환경의 조건은 짐작하지 말고 확인하세요.
- Admin Panel의
env:비밀값 참조는 Console 배포의 환경 변수에서 찾습니다. 앱 서버에만 같은 이름을 설정하면 적용되지 않습니다. - 이 비밀값을 발급·공유하는 공개 절차는 아직 없으며, 누가 맡을지는 별도의 운영 결정입니다.
- 증명이 필요한데 Console이 참조를 찾지 못하면 확정이
hmac_secret_missing으로 실패합니다. 참조를 찾더라도 증명과 모든 식별자·매입·금액 검사를 통과해야 합니다. - 비밀값 원문을 문서, 브라우저·공개 환경 변수, 로그에 넣지 마세요.
secrets.readCustomPaymentSecret은 앱이 같은 비밀값에 접근할 수 있도록 따로 허가받았다고 가정하며, Console의 비밀값을 가져오거나 공유하지 않습니다. SDK의 createProof(rawBody)는 전송할 JSON 바이트를 그대로 넘겨줍니다. 유효한 ISO 타임스탬프와 함께 ${timestamp}.${rawBody}에 HMAC-SHA256으로 서명하고 64자리 hex 서명을 돌려주세요. Console은 ±5분을 허용합니다. 미들웨어에서 본문을 다시 직렬화하지 말고, Membership이나 발신 웹훅의 서명 방식을 쓰지 마세요. Console은 이미 본 서명을 따로 기록하지 않으므로 재시도는 식별자·재전송 검사가 통제합니다. 앱이 만든 증명은 앱을 인증할 뿐 PSP 매입을 인증하지 않습니다.
import { createHmac } from 'node:crypto'
import { createServerClient } from '@01.software/sdk/server'
type Server = ReturnType<typeof createServerClient>
type Custom = Server['commerce']['paymentSessions']['custom']
type Session = Awaited<ReturnType<Custom['create']>>
type Confirmation = Awaited<ReturnType<Custom['confirm']>>
type Money = Session['money']
type Authority = {
tenantId: string
provider: string
providerAccountId: string
providerEnvironment: string
}
// Trusted app inputs, bound to the authorized app session/purchase, not a form.
export type CustomPurchase = Authority & {
authorityId: string
purchaseId: string
checkoutId: string
checkoutToken: string
idempotencyKey: string
providerPaymentId: string
hmacRequired: boolean
}
export type VerifiedCapture = Authority & {
providerSessionId: string
providerPaymentId: string
providerEventId: string
money: Money
occurredAt: string
}
type Retained = {
session: Session | null
capture: VerifiedCapture | null
confirmation: Confirmation | null
}
// All three ports below are app-owned requirements, not SDK services.
export type CustomPaymentStore = {
// Atomic unique claim by tenant + authority + purchase AND Checkout.
// Persist the original key/body and payment mapping before returning.
// An existing claim must match EVERY purchase field or throw; never replace.
claim(purchase: CustomPurchase): Promise<void>
read(purchase: CustomPurchase): Promise<Retained>
// Atomic compare-and-set: retain once, accept exact replay, otherwise throw.
// Retain only these typed fields, scoped to the original purchase claim.
keepSession(purchase: CustomPurchase, session: Session): Promise<void>
keepCapture(purchase: CustomPurchase, capture: VerifiedCapture): Promise<void>
keepConfirmation(
purchase: CustomPurchase,
result: Confirmation,
): Promise<void>
}
export type CustomPaymentPorts = {
store: CustomPaymentStore
provider: {
// Authenticate provider API/signed notification; verify tenant, provider,
// account, environment, session/payment/event identity and captured Money.
// Observe only the retained payment; this port must never create/charge it.
verifyCapture(
expected: Authority & {
providerSessionId: string
providerPaymentId: string
money: Money
},
): Promise<
| { kind: 'captured'; capture: VerifiedCapture }
| { kind: 'unknown' | 'failed' | 'rejected' }
>
}
secrets: {
// Separately provisioned app access to the SAME secret Console resolves.
// This is neither SDK secret retrieval nor automatic platform sharing.
readCustomPaymentSecret(authority: Authority): Promise<string>
}
}
function sameMoney(left: Money, right: Money) {
return (
left.amountMinor === right.amountMinor && left.currency === right.currency
)
}
function captureFor(
purchase: CustomPurchase,
session: Session,
value: VerifiedCapture,
): VerifiedCapture {
if (
value.tenantId !== purchase.tenantId ||
value.provider !== purchase.provider ||
value.providerAccountId !== purchase.providerAccountId ||
value.providerEnvironment !== purchase.providerEnvironment ||
value.providerSessionId !== session.providerSessionId ||
value.providerPaymentId !== purchase.providerPaymentId ||
!value.providerEventId.trim() ||
!Number.isFinite(Date.parse(value.occurredAt)) ||
!sameMoney(value.money, session.money)
)
throw new Error(
'Captured identity or Money mismatch; retain and investigate',
)
// Allowlist: never persist or forward a structurally assignable PSP object.
return {
tenantId: value.tenantId,
provider: value.provider,
providerAccountId: value.providerAccountId,
providerEnvironment: value.providerEnvironment,
providerSessionId: value.providerSessionId,
providerPaymentId: value.providerPaymentId,
providerEventId: value.providerEventId,
money: {
amountMinor: value.money.amountMinor,
currency: value.money.currency,
},
occurredAt: value.occurredAt,
}
}
export async function recoverCustomPayment(
server: Server,
providerSessionId: string,
provider = 'portone',
) {
const recovered = await server.commerce.paymentSessions.custom.retrieve({
provider,
by: { providerSessionId },
})
if (recovered.finalizationStatus === 'committed') {
return { kind: 'committed' as const, orderId: recovered.orderId }
}
return { kind: 'pending' as const, recovered }
}
export async function completeCustomPayment(
server: Server,
purchase: CustomPurchase,
ports: CustomPaymentPorts,
) {
const { store } = ports
await store.claim(purchase)
const retained = await store.read(purchase)
let savedSession = retained.session
if (!savedSession) {
const created = await server.commerce.paymentSessions.custom.create({
provider: purchase.provider,
checkoutToken: purchase.checkoutToken,
idempotencyKey: purchase.idempotencyKey,
})
savedSession = {
id: created.id,
provider: created.provider,
providerSessionId: created.providerSessionId,
money: {
amountMinor: created.money.amountMinor,
currency: created.money.currency,
},
}
await store.keepSession(purchase, savedSession)
}
const session = savedSession
if (session.provider !== purchase.provider)
throw new Error('Provider mismatch')
const authority: Authority = {
tenantId: purchase.tenantId,
provider: purchase.provider,
providerAccountId: purchase.providerAccountId,
providerEnvironment: purchase.providerEnvironment,
}
const retrieve = async () => {
const observed = await server.commerce.paymentSessions.custom.retrieve({
provider: session.provider,
by: { providerSessionId: session.providerSessionId },
})
if (
observed.id !== session.id ||
observed.provider !== session.provider ||
observed.providerSessionId !== session.providerSessionId ||
(observed.providerPaymentId !== null &&
observed.providerPaymentId !== purchase.providerPaymentId) ||
!sameMoney(observed.money, session.money)
)
throw new Error('Recovery identity or Money mismatch')
return observed
}
await retrieve() // Observation only; never infers a capture.
let capture = retained.capture
if (!capture) {
const verified = await ports.provider.verifyCapture({
...authority,
providerSessionId: session.providerSessionId,
providerPaymentId: purchase.providerPaymentId,
money: {
amountMinor: session.money.amountMinor,
currency: session.money.currency,
},
})
if (verified.kind !== 'captured') {
return { kind: 'needs_attention' as const, observation: verified.kind }
}
capture = captureFor(purchase, session, verified.capture)
await store.keepCapture(purchase, capture)
}
capture = captureFor(purchase, session, capture)
let confirmation = retained.confirmation
if (!confirmation) {
// No confirm idempotency-key option. Replay the SAME retained capture.
const secret = purchase.hmacRequired
? await ports.secrets.readCustomPaymentSecret(authority)
: null
if (purchase.hmacRequired && !secret?.trim())
throw new Error('Missing app secret')
const result = await server.commerce.paymentSessions.custom.confirm(
{
provider: capture.provider,
providerSessionId: capture.providerSessionId,
providerPaymentId: capture.providerPaymentId,
providerEventId: capture.providerEventId,
amount: capture.money.amountMinor,
currency: capture.money.currency,
occurredAt: capture.occurredAt,
},
secret === null
? {}
: {
createProof(rawBody) {
const timestamp = new Date().toISOString()
return {
timestamp,
signature: createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex'),
}
},
},
)
confirmation = {
orderId: result.orderId,
transactionId: result.transactionId,
status: result.status,
}
await store.keepConfirmation(purchase, confirmation)
}
const settled = await retrieve()
if (
settled.providerStatus !== 'captured' ||
settled.finalizationStatus !== 'committed' ||
settled.providerPaymentId !== capture.providerPaymentId ||
!settled.orderId
)
return {
kind: 'needs_attention' as const,
observation: 'local_finalization',
}
const checkout = await server.commerce.checkouts.retrieve({
checkoutId: purchase.checkoutId,
})
if (
checkout.id !== purchase.checkoutId ||
checkout.status !== 'placed' ||
checkout.order.id !== confirmation.orderId ||
settled.orderId !== confirmation.orderId
)
throw new Error('Original Checkout/order mismatch; retain and investigate')
return { kind: 'committed' as const, orderId: confirmation.orderId }
}completeCustomPayment(server, retainedPurchase, ports)는 서버가 그 구매를 허용한 뒤에만 호출하세요. claim은 원래의 워크스페이스·앱 권한, 구매, Checkout 연결 아래에서 CustomPurchase의 모든 필드를 비교해야 하며, 정확한 생성 키와 본문, 결제사 결제 매핑도 포함합니다. read는 그 확보 건의 저장된 상태만 돌려주고, 각 keep*는 첫 값이나 정확히 같은 재전송만 원자적으로 받고 다른 값은 거부합니다. 프로세스 메모리의 map은 운영용 저장소가 아닙니다. 이 기록에 비밀값을 넣지 마세요.
첫 구매는 앱에서 다음 순서로 실행합니다.
- 원래 구매로
completeCustomPayment를 호출합니다. 읽기 전용 검증기에 묻기 전에 세션을 만들고 내구성 있게 저장합니다. 결제사에 결제를 보낸 적이 없다면unknown결과는 확정 없이needs_attention을 반환합니다. 이것이 결제 흐름으로 넘기는 정상 경로일 수 있습니다. - 결제 흐름에서
ports.store.read(retainedPurchase)를 읽습니다. 저장된 세션과, 이 구매가 결제사 결제를 한 번도 시작하지 않았음을 보여 주는 별도의 내구성 있는 발송 기록이 있어야 합니다. 결제사를 호출하기 전에 그 첫 발송을 원자적으로 확보하세요. 명령은 저장된session.providerSessionId, 정확한session.money, 원래의providerPaymentId, 신뢰할 수 있는 워크스페이스·결제사·계정·환경에 묶고, 결제사 어댑터에서 그 결제 하나만 실행합니다.unknown결과나 세션 기록만으로는 결제를 실행할 수 없습니다. 발송 확보와 결제사 호출은 앱의 일이며 SDK 메서드나verifyCapture의 동작이 아닙니다. - 같은 구매로
completeCustomPayment를 다시 호출합니다. 저장된 세션을 다시 만들지 않고 쓰며, 그 결제의 매입을 독립적으로 검증한 뒤에만 확정하고 원래 Checkout의 주문을 조회합니다.
발송이 시작됐거나 PSP 시간 초과처럼 결과가 불분명하면 다시 발송하거나 첫 발송 확보를 초기화하지 마세요. 매입을 검증할 수 있을 때까지 같은 저장된 결제를 계속 확인하거나, 운영 처리를 위해 보존하세요. 검증된 매입 없는 unknown은 이전 결제가 없었다는 근거가 아닙니다.
recoverCustomPayment는 관찰만 하며 결제를 완료하거나 매입을 증명하지 않습니다. retrieve는 providerSessionId나 providerPaymentId 중 정확히 하나를 받아 정확히 일치하는 항목을 조회하고, 제한된 상태, 금액, 연결된 orderId만 돌려줍니다. 세션 목록이나 원시 관계, 결제사 증거, 지문, 계정 정보, 자격 증명은 보여 주지 않습니다. 커스텀 세션은 checkout.paymentState.activePaymentSession에 항상 null로 나오지만 세션이 없다는 뜻은 아닙니다. providerSessionId를 보관하고 그 식별자로 복구하세요.
모듈은 confirm 전에 검증한 매입을 원래의 세션·결제·이벤트 id, 실제 매입된 최소 단위 금액, 통화, occurredAt으로 고정합니다.
- 생성 응답 유실: 같은 키와 본문으로 이어서 실행합니다.
- 확정 응답 유실: 저장한 같은 매입(capture) 값을 그대로 다시 보내고 다시 조회합니다. 증명만 현재 타임스탬프로 다시 계산할 수 있습니다.
confirm에는 멱등 키 인자가 없습니다. - 저장된 확정이 있어도 커스텀 세션과 Checkout을 새로 조회해야 합니다. 확정의
orderId, 커밋된 커스텀retrieve의orderId, **원래checkoutId**의 placed 주문이 모두 일치해야 결제가 완료된 것입니다. alreadyConfirmed로는 앱의 다른 부수 효과를 정확히 한 번만 실행할 수 없습니다. 아래에서 설명하는 별도의 내구성 있는 확보를 쓰세요.
다음은 모두 완료가 아닙니다. 발송 뒤 해결되지 않은 PSP 시간 초과나 unknown, 검증 실패, 금액·통화 불일치, 로컬 확정 대기, 응답 반영 실패, 내용을 알 수 없는 not-found, 권한 거부. 기록을 보존하고 같은 저장된 식별자와 매입을 확인한 뒤 운영 지원을 받으세요. 오류는 그대로 전달되며 모듈은 자동으로 재시도하지 않습니다. 매입·실패 결과를 지어내거나, 세션·키·결제사·결제를 바꾸거나, 복구를 피하려고 스냅숏을 지우지 마세요. 운영 책임은 커머스 운영을 참고하세요.
멱등 mutation 응답은 exactly-once 게이트가 아닙니다
어떤 커머스 변경 응답 필드도 운영 알림, 발신 웹훅, 원장 기록처럼 멱등하지 않은 부수 효과를 안전하게 한 번만 실행하는 기준이 될 수 없습니다. processOnce 웹훅 패턴처럼 부수 효과의 안정적인 식별자를 키로 한 내구성 있는 확보를 직접 두세요. 데이터베이스 고유 키, 큐의 멱등 저장소, 결제사에 안전한 발송 원장을 쓰고, 프로세스 메모리나 응답 필드에 기대지 마세요.
paymentSessions.custom.confirm의 alreadyConfirmed: true는 관찰 시점에 이미 확정돼 있었다는 뜻이며, 이번 호출이 상태를 바꿨는지는 알려 주지 않습니다. 첫 확정 성공 응답에는 이 필드가 없습니다. 응답 형태만 판단한다면 result.alreadyConfirmed === false 대신 'alreadyConfirmed' in result로 확인하세요. 어느 분기로도 멱등하지 않은 부수 효과를 실행하지 마세요. 첫 확정이 커밋됐는데 응답이 유실되면 재시도는 alreadyConfirmed: true를 반환하므로 그 변경을 놓치게 됩니다.
orders.cancelOrder의 cancelCommitted, alreadyCanceled는 주문에 일어난 결과를 나타낼 뿐 응답이 재전송인지 알려 주지 않습니다. 같은 멱등 키로 재시도하면 처음 2xx 본문을 바이트 그대로 돌려주므로 cancelCommitted: true가 다시 올 수 있습니다. 두 필드 모두 이번 응답이 재전송인지, 부수 효과가 이미 실행됐는지 알려 주지 않습니다. 예외는 콘솔이 환불하는 주문(managedRefund)입니다. convergence_pending, operator_required 본문은 캐시하지 않으므로 같은 키로 재시도하면 환불을 다시 읽거나 이어 가고 더 새로운 결과가 올 수 있습니다.