Ecommerce
Complete catalog selection, a guest card purchase and same-order readback.
Prepare
Enable ecommerce and publish products, variants, options, and market assignments in the target environment. The catalog needs only a Publishable Key. Keep Secret Keys and privileged order or inventory work on your server. Customer sign-up belongs to Customers.
The examples run on each request in a Next.js App Router app. Build Options lists the tested versions and setup.
Implement catalog to selection
Copy the Product listing page, then the Product detail page and its error boundary. Open /products, follow a card and then a swatch, and reload: the same product and variant must stay selected. Both recipes need only the public SDK package.
Normal, empty, and failure results
Test these cases. Route params are async, so await them.
| Case | Expected result |
|---|---|
| Empty catalog | A successful result. Show an empty state. |
| One published product | Its card and detail page. |
| Unlisted product opened by URL | Its detail page. |
| Missing or non-public product | A standard 404. |
| Selection after reload, or a stale selection | The stable-ID selection round-trips; handle a selection that no longer exists. |
| Key, feature, or workspace error | Show the error. Do not fall back to raw collection reads. |
| Network or server failure | Offer a retry. Do not show it as zero stock or zero products. |
Public product data has no raw inventory counts. Catalog availability can be stale, and a stock check does not reserve stock. Checkout and order creation check stock and sellability again. Payments need the setup in the next section.
Complete a guest purchase
This example buys one nonshipping product with a positive KRW amount by card, through a PortOne Standard V2 connection (standard_v2, a Toss Payments V2 CARD channel). Prepare:
- A guest-purchasable product with no customer-group or per-customer purchase limit, an active variant, and a valid price in a KRW market.
- The Ecommerce feature.
- An active PortOne account, set up in Payments, with current readiness, webhook configuration, the Standard V2 channel key, and a redirect URL on an origin listed in the workspace CORS settings.
PortOne Hosted V2 (hosted_v2) accepts only overseas-payment V2 channels linked to a checkout profile, so a store with only domestic PG channels cannot use it. It is not covered here. Other currencies or methods, shipping, discounts, credits, and Events admissions need their own integration review.
Build the purchase on your app's existing durable purchase or session store. Copy lib/purchase.ts below and implement its PurchasePort on that store. It is app code, not an SDK helper or a new storage engine; use an in-memory adapter only in tests. The store must:
- Create one record per purchase. When the app confirms a purchase intent, atomically create one
PurchaseRecordwithauthorityIdfrom the verified app session, an opaquepurchaseId, and an immutableintent: the detail recipe'sslug, the canonicalselectionQuerywithout?, the market handle, a contact email, and the quantity (for example, two). The email is a contact snapshot, not customer authentication. A repeated submit or another tab must get this same record, including its pending work. - Own and serialize the record. Authenticate ownership before returning a record and serialize every tab and worker for that purchase. Before any upstream call, atomically claim each operation's key and exact request body together.
savecommits a detached snapshot before it resolves and rejects stale lock holders. Keep existing pending and result entries; a retry never allocates a new key. If a claim or save fails, stop the upstream work. Never reuse an identity with changed input. - Keep tokens private. Store capability tokens and payment actions privately, at rest and in transit. Save the Cart token and id, the Checkout token and id, and the PaymentSession id before the next step or navigation, and reopen the same record after a new request or an interruption. Keep tokens and records out of URLs, and keep them and PortOne request values out of logs, error reports, analytics, and tracking redirects. The account's redirect URL is fixed and carries no purchase id: ignore the query PortOne appends on return and find the record from the verified session. Return only the view that session asked for, as a private
no-storeresponse, and apply your existing same-origin and CSRF controls to purchase actions.
Run this module in your app's trusted request layer with a dedicated Publishable Key guest client. Pass authorityId from the verified session on every call; a purchase id alone grants nothing. customer: { persist: false } keeps the client from reading browser customer storage. Never add a Secret Key or PAT to act as the guest. Customer-bound resources also need that customer's authority, so this guest workflow does not use them.
// lib/purchase.ts — app-owned workflow; no server credential or PSP SDK.
import {
createClient,
createProductSelectionCodec,
resolveProductSelection,
} from '@01.software/sdk'
type Commerce = ReturnType<typeof createClient>['commerce']
type Cart = Awaited<ReturnType<Commerce['carts']['retrieve']>>
type Checkout = Awaited<ReturnType<Commerce['checkouts']['retrieve']>>
type Payment = Awaited<
ReturnType<Commerce['paymentSessions']['retrievePortOne']>
>
type StandardAction = Extract<
NonNullable<Payment['nextAction']>,
{ type: 'portone_sdk' }
>
// Exactly the PortOne Browser SDK V2 requestPayment() fields.
export type PortOnePaymentRequest = Pick<
StandardAction,
| 'storeId'
| 'channelKey'
| 'paymentId'
| 'orderName'
| 'totalAmount'
| 'currency'
| 'payMethod'
| 'redirectUrl'
>
type Saved<Request, Result> = { request: Request; result?: Result }
export type PurchaseRecord = {
authorityId: string
purchaseId: string
// Immutable after the app atomically creates this session-bound record.
intent: {
slug: string
selectionQuery: string
market: string
email: string
quantity: number
}
cartCreationStarted?: true
cart?: { cartToken: string; snapshot: Cart }
add?: Saved<
Parameters<Commerce['carts']['addItem']>[0],
Awaited<ReturnType<Commerce['carts']['addItem']>>
>
update?: Saved<
Parameters<Commerce['carts']['updateItem']>[0],
Awaited<ReturnType<Commerce['carts']['updateItem']>>
>
checkout?: Saved<
Parameters<Commerce['checkouts']['create']>[0],
Awaited<ReturnType<Commerce['checkouts']['create']>>
>
payment?: Saved<
Parameters<Commerce['paymentSessions']['createPortOne']>[0],
Payment
>
}
// Implement using the EXISTING app's durable purchase/session store.
// exclusive authenticates authorityId, checks record ownership, and serializes
// all tabs/workers for this purchase. save commits a detached durable snapshot
// before resolving; it is fenced against expired ownership and older revisions.
// A failed save/claim aborts work. Do not hold an uncommitted DB transaction
// across upstream I/O. Never expire/delete an unresolved payment's identity.
export interface PurchasePort {
exclusive<T>(
authorityId: string,
purchaseId: string,
work: (
record: PurchaseRecord,
save: (next: PurchaseRecord) => Promise<void>,
) => Promise<T>,
): Promise<T>
}
export type PurchaseView =
| { kind: 'portone_sdk'; request: PortOnePaymentRequest }
| { kind: 'complete'; order: { id: string; orderNumber: string } }
| { kind: 'order_readback'; order: { id: string; orderNumber: string } }
| { kind: 'needs_attention' }
export function purchaseWorkflow(
config: { publishableKey: string; apiUrl?: string },
store: PurchasePort,
) {
// Dedicated guest client: do not inherit a signed-in customer's browser state.
const { commerce } = createClient({ ...config, customer: { persist: false } })
const run = <T>(
authorityId: string,
purchaseId: string,
work: (
r: PurchaseRecord,
save: (next: PurchaseRecord) => Promise<void>,
) => Promise<T>,
) =>
store.exclusive(authorityId, purchaseId, async (r, save) => {
if (r.authorityId !== authorityId || r.purchaseId !== purchaseId)
throw new Error('Purchase authority mismatch')
return work(r, save)
})
const rememberCart = (r: PurchaseRecord, snapshot: Cart) => {
if (
r.cart &&
(r.cart.snapshot.id !== snapshot.id ||
snapshot.revision < r.cart.snapshot.revision)
) {
throw new Error('Refuse mismatched or older Cart snapshot')
}
if (!r.cart) throw new Error('Missing Cart capability')
r.cart.snapshot = snapshot
}
const requirePaidPath = (checkout: Checkout) => {
if (
checkout.status !== 'open' ||
checkout.amounts.totalMinor <= 0 ||
checkout.amounts.currency !== 'KRW' ||
checkout.items.length === 0 ||
checkout.items.some((item) => item.requiresShipping)
) {
throw new Error('Outside configured nonshipping positive KRW path')
}
}
const observe = async (r: PurchaseRecord): Promise<PurchaseView> => {
const created = r.checkout?.result
if (!created) throw new Error('Resume the stored Checkout request first')
if (!r.payment) {
const checkout = await commerce.checkouts.retrieve({
checkoutToken: created.checkoutToken,
})
if (checkout.id !== created.checkout.id)
throw new Error('Checkout identity mismatch')
// Already placed is order readback, not proof about an unknown PSP attempt.
return checkout.status === 'placed'
? { kind: 'order_readback', order: checkout.order }
: { kind: 'needs_attention' }
}
if (!r.payment.result)
throw new Error('Resume the stored PaymentSession request first')
const payment = await commerce.paymentSessions.retrievePortOne({
paymentSessionId: r.payment.result.id,
checkoutToken: created.checkoutToken,
})
const placedCheckout = await commerce.checkouts.retrieve({
checkoutToken: created.checkoutToken,
})
if (
payment.id !== r.payment.result.id ||
placedCheckout.id !== created.checkout.id
) {
throw new Error('Purchase identity mismatch')
}
if (
payment.providerStatus === 'captured' &&
payment.finalizationStatus === 'committed' &&
payment.reconciliation.status === 'none' &&
placedCheckout.status === 'placed' &&
payment.order.id === placedCheckout.order.id
) {
return { kind: 'complete', order: placedCheckout.order }
}
if (
placedCheckout.status === 'open' &&
payment.reconciliation.status === 'none' &&
payment.presentationStatus === 'actionable' &&
payment.nextAction?.type === 'portone_sdk'
) {
// Non-cancelable: retry only this exact request, never a sibling session.
const action = payment.nextAction
return {
kind: 'portone_sdk',
request: {
storeId: action.storeId,
channelKey: action.channelKey,
paymentId: action.paymentId,
orderName: action.orderName,
totalAmount: action.totalAmount,
currency: action.currency,
payMethod: action.payMethod,
redirectUrl: action.redirectUrl,
},
}
}
return { kind: 'needs_attention' }
}
return {
async prepareCart(authorityId: string, purchaseId: string): Promise<Cart> {
return run(authorityId, purchaseId, async (r, save) => {
if (r.checkout || r.payment)
throw new Error('Resume existing purchase; do not edit its Cart')
if (!Number.isSafeInteger(r.intent.quantity) || r.intent.quantity < 1)
throw new Error('Invalid quantity')
if (!r.cart) {
// carts.create has NO idempotency key. A lost result needs app support;
// never replay this pending create as though it had the later contract.
if (r.cartCreationStarted)
throw new Error('Cart creation outcome unavailable; retain record')
r.cartCreationStarted = true
await save(r)
const created = await commerce.carts.create({
market: r.intent.market,
})
r.cart = { cartToken: created.cartToken, snapshot: created.cart }
await save(r)
}
if (!r.add) {
const result = await commerce.product.detailCatalog({
slug: r.intent.slug,
})
if (!result.found) throw new Error('Product unavailable')
const codec = createProductSelectionCodec(result.product, {
emit: 'canonical-id',
})
const selection = resolveProductSelection(
result.product,
codec.parse(r.intent.selectionQuery),
)
if (!selection.selectedVariant)
throw new Error('Choose a current variant')
r.add = {
request: {
cartToken: r.cart.cartToken,
expectedRevision: r.cart.snapshot.revision,
idempotencyKey: crypto.randomUUID(),
productId: String(result.product.product.id),
variantId: String(selection.selectedVariant.id),
quantity: 1,
},
}
await save(r) // Atomic claim fixes key AND body before upstream.
}
if (!r.add.result) {
const result = await commerce.carts.addItem(r.add.request)
rememberCart(r, result.cart)
r.add.result = result
await save(r)
}
if (!r.update) {
const line = r.cart.snapshot.items.find(
(item) => item.variant.id === r.add!.request.variantId,
)
if (!line) throw new Error('Selected Cart line unavailable')
r.update = {
request: {
cartToken: r.cart.cartToken,
expectedRevision: r.cart.snapshot.revision,
idempotencyKey: crypto.randomUUID(),
cartItemId: line.id,
quantity: r.intent.quantity,
},
}
await save(r)
}
if (!r.update.result) {
const result = await commerce.carts.updateItem(r.update.request)
rememberCart(r, result.cart)
r.update.result = result
await save(r)
}
const latest = await commerce.carts.retrieve({
cartToken: r.cart.cartToken,
})
rememberCart(r, latest)
await save(r)
if (
latest.items.length === 0 ||
latest.items.some((item) => item.requiresShipping)
) {
throw new Error('Review Cart; this path requires nonshipping items')
}
return latest // Display server totals; no client-side Money arithmetic.
})
},
async startPayment(
authorityId: string,
purchaseId: string,
): Promise<PurchaseView> {
return run(authorityId, purchaseId, async (r, save) => {
if (!r.cart || !r.update?.result)
throw new Error('Prepare and review Cart first')
if (!r.checkout) {
r.checkout = {
request: {
cartToken: r.cart.cartToken,
customer: { email: r.intent.email },
idempotencyKey: crypto.randomUUID(),
},
}
await save(r)
}
if (!r.checkout.result) {
r.checkout.result = await commerce.checkouts.create(
r.checkout.request,
)
await save(r) // Persist Checkout id/token before any provider handoff.
}
if (!r.payment) {
const checkout = await commerce.checkouts.retrieve({
checkoutToken: r.checkout.result.checkoutToken,
})
if (checkout.id !== r.checkout.result.checkout.id)
throw new Error('Checkout identity mismatch')
if (checkout.status === 'placed')
return { kind: 'order_readback', order: checkout.order }
requirePaidPath(checkout)
if (checkout.paymentState.activePaymentSession)
return { kind: 'needs_attention' }
r.payment = {
request: {
checkoutToken: r.checkout.result.checkoutToken,
idempotencyKey: crypto.randomUUID(),
provider: 'portone',
providerOptions: {
profile: 'standard_v2',
paymentMethod: 'CARD',
},
},
}
await save(r)
}
if (!r.payment.result) {
r.payment.result = await commerce.paymentSessions.createPortOne(
r.payment.request,
)
await save(r) // Persist PaymentSession id before the browser action.
}
return observe(r)
})
},
async resume(
authorityId: string,
purchaseId: string,
): Promise<PurchaseView> {
return run(authorityId, purchaseId, (r) => observe(r))
},
}
}Run and reopen the same purchase
- After the shopper picks a product and variant, create or load the session-bound record. Build
purchaseWorkflowwith the Publishable Key, API URL, and yourPurchasePort, and callprepareCart(authorityId, purchaseId)from the purchase action. It resolves the selection against fresh catalog detail, creates a Cart, adds one item, sets the intended quantity, and reads the latest Cart. Show the returned server totals and the warnings inadd.resultandupdate.result. Never calculate a charge from catalog prices. The shopper reviews this Cart before continuing. - When the shopper confirms, call
startPaymentfor the same record. Checkout freezes the server totals and rechecks purchase rules, stock, and market. If the Checkout is alreadyplaced, the result isorder_readback: show its sanitized order reference without starting a PSP session. This can happen for a zero total or for a positive total fully covered by credit, so do not label it "free". The paid branch in this sample requires an open, positive, nonshipping KRW Checkout. The server sets the provider payment ID and amount; the app sends only the Standard profile and method. - A
portone_sdkview carries the exact PortOne Browser SDK V2 request. In the shopper's browser, pass it unchanged toPortOne.requestPayment(view.request)from@portone/browser-sdk/v2. Do not change its payment ID, amount, or redirect URL. Mobile browsers return to the account's redirect URL, so that page must belong to the original purchase flow; desktop browsers may resolve the promise without navigating. The action cannot be canceled. To retry, repeat the sameview.request; never create a second PaymentSession. Keep the purchase page available so the shopper can choose Check purchase status after paying. - That action calls
resumewith the same verified session and purchase id, with no token in the URL. It looks up the saved PaymentSession with the saved Checkout token, and looks up that Checkout separately. For this PortOne payment, onlycompleteconfirms it: the payment is captured and committed with reconciliationnone, and the Checkout is placed with the sameorder.id. Showorder.idandorderNumberfrom that lookup; there is no guest order detail or list call. A redirect, query string,requestPayment()result, or browser success message never proves payment.
If a write response, or the save after it, was lost, reopen the record. prepareCart resumes a pending add or update, and startPayment resumes a pending Checkout or PaymentSession creation with the saved request unchanged. A saved PaymentSession is always looked up, never created again. resume refuses a missing creation result, so a lost id is never read as "no payment". carts.create has no idempotency key: if its token or result was lost, the saved pending marker stops automatic recreation. Keep the record and resolve it through app support; do not retry Cart creation as if it were idempotent.
Keep uncertain outcomes separate
| Situation | What to do |
|---|---|
| Empty Cart; unavailable product, stock, or market; invalid selection; feature, key, or workspace denial | Keep the typed error and request id, and fix the purchase intent. Never turn a denial into an empty, free, or paid result, and never fall back to raw collections. |
cart_revision_conflict | Read the latest Cart and ask the shopper to review the change. The rejected request is not a new change. Only a separately confirmed change gets a new operation record and key, after the old outcome is resolved. This sample stops instead of rewriting its request. |
Lost response, idempotency_in_progress, or a temporary failure | Keep the original key and body and follow the SDK retry policy; reads and writes retry differently. A same-key replay is not a new change. |
unknown, processing, captured but pending, result not applied, committed with reconciliation required, or a different order | Show "pending" or "needs attention", look up the same identity again, or contact support. Do not create another key, session, provider, or charge. committed alone does not clear reconciliation. |
| Expired or abandoned Checkout, failed lookup, or closed browser | Keep every linked identity and check the authoritative status or contact support. None of these means the payment was not captured; a capture can arrive late. Do not delete records or bypass retention to restart payment. |
The workflow refuses an older Cart revision before it can replace the saved Cart. Once a PaymentSession exists, it does not edit that Cart and does not treat a placed Checkout as resolving the session's uncertain outcome. Errors propagate: show a read retry or an unconfirmed-operation state, without exposing the request or error body, which carry capabilities.
Verify this integration
Test your durable port with concurrent tabs, a failed save before each upstream write, and a lost response followed by the exact same key and body. With the public SDK and a controlled transport, check that:
- the Standard action handoff leads to captured, committed, no reconciliation, and a separate lookup of the same order's Checkout;
- committed with reconciliation required, a different order, a lower Cart revision, and a failed lookup are all rejected;
- after
unknown, repeated start and status calls create no new payment; - empty catalog, sellability, stock, and market changes, stale revisions, and feature, key, or workspace denials are handled.
A controlled transport tests your app's behavior, not provider settlement or the live workspace. Check the live flow after Deploy. Commerce Operations covers operational ownership.
Next Actions
Use Commerce Operations, Integrations & Keys, and Deploy for operating ownership, provider setup, and launch.