01.software Docs
Specialist Integrations

Payments

Payment action reuse, reconciliation and custom server bridges.

Call each method only from the caller its section names. Configure credentials first; see SDK for imports and peer dependencies.

Managed payment providers

Checkout paymentState.activePaymentSession.provider and PaymentSession responses use one closed provider set.

ProviderAvailability
tossAvailable
portoneAvailable
stripeReserved. The server emits it only after Stripe becomes available.

Upgrade every storefront and server that reads Checkouts to @01.software/sdk 0.72.1 or later before Stripe becomes available. From 0.72.1, a Checkout reference with provider: 'stripe' reads successfully; earlier published SDK versions reject it as an invalid response. The PaymentSession methods still reject a Stripe session in 0.72.1.

Choose the payment connection environment

When creating a managed payment connection in the Admin Panel, select Live in Live and Test in Sandbox. To create a test payment connection, switch to Sandbox using the Project/environment selector in the sidebar. If Sandbox is missing, create it from the selector first.

Report who initiated a server cancellation

Server commerce.paymentSessions.cancel and cancelPortOne accept optional initiatedBy: 'customer' | 'operator'. Omit it to use operator; the SDK sends the field only when you supply it. Browser and publishable-key requests reject this field, and server requests reject other values.

Use customer when your authenticated workspace server has established that the customer explicitly requested cancellation or reservation release. Console records your server's report and applies the same server credential, write scope, workspace ownership, and cancelability checks. PortOne Standard keeps its cancellation restriction.

Retry with the original idempotency key. cancellationReason preserves the previously stored cancellation attribution; it does not identify the final no-capture verification method or the customer's actual reason for leaving. A cleanup path that combines USER_CANCEL, card declines, and invalid parameters needs separate outcome handling before reporting customer cancellation.

After a successful Hosted close, the session stays open and capture remains possible until Console confirms no capture. If release is deferred after close, later expiry lookup, webhook, or reconcile release retains the stored attribution. This also applies to older SDK server calls that omit initiatedBy and to browser cancellation (customer).

A Hosted session terminated by expiry, provider cancellation, or failure (failed) rejects cancellation with 409 payment_session_not_cancelable when Console has no stored close result for an idempotency key, preserving its terminal state. Only when Console has stored the close result for a key can you reuse that key to receive the stored result. In that case, another key returns 409 idempotency_conflict.

When the stored reason is absent or null, Console release uses its existing expired or provider classification. Template shared failure cleanup calls server cancel without initiatedBy, recording operator when no cancellation attribution is already stored. Existing records are not backfilled.

Deploy Console support, publish the SDK, then enable this field in templates or apps. Older Consoles reject the field with 400 validation_failed; cancellation does not run or release the slot. Calls that omit it retain the existing request shape, but the later public cancellationReason may now retain operator or customer instead of expired or provider.

PortOne Standard V2 cancellation and retry

A portone_sdk next action declares cancelable: false and retryPolicy: 'same_action_only'. Check these before calling a PaymentSession cancel method. A browser failure, abandonment, local expiry, or an unsuccessful provider result does not free the Checkout's payment slot or allow a new payment ID.

The SDK requests these capability fields when it creates a Standard session and when it retrieves a PortOne session in the browser or on the server, where the profile is unknown. Hosted create and cancel calls do not request them. The SDK also accepts the action the released Console returns by default and fills in the same fixed values, so the SDK and Console can be upgraded independently.

Direct HTTP callers get the default action shape. To receive the capability fields on create or retrieve, send Accept: application/vnd.01software.portone-action-capabilities.v1+json.

  • The header must contain only that media type; surrounding whitespace is allowed. Lists, wildcards, parameters, and q-values get the default shape.
  • Responses are private and no-store.
  • Only a successful opt-in that returns a Standard capability action uses that media type as Content-Type. Hosted, Toss, cancel, confirm, and error responses stay 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
}

Retrieve the same PaymentSession after the browser result. Only Console decides whether the payment is final, from its authenticated PortOne payment lookup. A webhook only prompts Console to run that lookup; the webhook itself is not proof of payment.

Server-only PortOne identity reconciliation

