Paycose Docs
Checkout SDK

Payment Providers

How the SDK resolves a card provider per session, which providers are live, and the separate createProvider/registerProvider API registry.

Two different registries, one word

The SDK has two things called "provider". They do not talk to each other.

What it isWho picks
Card provider pluginsThe real system. One plugin per acquirer (stripe, omise, kbank, scb, krungsri, vault). Each owns its charge form and its setup-intent form.The server. The plugin is resolved from the session the wallet returns.
API provider registrycreateProvider / registerProvider / getAvailableProviders — a factory map for the low-level LodashProvider API client.You, by name, when you construct a client by hand.

The embedded checkout, <PaymentElement> and <SetupIntentElement> all use the card plugin registry. Nothing you register through registerProvider changes which form the checkout renders.


The live card providers

Each plugin mirrors backend/wallet/plugins/payment-methods/card/<provider> and declares its own capabilities.

Plugin idBackend dirkindcanSaveCardHow the card is capturedMatched by
stripecard/stripeself-tokenizingyesStripe.js + @stripe/react-stripe-js are loaded lazily with session.public_key; the payment is confirmed in-browser from the confirm_inline auth actionprovider === 'stripe'
omisecard/omiseself-tokenizingyesThe Omise.js card popup tokenizes the PAN, then the flow continues by redirectprovider === 'omise'
kbankcard/kbankredirectnoThe bank hosts the page — the SDK renders zero card inputs and only follows next_action[type === 'redirect'].urlprovider === 'kbank'
scbcard/scbredirectnosameprovider === 'scb'
krungsricard/krungsriredirectnosameprovider === 'krungsri'
vaultcard/lodashvaultyesECDH: the PAN is encrypted to a per-session public key and the ciphertext is POSTed to the vaultsession_id + intent_id + public_key all present

The vault plugin is named for the mechanism, not for the wire value. Its sessions arrive with provider: 'lodash'; it matches on the three fields the wallet only populates when it has minted a per-session vault key.


How one plugin is picked

The session decides. PaymentSessionResponse and SetupSessionResponse both carry provider, public_key, and optionally session_id / intent_id. The resolver walks every registered plugin and keeps the highest-priority plugin whose matches() accepts the session — array order is not load-bearing.

  • Name matchers (provider === 'stripe', 'omise', 'kbank', 'scb', 'krungsri') use priority: 100.
  • The field-sniffing vault matcher uses priority: 10.

So a Stripe session that also happens to carry session_id + intent_id + public_key still resolves to stripe. If nothing matches — an acquirer the SDK has no plugin for, e.g. adyen — the orchestrator renders an "unsupported provider" notice instead of a form. It never falls back to a generic card form.

You can read the resolved acquirer yourself from the session:

import { CheckoutClient } from '@checkout/sdk'

const client = new CheckoutClient({
  apiEndpoint: 'https://api.example.com',
  publishableKey: 'pk_test_1234',
})

const session = await client.fetchChargeSession(chargeSession)
session.provider   // 'stripe' | 'omise' | 'kbank' | 'scb' | 'krungsri' | 'lodash'
session.public_key // provider key, or '' for redirect-only banks
session.next_action?.find((a) => a.type === 'redirect')?.url

Rendering — you never choose the form

Mount the element, hand it the session, and the right provider form appears.

import { PaymentElement } from '@checkout/sdk/react'
import '@checkout/sdk/styles.css'

export function Pay({ session }: { session: string }) {
  return (
    <PaymentElement
      session={session}
      config={{
        apiEndpoint: 'https://api.example.com',
        vaultEndpoint: 'https://vault.example.com',
      }}
      onPaid={(returnUrl) => {
        if (returnUrl) location.href = returnUrl
      }}
    />
  )
}

vaultEndpoint is only consumed by the vault plugin, but you cannot know in advance which plugin a session will resolve to — set it once and forget it:

import { configure } from '@checkout/sdk'

configure({
  apiEndpoint: 'https://api.example.com',
  vaultEndpoint: 'https://vault.example.com',
  frameUrl: 'https://pay.example.com/embed',
  publishableKey: 'pk_test_1234',
})

Redirect-only providers (kbank, scb, krungsri)

