Skip to content

Flow state machine ​

The server drives the flow. Every response carries the session's current Step. The modal renders the step and fires the transitions it allows.

The Step ​

ts
type Step = {
  sessionId: string
  state: StateName
  detail?: StepDetail         // a finer label: { code, providerStatus? }, e.g. { code: 'settling' }
  legIndex?: number           // which leg of the pathway is active
  surface?: Surface           // what to show: QR, redirect, deposit address, ...
  transitions: Transition[]   // what the user or the client may do next
  error?: OpenRampError
  expiresAt?: string
}

The legs, the provider order ids and the transactions of the payment are in PublicSession.payment (see Payment), not in the step.

Step detail ​

Step.detail is a finer label inside state:

ts
type StepDetail = {
  code: StepDetailCode     // from the closed list STEP_DETAIL_CODES
  providerStatus?: string  // the provider's own status, for example Relay 'pending'
}

code comes from a closed list of lowercase values, STEP_DETAIL_CODES in @openrampkit/core (type StepDetailCode, check with isStepDetailCode):

codeUsed inMeaning
kyc_detailsKYCThe user gives details in a form
kyc_termsKYCThe user accepts the provider terms
kyc_verifyKYCThe user verifies identity at the provider
kyc_reviewKYCThe provider reviews the identity
card_detailsPAYMENTThe user gives card details
bank_detailsPAYMENTThe user sends a bank transfer to the details shown
payout_accountPAYMENTThe user gives a payout account
send_cryptoPAYMENTThe user sends crypto
waiting_for_depositPROCESSINGThe provider waits for a deposit
ambiguous_depositPAYMENT, PROCESSINGA deposit matches more than one session; an operator must check it
confirmingPROCESSINGA transaction waits for confirmation on chain
bridgingPROCESSINGThe funds move between networks
settlingPROCESSINGThe provider settles or pays out
delayedPROCESSINGThe provider reports a delay
refundingPROCESSINGThe provider refunds the payment
processingPROCESSINGAny other provider work

Rules:

  • An adapter maps its provider statuses to this list. It puts the raw provider status in detail.providerStatus. The server writes each new raw status to the session timeline (leg.provider_status).
  • providerStatus is free text for display and support. The server keeps it only when it has at most 64 characters of letters, digits, spaces and _ - . :. It drops other values.
  • The server drops a detail whose code is not in the list, and logs a warning.
  • The web UI shows the label of messages.stepDetail[code] in the user's language. Without a known code, it shows the state title.

States ​

StateMeaningTerminal
SELECT_METHODNo payment yet. The user picks a method, an amount and a quote.No
QUOTEA quote step inside a legNo
AUTHThe provider needs the user to sign inNo
KYCThe provider needs identity checksNo
PAYMENTThe user must act: scan, pay, send, signNo
PROCESSINGPaid; the provider or the chain is workingNo
COMPLETEDEvery leg succeededYes
FAILEDA leg failedYes
EXPIREDThe session or the payment expiredYes
REFUNDEDThe provider refunded the paymentYes
BLOCKEDNot allowed (policy or region)Yes

The table as data ​

The legal moves live in one table in @openrampkit/core, TRANSITION_TABLE. The server, the client and the adapter test kit all read it. Terminality comes from this table, never from counting transitions.

FromMay move to
SELECT_METHODQUOTE, BLOCKED, EXPIRED
QUOTESELECT_METHOD, AUTH, KYC, PAYMENT, PROCESSING, BLOCKED, EXPIRED
AUTHKYC, PAYMENT, FAILED, EXPIRED
KYCKYC, PAYMENT, FAILED, EXPIRED
PAYMENTPAYMENT, PROCESSING, COMPLETED, FAILED, EXPIRED, QUOTE
PROCESSINGPROCESSING, PAYMENT, COMPLETED, FAILED, REFUNDED, EXPIRED
COMPLETEDnone
FAILEDSELECT_METHOD (try again)
EXPIREDnone
REFUNDEDnone
BLOCKEDSELECT_METHOD

Helpers: isTerminal(state), isLegalMove(from, to), validateStep(step), TABLE_VERSION (currently 1). isLegalMove also allows a move to the same state.

The same table as a diagram. [*] on the left is a new session. [*] on the right is a final state with no way out.

FAILED is terminal, but the table lets it go back to SELECT_METHOD: the restart transition does this after a failed attempt (session status requires_payment_method). After a final failure (session status failed), the server refuses restart. BLOCKED, REFUNDED, REVERSED and CANCELED have no way out. EXPIRED can go to PROCESSING or COMPLETED (a late payment).