createServerClient().commerce.paymentSessions.findPortOne/reconcilePortOne finds a managed PortOne payment by its full identity: provider + providerAccountKey + environment + providerPaymentId. The workspace comes only from the server credential; the request body rejects a workspace 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 reads local data only.
  • reconcilePortOne needs write scope and a durable idempotency key, and runs the existing managed reconciliation. After an interrupted response, reuse the same key: Console returns the first committed result without calling the provider again. Its disposition is applied, observed, recovered_after_ambiguous_result, or requires_attention.
  • A local miss returns { found: false } over HTTP and in the SDK. Reconciliation never claims an unknown provider payment or attaches it to a Checkout or Order by guessing.
  • Neither method exists on the browser client.

Server-only custom payment bridge

createServerClient().commerce.paymentSessions.custom.create/retrieve/confirm supports a payment provider that has no built-in adapter. The namespace and its types exist only in @01.software/sdk/server; keep them on your trusted server. The bridge records captures that you verified yourself. It is not a managed PSP adapter, a general "mark paid" API, an integration that runs payments for you, a refund interface, or a manual or offline pending-order flow.

Before you start

  • An eligible, open, unexpired Checkout whose id and token your server has already bound to the authorized app session and purchase.
  • In the Admin Panel, the selected workspace's Custom Payment Providers with an enabled provider for this Checkout's market and a supported currency.
  • A server client with that workspace's matching Publishable Key and Secret Key and the write authority it needs.

A custom provider slug may match a managed provider's name. That does not make its sessions managed or skip the managed-adapter checks.

What your app provides

The custom.ts module below takes these trusted inputs. From your purchase workflow, pass the original valid UUIDv4 (or a supported ik_ key), the normalized provider, and the original provider-payment mapping. Its atomic durable claim fixes the create key, body, and payment mapping before the SDK request; concurrent or interrupted attempts must reuse them. Save only the create result's id, provider, providerSessionId, and money before any provider work. Your store must reject a changed binding, session, capture, or receipt instead of overwriting it, and must encrypt or otherwise protect the Checkout capability at rest.

The store, provider verifier, and secret reader are typed ports your app implements, not SDK or platform services.

  • The store provides durable atomic uniqueness and compare-and-set in your storage engine.
  • The verifier authenticates a provider API response or signed notification. It checks the expected workspace, provider, account, and environment, the exact session, payment, and event identity, and the captured amount, and returns only that verified capture. It only observes the saved payment and cannot start another charge.
  • Running the payment at the provider, with its original payment mapping, belongs to your trusted app workflow, outside this bridge. A redirect, client success, HMAC, or retrieve status alone never proves capture.

HMAC proof

Set hmacRequired from trusted integration configuration. Proof is required when the Console runtime has NODE_ENV === 'production' and the session market is US. This is not the workspace's Production or Sandbox environmentKind; observe a hosted staging runtime instead of assuming its condition.

  • The Admin Panel's env: secret reference is resolved from the Console deployment's environment variables. Setting the same name only on your app server does not count.
  • There is no published procedure yet for provisioning or sharing this secret; who does it is a separate operational decision.
  • If proof is required and Console cannot resolve the reference, confirmation fails with hmac_secret_missing. Even when the reference resolves, the proof and every identity, capture, and amount check must still pass.
  • Never put the raw secret in documents, browser or public environment variables, or logs.

secrets.readCustomPaymentSecret assumes your app has separately approved access to the same secret; it does not fetch or share Console secrets. The SDK's createProof(rawBody) gives you the exact JSON bytes it will send. Sign ${timestamp}.${rawBody} with HMAC-SHA256 and a valid ISO timestamp, and return a 64-character hex signature; Console accepts ±5 minutes. Do not reserialize the body in middleware, and do not use the Membership or outbound-webhook signature schemes. Console keeps no record of seen signatures, so the identity and replay checks control retries. A proof that your app generates authenticates your app, not the PSP capture.

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 }
}

