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.