Collect a card in React Native. The card details never touch your app code.
Authored in ReScript, published as JavaScript with genType-generated TypeScript declarations — you
need neither. No native step: no native module, no pod install, no Codegen, no autolinking. Peers
are react (>=19 <20) and react-native (>=0.79 <0.88); no runtime dependencies.
Where hyperswitch-web's separate card fields have a name, this library uses it; what it adds is additive and cannot collide.
| Web SDK | This library |
|---|---|
cardForm.create('cardNumber' | 'cardExpiry' | 'cardCvc', options) |
<CardNumberField /> <CardExpiryField /> <CardCVCField />, options as props |
cardForm.tokenize() |
tokenize() on the CardForm ref, or createCardForm().tokenize() |
field.on(…), cardForm.on(…) |
onReady onFocus onBlur onChange props, or createCardForm().on() |
change payload, cardDetailsChange envelope |
the same keys, plus touched, errorCode, isCoBadged, canSubmit, fields |
{error: {code, message, type}}, placeholder, savedCard, appearance, locale |
the same envelope, codes and names, plus a status discriminant |
The root entry is the merchant's: tokenize(), and nothing that moves money. The Hyperswitch
checkout SDK drives payment confirmation through …/host with the same components; merchants never
import it.
yarn add @juspay-tech/react-native-hyperswitch-vaultYour server creates the payment-method session with your secret key; pass the response through
untouched. (vaultDetails={{vaultType: 'hyperswitch', vaultData: {sdkAuthorization}}}, the web
SDK's spelling, is accepted in place of session — at the cost of not knowing its expires_at.)
import {
CardForm, CardNumberField, CardExpiryField, CardCVCField,
type VaultFormHandle,
} from '@juspay-tech/react-native-hyperswitch-vault';
const formRef = useRef<VaultFormHandle>(null);
const [canSave, setCanSave] = useState(false);
<CardForm
ref={formRef}
session={session}
environment="sandbox"
locale="en"
onChange={e => setCanSave(e.canSubmit)}>
<CardNumberField />
<View style={{flexDirection: 'row', gap: 12}}>
<CardExpiryField />
<CardCVCField />
</View>
</CardForm>;Then, on your button:
const result = await formRef.current?.tokenize();
if (result?.status === 'success') {
await sendTokenToYourBackend(result.token); // never store or display the token in the app
} else if (result?.error) {
showMessage(result.error.message); // result.error.code names the cause
}Unconfigured, a field renders a complete input: placeholder, floating label, brand mark, CVC glyph,
error tint. Composed fields print no error text — it arrives on onChange as error, for you to
place; the ready-made HyperswitchVaultForm prints it.
tokenize() takes no arguments and moves no money. A premature press answers validation_error or
incomplete_field_set with no network request; onChange gives you canSubmit as they type.
Mount only CardCVCField, pass the stored card's token and network, settle with the same
tokenize().
<CardForm ref={formRef} session={session} environment="sandbox" onChange={e => setReady(e.canSubmit)}>
<CardCVCField
savedCard={{
paymentToken: entry.payment_method_token,
paymentMethodData: {card: {cardNetwork: entry.payment_method_data.card.card_network}},
}}
/>
</CardForm>;
const result = await formRef.current?.tokenize(); // use result.token, not the one you passedYour backend lists the cards with GET /v1/payment-method-sessions/{id}/list-payment-methods on the
same session and reads requires_cvv: false charges the listed token with nothing mounted,
true mounts this field with that entry's token. The CVC is held under the returned token for 15
minutes — confirm inside that window.
cardNetwork sets the CVC length rule ('amex' and similar aliases are understood); without it
valid turns true at three digits even on an Amex. The field must be the only one in the form, and
paymentToken must be present — blank, tokenize() answers validation_error naming the fix.
Place the fields yourself; everything else is identical. Exactly one card-number, one expiry and one
CVC field per form (or one CVC field with savedCard). CardholderNameField is the one field the
web SDK lacks; blank, it is omitted from the request.
<CardForm ref={formRef} session={session} environment="sandbox" appearance={{labels: 'above'}}>
<CardholderNameField label="Name on card" />
<CardNumberField placeholder="Card number" cardBrandIcon="standard" />
<CardExpiryField placeholder="MM / YY" />
<CardCVCField placeholder="CVC" cvcIcon="default" />
</CardForm>;Callbacks fire once after mount, then only when the snapshot changes — an inline arrow is safe. Pass none and nothing is derived. No field event carries a card value.
On a field. onReady, onFocus and onBlur give {elementType}. onChange gives:
| Key | Meaning |
|---|---|
empty, complete, valid |
complete is identical to valid, as on the web |
brand |
'Visa' | 'Mastercard' | 'AmericanExpress' | …, absent until detected |
error, errorCode |
the message on screen and its code (required, invalid_card_number, invalid_expiry, invalid_cvc) — present once the customer leaves a bad field, gone while the cursor is back inside |
touched |
has the customer left this field yet? |
isCoBadged |
card number only: a genuine choice of network is being offered |
On the form. onReady gives {elementType: 'cardForm'} whenever all required fields become
complete. onChange gives the web's cardDetailsChange envelope plus this library's summary:
| Key | Meaning |
|---|---|
payload |
bin, last4, brand, expiryMonth, expiryYear, formattedExpiry, isCardNumberComplete, isCvcComplete, isExpiryComplete, isCardNumberValid, isExpiryValid — null until known |
canSubmit |
fieldsReady && session usable && valid && !submitting |
sessionStatus |
'valid' | 'invalid' | 'absent' | 'expired' | 'consumed' |
fieldsReady, complete, valid, submitting, isCoBadged |
form-wide state |
networkError |
present when the network is not one you accept |
fields |
{cardNumber, cardExpiry, cardCvc, cardholderName?} — each a field change |
payload comes from the same sdk-utils function the web SDK uses: bin at six digits, last4 when
the number completes. It is the only place a card-derived digit reaches your code — never the PAN,
the CVC or the token.
For a form held outside React state:
const cardForm = createCardForm({session, environment: 'sandbox'});
cardForm.on('change', e => setEnabled(e.canSubmit));
<cardForm.Form>
<CardNumberField /> <CardExpiryField /> <CardCVCField />
</cardForm.Form>;
const result = await cardForm.tokenize();
cardForm.getState(); // the last `change`, or null/* root — merchants */
type VaultFormHandle = {
tokenize(): Promise<VaultTokenizeResult>;
reset(): void;
focus(field: 'cardNumber' | 'cardExpiry' | 'cardCvc' | 'cardholderName'): void;
};
/* each field's ref */
type VaultFieldHandle = {focus(): void; blur(): void; clear(): void};
/* ./host — the checkout SDK; the same runtime object */
type HostFormHandle = VaultFormHandle & {
confirmPayment(input: VaultPaymentConfirmInput): Promise<VaultPaymentResult>;
};- Repeating the same operation while it is pending returns the same promise, so double presses
are harmless. (The web answers a second call
tokenization_in_progress; this is deliberate.) - Requesting the other operation mid-flight (on
./host) answersconfirm_in_progress/tokenization_in_progresswith no request. reset()clears values, validation and errors, and is ignored mid-flight.clear()on a field ref clears that field.
/* The ONLY published type carrying a token. */
type VaultTokenizeResult =
| {status: 'success'; token: string; card?: VaultTokenizedCard}
| {status: 'validation_error'; error: SafeVaultError}
| {status: 'error'; error: SafeVaultError};
/* The vault's echo of the card it stored — the members `onChange` publishes, and no more.
Present for a new card, absent on the saved-card CVC refresh. An absent member is an
absent key, never `undefined`. */
type VaultTokenizedCard = {
bin?: string; last4: string; brand?: string; expiryMonth: string; expiryYear: string;
};
type SafeVaultError = {
code: SafeVaultErrorCode;
message: string; // library-owned, customer-safe
type: 'validation_error' | 'api_error' | 'card_error'; // the web's classification
};if (result.error) works as it does on the web; status is this library's addition so TypeScript
can narrow.
error.code |
Meaning | Request sent? |
|---|---|---|
validation_error |
a field is empty or malformed, or savedCard has no token |
no |
incomplete_field_set |
a required field is missing or mounted twice | no |
session_expired |
the session's expires_at has passed |
no |
session_consumed |
this session already tokenized a card | no |
invalid_session |
no session, an unreadable one, or another vault's | no |
unsupported_configuration |
an invalid endpoint, or savedCard beside a card-number field |
no |
tokenization_failed |
the vault refused, or answered unreadably | yes |
unknown_outcome |
the request threw, timed out, or was aborted — reconcile before retrying | unknown |
confirm_in_progress |
./host only: a payment confirmation is in flight |
no |
<CardForm
appearance={{
variables: {colorPrimary: '#0570DE', colorText: '#1A1A1A', borderRadius: 8, inputFieldHeight: 48},
labels: 'floating', // 'above' | 'floating' | 'never', for every field
}}
locale="fr" // any code the web SDK accepts; the same sdk-utils bundles
localisation={{validationMessages: {cardNumberInvalid: 'Vérifiez le numéro'}}}
/>variables takes the web's names — colorPrimary, colorText, colorDanger,
colorTextPlaceholder, colorBackground, borderColor, borderRadius, fontFamily,
inputFieldHeight — plus this library's borderWidth, gap, fontScale,
placeholderTextSizeAdjust, errorTextSizeAdjust, errorMessageSpacing and cardBrandIcon.
The web's theme, rules, innerLayout and fonts are CSS concepts with no React Native
analogue. Per-field looks use styles slots (root, container, input, placeholder, label,
error, accessory), which patch the theme rather than replace it.
| Prop | Fields | Values | Default |
|---|---|---|---|
placeholder |
all | any string; '' renders none |
the locale's, or the web's 1234 1234 1234 1234 / 123 |
label |
all | any string; '' renders none |
the locale's |
labelBehavior |
all | 'above' | 'floating' | 'never' |
appearance.labels, else 'floating' |
errorDisplay |
all | 'none' | 'colorOnly' | 'inline' |
'colorOnly' composed, 'inline' ready-made |
cardBrandIcon |
card number | 'standard' | 'hidden' | 'animated' | 'hideGeneric' |
appearance.variables.cardBrandIcon, else 'standard' |
cvcIcon |
CVC | 'hidden' | 'default' |
'default' |
savedCard |
CVC | {paymentToken, paymentMethodData: {card: {cardNetwork}}} |
none |
unstyled |
all | boolean | the form's unstyled |
accessibilityLabel, accessibilityHint, testID |
all | string | library defaults |
enabledCardSchemes on the form restricts the networks you accept; spellings are canonicalised
('visa', 'amex', 'American Express'), and an unrecognised entry is ignored with a
development-only warning.
environment selects a public Hyperswitch host; vaultEndpoint overrides it with your own, and is
where tokenize() posts:
<CardForm session={session} environment="sandbox" vaultEndpoint={{baseUrl: 'https://payments.your-company.example/api'}} />The base is validated — https required (http only on loopback, never in production), no
credentials, query string or fragment. A base that fails returns unsupported_configuration with
nothing sent.
- A session is single-use. After a successful
tokenize()it isconsumed:canSubmitturns false and a second call answerssession_consumed, as on the web. Fetch one session per card. expires_atis honoured when the session response is passed through —sessionStatusreportsexpiredandtokenize()answerssession_expiredwithout a request.- Replacing the
sessionprop aborts in-flight work and starts fresh. - A minted token is never re-minted. On
./host, a failed payment confirm retries only the confirm.
- Secret API key: server only. Never in the app, an app
.env, or version control. - Never log or display the session or the
sdk_authorization. This library logs nothing at all, beyond a development-only warning for an unrecognisedenabledCardSchemesentry. - The payment-method token belongs on your backend, not in your app and not in your logs.
PAN, expiry and CVC never cross the library's supported public API. They stay in library-owned state and are sent only by the library's own tokenization transport. You receive the card-details payload (BIN, last four, expiry parts), safe UI state, and the token.
An API and data-flow guarantee — not process isolation, memory zeroization, a PCI DSS claim, or protection from malicious code inside your own app. Only your assessor can determine your scope.
| Symptom | Cause |
|---|---|
invalid_session immediately, nothing sent |
the session has no vault_details, an unsupported vault_type, or a blank authorization. Check your server returned the response verbatim. |
incomplete_field_set |
a required field is not mounted, or is mounted twice. |
session_consumed |
this session already tokenized a card. Fetch a new one. |
validation_error with a message about savedCard |
the lone CVC field has no paymentToken. |
every card reports networkError |
enabledCardSchemes contains no recognised network. Check the development warning. |
Cannot read properties of null (reading 'useMemo') at render |
two copies of React in the bundle. Alias react, react-dom and react-native to single absolute paths. |
| 0.8 | 0.9 |
|---|---|
CardCVCWidget, CardNumberWidget, CardExpiryWidget, CardholderNameWidget, HyperswitchVault.* |
CardCVCField, CardNumberField, CardExpiryField, CardholderNameField |
'expiry', 'cvc' (in focus(), state.field, fields.*, fieldOptions.*, fieldStyles.*) |
'cardExpiry', 'cardCvc' |
onStateChange / onFormStateChange |
onChange (+ onReady, onFocus, onBlur) |
state.status, state.focused, state.error.code |
empty/complete, focus/blur events, errorCode |
brand: 'visa' | 'americanExpress' | … | 'unknown' |
brand: 'Visa' | 'AmericanExpress' | …, absent when unknown |
brandIconMode |
cardBrandIcon |
cvcIcon: 'none' |
cvcIcon: 'hidden' |
labelBehavior: 'static' | 'none' |
'above' | 'never' (and form-wide appearance.labels) |
appearance.primaryColor, textColor, errorColor, placeholderColor, backgroundColor, inputHeight, brandIconMode |
appearance.variables.colorPrimary, colorText, colorDanger, colorTextPlaceholder, colorBackground, inputFieldHeight, cardBrandIcon |
HyperswitchVaultSavedCardForm + updateSavedPaymentMethod() |
<CardCVCField savedCard={…} /> inside <CardForm> + tokenize() |
invalid_card_data, not_ready, server_error |
validation_error, incomplete_field_set, tokenization_failed |
createCardForm().subscribe(cb) |
createCardForm().on('change', cb) |
example/ is a runnable React Native app and example-server/ a dependency-free merchant backend.
They are separate directories on purpose — the secret API key belongs only on the server.