Call completeCustomPayment(server, retainedPurchase, ports) only after your server authorizes that purchase. claim must compare every CustomPurchase field under the original workspace and app authority, purchase, and Checkout binding, including the exact create key and body and the provider-payment mapping. read returns only that claim's saved state, and each keep* atomically accepts a first value or its exact replay and rejects anything different. A process-local map is not a production store. Keep secrets out of this record.

For a first purchase, run this sequence in your app:

  1. Call completeCustomPayment with the original purchase. It creates and durably saves the session before asking the read-only verifier. If the payment was never sent to the provider, an unknown result returns needs_attention without confirming; this can be the normal handoff to your payment workflow.
  2. In that workflow, read ports.store.read(retainedPurchase). Require the saved session and a separate durable dispatch record showing that this purchase has never started a provider payment. Atomically claim that first dispatch before calling the provider. Bind the command to the saved session.providerSessionId, the exact session.money, the original providerPaymentId, and the trusted workspace, provider, account, and environment, then run that one payment in your provider adapter. Neither unknown nor the session record alone permits a charge. Dispatch claims and provider calls are your app's job, not SDK methods or part of verifyCapture.
  3. Call completeCustomPayment again with the same purchase. It reuses the saved session without creating another, verifies that payment's capture independently, and only then confirms and reads the original Checkout's Order.

If dispatch has started or its outcome is unclear, including a PSP timeout, do not dispatch again or reset the first-dispatch claim. Keep checking the same saved payment until its capture can be verified, or keep it for operational resolution. An unknown result without a verified capture never shows that an earlier charge did not happen.

recoverCustomPayment observes; it does not complete a payment or prove capture. retrieve takes exactly one providerSessionId or providerPaymentId, does an exact lookup, and returns only a bounded status, amount, and resolved orderId. It does not list sessions or show raw relations, provider evidence, fingerprints, account identity, or credentials. Custom sessions always show null in checkout.paymentState.activePaymentSession, which does not mean that no session exists. Keep the providerSessionId and recover by that exact identity.

Before confirm, the module fixes the verified capture with its original session, payment, and event ids, the actual captured minor-unit amount, the currency, and occurredAt.

  • Lost create response: resume with the same key and body.
  • Lost confirm response: send the same saved capture unchanged and retrieve again. Only the proof may be recomputed, with a current timestamp. confirm has no idempotency-key argument.
  • A saved confirmation still needs fresh custom and Checkout reads. The payment is complete only when confirm's orderId, the committed custom retrieve orderId, and the placed Order of the original checkoutId all match.
  • alreadyConfirmed never makes another app side effect run exactly once; use a separate durable claim, as described below.

None of these means completion: an unresolved PSP timeout or unknown after dispatch, a verification failure, an amount or currency mismatch, pending local finalization, a failed response application, an opaque not-found, or a permission denial. Keep the record, check the same saved identity and capture, and get operational support. Errors propagate, and the module does not retry automatically. Do not invent captured or failed results, swap the session, key, provider, or payment, or delete the snapshot to skip recovery. See Commerce for operational ownership.

Idempotent mutation responses are not exactly-once gates

No commerce mutation response field can safely gate a non-idempotent side effect, such as an operational alert, an outbound webhook, or a ledger entry. Gate it with your own durable claim keyed by the side effect's stable identity, as in the processOnce webhook pattern: a database unique key, a queue's idempotency store, or a provider-safe send ledger, not process memory or a response field.

For paymentSessions.custom.confirm, alreadyConfirmed: true means the session was already confirmed when observed, not whether this call caused the change. The field is absent from the first successful confirmation. To read the response shape, test 'alreadyConfirmed' in result rather than result.alreadyConfirmed === false. Do not trigger a non-idempotent side effect from either branch: if the first confirmation commits but its response is lost, the retry returns alreadyConfirmed: true and the change would be missed.

For orders.cancelOrder, cancelCommitted and alreadyCanceled describe what happened to the order, not whether the response was replayed. A retry with the same idempotency key returns the original 2xx body byte for byte, so it can return cancelCommitted: true again. Neither field tells you whether this response was a replay or whether a side effect already ran. The exception is a Console-owned refund (managedRefund): a convergence_pending or operator_required body is not cached, so the same-key retry reads or resumes the refund and can return a newer outcome.

Next Actions

On this page