01.software Docs
Specialist Integrations

Payments

Payment action reuse, reconciliation and custom server bridges.

Payments

Use this integration only in the stated caller context. Configure credentials first; the SDK README owns the package surface.

PortOne Standard V2 cancellation and retry

A portone_sdk next action declares cancelable: false and retryPolicy: 'same_action_only'. Check those capabilities before calling a PaymentSession cancellation method. Browser failure, abandonment, local expiry, or an unsuccessful provider observation does not release the Checkout payment slot or authorize a new payment ID.

The current SDK negotiates this capability projection on Standard create and on browser/server PortOne retrieve, where the profile is unknown. Hosted create and browser/server cancel do not send the capability Accept header. It also accepts the released Console's exact Standard action and normalizes the same fixed semantics, so SDK and Console upgrades do not require lockstep deployment. Direct HTTP callers receive the released exact shape by default and can opt in on create/retrieve with Accept: application/vnd.01software.portone-action-capabilities.v1+json. That value must be the sole exact media type after optional surrounding whitespace. Accept lists, wildcards, parameters, and q-values deliberately stay on the default projection. Responses are private and no-store, and only a successful exact opt-in that actually returns a Standard capability action uses the negotiated media type as Content-Type; Hosted, Toss, cancel, confirm, and error responses remain 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's authenticated PortOne lookup or webhook convergence determines payment finality.

Server-only PortOne identity reconciliation

createServerClient().commerce.paymentSessions.findPortOne/reconcilePortOne locates a managed PortOne payment from its complete canonical identity: provider + providerAccountKey + environment + providerPaymentId. Workspace scope comes only from the server credential; the strict request body rejects a caller-supplied 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 is local and read-only. reconcilePortOne requires write scope and a durable idempotency key, and invokes the existing managed convergence engine. Reuse the same key after an interrupted response: Console replays the first atomically committed result without repeating provider I/O. Its disposition is applied, observed, recovered_after_ambiguous_result, or requires_attention. Both HTTP and SDK use { found: false } for local not-found; reconciliation never claims or heuristically attaches an unknown provider payment to a Checkout or Order. Neither method is available on the browser client.

Server-only custom payment bridge

createServerClient().commerce.paymentSessions.custom.create/retrieve/confirm supports a provider that has no first-party adapter. Keep this namespace and its types behind @01.software/sdk/server: browser clients and the SDK root do not expose it.

import { createServerClient } from '@01.software/sdk/server'

type Server = ReturnType<typeof createServerClient>

export async function recoverCustomPayment(
  server: Server,
  providerSessionId: string,
) {
  const recovered = await server.commerce.paymentSessions.custom.retrieve({
    provider: 'portone',
    by: { providerSessionId },
  })
  if (recovered.finalizationStatus === 'committed') {
    return { kind: 'committed' as const, orderId: recovered.orderId }
  }
  return { kind: 'pending' as const, recovered }
}

retrieve accepts exactly one providerSessionId or providerPaymentId. It performs an exact lookup and returns only bounded status, Money, and the resolved orderId; it neither lists sessions nor exposes raw PaymentSession relations, provider evidence, fingerprints, or credentials. The result is a recovery observation, not proof that a provider capture is valid. Trusted server code must still independently verify the provider API response or signed webhook before confirming the exact captured identity and Money. This bridge is neither a generic “mark paid” API nor a manual/offline pending-order placement flow; see the SDK README and Commerce for the complete boundary. Custom bridge sessions intentionally appear as null in checkout.paymentState.activePaymentSession; persist the providerSessionId from custom.create and use the exact lookup above as the supported custom-lane settlement recovery path.

Idempotent mutation responses are not exactly-once gates

No commerce mutation response field is a safe exactly-once gate for a non-idempotent side effect such as an operational alert, outbound webhook, or ledger entry. Gate the side effect with a consumer-owned durable claim keyed by its stable logical identity. Follow the processOnce webhook pattern: use a database unique key, queue idempotency store, or provider-safe send ledger rather than process memory or a response field.

For paymentSessions.custom.confirm, alreadyConfirmed: true means the session was already confirmed at observation time, not that the current call did or did not cause the transition. The field is absent from the initial successful confirmation. If you only need to interpret that response shape, test 'alreadyConfirmed' in result instead of result.alreadyConfirmed === false. Do not use either branch to trigger a non-idempotent side effect: if the initial confirmation commits but its response is lost, the retry returns alreadyConfirmed: true and the transition would otherwise be silently missed.

For orders.cancelOrder, cancelCommitted and alreadyCanceled describe the domain effect disposition, not transport replay. A retry with the same idempotency key replays the cached initial 2xx body byte-for-byte, so it can return the original cancelCommitted: true again. Neither field tells you whether this response was replayed or whether a side effect has already run.

Next Actions

On this page