These three share one adapter. What that means in practice:

  • No card inputs exist in the DOM. The form is a spinner plus a "Continue to KBank / SCB / Krungsri" button; the bank's own page collects the PAN.
  • No public key, no token. session.public_key is empty.
  • Cards cannot be saved. canSaveCard: false — the setup-intent form renders a "cards cannot be saved with this provider" notice instead of a form. Do not build a save-card UX that assumes every provider supports it.
  • The redirect must break out of the iframe. A bank page sets X-Frame-Options, so it can never render inside the embed.

That last point is why the default redirect behaviour matters:

// Correct — the SDK navigates the top window itself
<PaymentElement session={session} config={config} embedMode />

// Only if you must observe or override it. You now own the navigation:
// a handler that just logs strands the customer on a blank frame.
<PaymentElement
  session={session}
  config={config}
  embedMode
  onRedirect={(url) => myRouter.hardNavigate(url)}
/>

Precedence is: explicit onRedirect → embedMode ? navigateTop : window.location.assign.


Self-tokenizing providers (stripe, omise)

Both keep the PAN out of the wallet API: the provider's own script tokenizes it in the browser.

  • stripe — @stripe/stripe-js and @stripe/react-stripe-js are imported lazily and initialized with session.public_key. A saved card that carries no client secret is short-circuited before the Stripe.js gate, so a blocked or failed script load cannot strand a 3DS hand-off.
  • omise — Omise.js is loaded and its card popup is opened; the resulting token is posted to the wallet, which replies with a redirect for 3DS.

Both support saving cards, so <SetupIntentElement> renders a real form for them.


What a merchant can and cannot change

CannotWhich acquirer a session uses. That is per-app, per-country routing configured server-side; the SDK reads session.provider and obeys it.
CannotWhether a provider can save cards, or whether it renders card fields. Those are plugin capabilities (kind, canSaveCard), not options.
CannotAdd a card provider to the checkout UI from your app. Plugins live in the SDK package (src/providers/<name>/) and ship in the bundle.
CanapiEndpoint, vaultEndpoint, publishableKey, frameUrl, locale, colorScheme.
CanOwn the redirect via onRedirect, and the completion via onPaid / onSaved / returnUrlOverride.
CanRegister your own API provider factory — see below. It does not touch the checkout UI.

The API provider registry

Separate, smaller, and unrelated to the card plugins. It is a name → factory map used when you drive the wallet API directly instead of mounting the checkout UI.

import { createProvider, getAvailableProviders } from '@checkout/sdk'

getAvailableProviders() // ['lodash']

const provider = createProvider('lodash', {
  apiEndpoint: 'https://api.example.com',
  vaultEndpoint: 'https://vault.example.com',
  publishableKey: 'pk_test_1234',
})

provider.getPublishableKey() // 'pk_test_1234'

createProvider throws Unknown provider: <name> for anything not in the map. The only built-in entry is lodash, backed by the exported LodashProvider class.

ProviderConfig

Prop

Type

LodashProviderConfig extends it with sessionToken, country, cardTokenizationKey, onetimeSession, sessionId and intentId.

PaymentProvider

The interface a registered factory must return.

Prop

Type

Registering your own

Annotate the factory's return type as PaymentProvider and every method argument is contextually typed for you.

import { registerProvider, createProvider, type PaymentProvider } from '@checkout/sdk'

function myProvider(config: { apiEndpoint: string; publishableKey?: string }): PaymentProvider {
  function post<T>(path: string, body: unknown): Promise<T> {
    return fetch(`${config.apiEndpoint}${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    }).then((r) => r.json() as Promise<T>)
  }

  return {
    name: 'my-provider',
    createCheckoutSession: (options) => post('/sessions', options),
    createSetupIntent: (options) => post('/setup-intents', options),
    confirmPayment: (clientSecret, paymentDetails) =>
      post('/confirm-payment', { clientSecret, paymentDetails }),
    confirmSetup: (clientSecret, paymentDetails) =>
      post('/confirm-setup', { clientSecret, paymentDetails }),
    getPublishableKey: () => config.publishableKey,
  }
}

registerProvider('my-provider', myProvider)

const mine = createProvider('my-provider', { apiEndpoint: 'https://api.example.com' })

registerProvider only adds an entry to this factory map. It does not add a card provider to the embedded checkout, <PaymentElement> or <SetupIntentElement> — those resolve plugins from the session, and an acquirer with no plugin renders the unsupported-provider notice. Note also that createProvider is typed as returning LodashProvider, so a custom factory's extra methods need a cast.


Next

On this page