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 is | Who picks | |
|---|---|---|
| Card provider plugins | The 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 registry | createProvider / 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 id | Backend dir | kind | canSaveCard | How the card is captured | Matched by |
|---|---|---|---|---|---|
stripe | card/stripe | self-tokenizing | yes | Stripe.js + @stripe/react-stripe-js are loaded lazily with session.public_key; the payment is confirmed in-browser from the confirm_inline auth action | provider === 'stripe' |
omise | card/omise | self-tokenizing | yes | The Omise.js card popup tokenizes the PAN, then the flow continues by redirect | provider === 'omise' |
kbank | card/kbank | redirect | no | The bank hosts the page — the SDK renders zero card inputs and only follows next_action[type === 'redirect'].url | provider === 'kbank' |
scb | card/scb | redirect | no | same | provider === 'scb' |
krungsri | card/krungsri | redirect | no | same | provider === 'krungsri' |
vault | card/lodash | vault | yes | ECDH: the PAN is encrypted to a per-session public key and the ciphertext is POSTed to the vault | session_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') usepriority: 100. - The field-sniffing
vaultmatcher usespriority: 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')?.urlRendering — 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_keyis 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-jsand@stripe/react-stripe-jsare imported lazily and initialized withsession.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.jsis 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
| Cannot | Which acquirer a session uses. That is per-app, per-country routing configured server-side; the SDK reads session.provider and obeys it. |
| Cannot | Whether a provider can save cards, or whether it renders card fields. Those are plugin capabilities (kind, canSaveCard), not options. |
| Cannot | Add a card provider to the checkout UI from your app. Plugins live in the SDK package (src/providers/<name>/) and ship in the bundle. |
| Can | apiEndpoint, vaultEndpoint, publishableKey, frameUrl, locale, colorScheme. |
| Can | Own the redirect via onRedirect, and the completion via onPaid / onSaved / returnUrlOverride. |
| Can | Register 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.