Who uses the table:

  • The server and the client read terminality from it (isTerminal). A terminal step stops the poll, and the server refuses a new payment while a step is not terminal.
  • The adapter test kit checks each LegStep with validateStep. For example, a terminal step must not have an AWAIT transition.
  • The server does not reject a move that is not in the table. Adapters must return legal steps. The conformance kit helps you check this.

In practice, the server moves SELECT_METHOD straight to the first leg's state when POST /select starts the leg. QUOTE is for adapters that quote inside a leg.

Leg status ​

Each leg has its own LegStatus. The server gets the session state from the leg step with one rule, stateFor(step) in @openrampkit/core:

Leg statusSession stateFinal
pendingAUTH or KYC with a phase, else PROCESSINGNo
requires_actionThe action.kind: AUTH, KYC or PAYMENTNo
processingAUTH or KYC with a phase, else PROCESSINGNo
succeededCOMPLETED for the last leg, else PROCESSING while the next leg startsYes
failedFAILEDYes
refundedREFUNDED, or REVERSED when the leg had succeededYes
reversedREVERSEDYes
expiredEXPIREDYes

The server enforces the order of leg statuses for provider events (webhooks and adapter routes). Each status has a rank (LEG_STATUS_RANK): pending 0, requires_action 1, processing 2, succeeded, failed and expired 3, refunded and reversed 4. An event can move a leg to the same status or to a status of a higher rank (isLegalLegMove). A final leg does not move, with one exception: a succeeded leg can become refunded or reversed. So a late pending event cannot move a processing leg back. The server logs the event, adds 1 to the event.out_of_order metric, and answers the provider with 200 (the event is ignored, not an error).

One move back can be allowed: from processing to requires_action with a new action.surface. For example, an offramp learns its deposit address from a webhook and now needs a WALLET_TX. A new surface can send the user's funds to a new place, so the server allows this move only when all of these are true:

  • the leg's spec has the capability surface_after_processing,
  • the surface kind is in the spec's surfaces,
  • the leg has no transaction that moves funds yet (an approval does not count),
  • the leg did not move back before (once per leg).

The timeline gets leg.surface_after_processing. No built-in adapter needs this move today.

A status check (status()), a transition (transition()) and a provider event also move a leg only forward. One more move back is allowed for them: a review step (processing with phase: 'kyc' or phase: 'auth', for example a KYC review) can end with a step for the user (requires_action), before the leg has a transaction that moves funds. A status check or an event that would move a leg back is ignored. A transition that would do it answers 409.

A session with a completed payment (COMPLETED, or REVERSED after it) refuses every browser change with 409: plan, quotes, target, select and transitions, with the client secret or a pay link. An EXPIRED session moves on only when money arrives on the leg that waited at expiry, inside the grace window (latePayments).

When an event has an eventId, the server keeps the id in the session (the last 50 ids). An event with an id that the session already applied is ignored.

Transitions ​

A step lists what may happen next:

ts
type Transition =
  | { name: string; kind: 'SUBMIT'; label: string; inputs?: FieldSpec[] }        // a button, maybe with a form
  | { name: string; kind: 'AWAIT'; poll: PollSpec }                              // the client polls
  | { name: string; kind: 'SURFACE_RESULT'; expects: 'completed' | 'closed' | 'tx_hash' } // the surface reports back
  • SUBMIT: the modal shows a button with label. For FORM and OTP surfaces, the first SUBMIT is the form's submit button. The mock adapter's "Simulate payment (test mode)" is a SUBMIT.
  • AWAIT: the client polls GET /sessions/:id/step. The delay starts at intervalMs, grows by backoff per attempt up to maxIntervalMs, and stops after giveUpAfterMs.
  • SURFACE_RESULT: the surface reports an outcome. For WALLET_TX with expects: 'tx_hash', the controller sends the transactions with the wallet and fires the transition with { txHash }. For REDIRECT with expects: 'completed', the modal shows a "Continue" button after the user opened the checkout.

The client fires SUBMIT and SURFACE_RESULT with POST /sessions/:id/transitions/:name. The server refuses a name that is not in the current step, and refuses AWAIT names.

One name is special: restart. It leaves the current payment and goes back to SELECT_METHOD. It is allowed while the session status is requires_payment_method (before payment starts, or after a failed attempt) and while the step is PAYMENT. A session with a final status refuses it (409). The server keeps the left payment as an earlier attempt: a late provider event for it still applies.

Legs and the session step ​

Adapters return a LegStep for their leg (adapter API version 2). A provider event (LegEvent) is a LegStep with the leg's ref:

ts
type LegStep = {
  status: LegStatus
  action?: { kind: 'auth' | 'kyc' | 'payment'; surface?: Surface; transitions: Transition[] } // only with requires_action
  phase?: 'auth' | 'kyc'   // only with pending or processing: for example a KYC review
  poll?: PollSpec          // how often the UI checks a step that waits
  detail?: StepDetail
  error?: OpenRampError
  ref?: string             // our reference for the leg
  providerRef?: string     // the provider's own order id
  output?: Amount
  transactions?: LegTransaction[]
}

The server wraps it into the session's Step:

  • The state comes from stateFor(step). requires_action gives the action kind (AUTH, KYC or PAYMENT). pending and processing give the phase (AUTH or KYC), else PROCESSING. A final status gives the state of the same name.
  • The step's surface and transitions come from action. A step without an action gets an AWAIT poll (poll, else the server default). A final step has no transitions.
  • A leg's status is tracked per leg in PublicSession.payment.legs.
  • When a leg succeeds and it is not the last one, the server starts the next leg at once. The session shows PROCESSING meanwhile.
  • When every leg succeeded, the step is COMPLETED.
  • The server merges each new leg step into the current one (mergeLegStep). The leg keeps what a later step leaves out: ref, providerRef, output and every transaction. While the user must act, a requires_action step without an action surface keeps the current surface. For example, a status poll while the user pays in a provider page keeps the provider page.
  • Every adapter step and event goes through one entry check first (sanitizeLegStep), also for earlier attempts. See Security: adapter data.

The session's status follows the step: requires_payment_method (no active payment, or the last attempt failed and the user can try again), requires_action (the active leg waits for the user, for example to pay), processing (the user paid or acted, and the provider or the chain works), succeeded, failed (final), canceled, expired, refunded or reversed. A FAILED step gives requires_payment_method with lastError while the session has attempts left, and failed when it does not (see Session status).

Errors as fields ​

Errors do not end the flow with an exception. They travel as fields:

  • Step.error on a failed step, with a recovery hint.
  • errors next to quotes, one per pathway that could not be quoted.
  • MethodOption.reason on an unavailable method.
  • HTTP error responses as { "error": OpenRampError }.
ts
type OpenRampError = {
  code: OpenRampErrorCode
  message: string      // safe to show to the user
  retryable: boolean
  recovery?: 'requote' | 'retry_payment' | 'choose_other' | 'contact_support'
  legId?: string
}
CodeDefault messageRetryable
REGION_UNSUPPORTEDThis method is not available in your region.No
AMOUNT_TOO_LOWThe amount is below the minimum for this method.No
AMOUNT_TOO_HIGHThe amount is above the maximum for this method.No
QUOTE_EXPIREDThe quote expired. Get a new quote to continue.Yes
NO_QUOTESNo provider can serve this amount right now. Try another method or amount.Yes
PROVIDER_DECLINEDThe provider declined this payment. Try another method.No
KYC_REJECTEDThe provider could not verify your identity.No
PAYMENT_FAILEDThe payment did not go through. You can try again.Yes
PAYMENT_REVERSEDThe provider refunded or reversed this payment after it completed.No
DELIVERY_FAILEDThe funds could not be delivered. Contact support.No
RATE_LIMITEDToo many requests. Wait a moment and try again.Yes
PROVIDER_UNAVAILABLEThe provider is not available right now.Yes. A provider that refuses our credentials (HTTP 401 or 403) gives a setup error: "{Provider} is not set up for this app yet. Try another method.", not retryable, recovery choose_other.
CLIENT_UPGRADE_REQUIREDUpdate the app to use this method.No
SESSION_EXPIREDThis session expired. Start a new deposit.No
UNAUTHORIZEDThis session is not valid.No
CONFLICTThe session changed at the same time. Try again.Yes
ADDRESS_REJECTEDThis address cannot receive withdrawals. Use another address.No
DESTINATION_NOT_ALLOWEDThis app does not allow withdrawals to this target.No
DESTINATION_LOCKEDThe app set where these funds go. You cannot change it.No
BAD_REQUESTThe request is not valid.No
NOT_FOUNDNot found.No
INTERNALSomething went wrong on our side.Yes

Adapters may add their own codes. Build errors with openRampError(code, overrides), and throw them across boundaries as new OpenRampException(error, httpStatus).

The client side ​

DepositController in @openrampkit/client turns the steps into screens: loading, target (withdraw only), methods, amount, quotes, step, result, error. See API: @openrampkit/client.

Processing, then complete

MIT licensed