Collect a Top-Up Card
Collect an external card for top-up inside Wirex-served iframes and hand the resulting card id to the top-up API.
Before You Start
Read the following guides before proceeding:
| Guide | Why |
|---|---|
| PCI Compliant SDK | Isolation model and package map |
| SDK Setup | Client id, environments, installation, CSP |
| Card Top-Up | The REST flow the collected card feeds |
| Events and Error Handling | Change events and failure modes |
Overview
@wirexapp/card-topup-web collects an external debit or credit card — PAN, expiry, CVV and cardholder
name — inside Wirex-served iframes and tokenizes it straight from the frame. Your page receives a cardId
and non-sensitive metadata.
The cardId is the same value that
Top-Up Card Registration returns from
POST {pci_base_url}/b2b/cards/topup. Using the SDK replaces that direct call: card data reaches Wirex
from inside the frame, so the PAN and CVV never enter your application.
Card top-up requires the CardTopup capability to be active. Check it with GET /api/v2/user before
mounting the fields.
Choose a Layout Variant
| Per-field | Combined | |
|---|---|---|
| Creator | createCardTopUpFields(sdk, config) | createCardTopUpForm(sdk, config) |
| Iframes | One per field — number, expiry, cvv, name | One, containing every field |
| Layout control | Yours — each field mounts into your own container | Wirex's stock layout |
| Mount call | field(name).mount(container), four times | mount(container), once |
| Tokenizer | tokenizeTopUp(params) | tokenizeTopUp(params) |
Both accept the same config and return the same result. Per-field is the recommended variant; combined is
the shortest drop-in. Follow one path top to bottom — the two are not mixed within one card form.
Configuration
createCardTopUpFields(
{ environment, clientId },
{
auth: { getBearerToken },
appearance,
strings,
onChange,
},
);| Field | Type | Required | Description |
|---|---|---|---|
auth.getBearerToken | () => Promise<string> | Yes | Returns a fresh user token. Called at tokenize in the per-field variant; at mount and at tokenize in the combined variant |
appearance | object | No | Whitelisted styling tokens — see Appearance and Localization |
strings | object | No | Translated labels and validation messages — see Strings Keys |
onChange | (state) => void | No | Aggregated validity and detected brand. Never carries card values |
appearance and strings are read when a field mounts. Changing either afterwards has no effect until the
element is destroyed and recreated.
Step 1: Mount the Fields
import { createCardTopUpFields } from '@wirexapp/card-topup-web';
const topUp = createCardTopUpFields(
{ environment: 'uat', clientId: 'a4f6c1e2-9b03-4d77-8a51-2f0c6b8e4d19' },
{
auth: { getBearerToken: () => session.getAccessToken() },
onChange: state => {
submitButton.disabled = !state.complete;
brandIcon.dataset.brand = state.brand;
},
},
);
await topUp.field('number').mount(document.querySelector('#card-number'));
await topUp.field('expiry').mount(document.querySelector('#card-expiry'));
await topUp.field('cvv').mount(document.querySelector('#card-cvv'));
await topUp.field('name').mount(document.querySelector('#card-name'));Each field() handle exposes mount(container) and destroy(). The frames combine their values inside
the Wirex origin at tokenize time; the values never pass through your page.
All four fields must be mounted before tokenizing. The card-number field aggregates the others and
issues the request.
| Call | Throws |
|---|---|
field(name) for a name outside the profile | Field "<name>" is not part of the "topup" profile |
field(name) twice for the same name | Field "<name>" already created for this group |
tokenizeTopUp() without a mounted number field | The card-number field is not mounted |
The combined variant mounts once:
import { createCardTopUpForm } from '@wirexapp/card-topup-web';
const topUp = createCardTopUpForm(sdk, { auth, onChange });
await topUp.mount(document.querySelector('#card-form'));Step 2: Gate Your Submit Button
onChange reports non-sensitive state while the user types.
onChange: state => {
state.complete; // boolean — every field of the profile passes validation
state.brand; // 'Visa' | 'MasterCard' | 'unknown'
state.fields; // { number: { valid, touched }, expiry: {…}, cvv: {…}, name: {…} }
}| Field | Type | Description |
|---|---|---|
complete | boolean | true when every field of the profile is valid |
brand | enum | Visa, MasterCard or unknown |
fields | object | Per-field { valid, touched }, keyed by field name |
Step 3: Tokenize
Collect the billing address with your own form components — it carries no cardholder data.
const result = await topUp.tokenizeTopUp({
billingDetails: {
line1: '1 High Street',
line2: 'Flat 2',
city: 'London',
state: 'LND',
zipCode: 'N1 9GU',
country: 'GB',
},
label: 'Personal Visa',
});| Field | Type | Required | Description |
|---|---|---|---|
billingDetails.line1 | string | Yes | First address line |
billingDetails.line2 | string | No | Second address line |
billingDetails.city | string | Yes | City |
billingDetails.state | string | No | State or region |
billingDetails.zipCode | string | Yes | Postal code |
billingDetails.country | string | Yes | ISO 3166-1 alpha-2 country code |
label | string | No | Saved-card nickname. Falls back to the cardholder name typed in the name field when blank |
Result:
{
"cardId": "c34908da-d980-4a9c-9b39-4dabd6f6144e",
"brand": "Visa",
"bin": "424242",
"last4": "4242",
"expiryMonth": 12,
"expiryYear": 2028
}| Field | Description |
|---|---|
cardId | Card identifier. The only value to store |
brand | Visa, MasterCard or unknown |
bin | First 6 digits of the PAN |
last4 | Last 4 digits of the PAN |
expiryMonth | Expiry month, 1–12 |
expiryYear | Expiry year, four digits |
bin and last4 are out of PCI scope and safe to display. Do not log the result beyond cardId, brand
and last4.
Step 4: Estimate and Execute the Top-Up
cardId is passed as external_card_id to the top-up endpoints. Full request and response documentation
is in Top-Up Transfer.
const estimate = await fetch(`${baseUrl}/api/v1/cards/topup/estimate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-User-Wallet': userEoaAddress,
'X-Chain-Id': chainId,
'Content-Type': 'application/json'
},
body: JSON.stringify({
external_card_id: result.cardId,
destination_amount: '100000000',
destination_token_address: destinationTokenAddress
})
}).then(response => response.json());
const execute = await fetch(`${baseUrl}/api/v1/cards/topup/execute`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-User-Wallet': userEoaAddress,
'X-Chain-Id': chainId,
'Content-Type': 'application/json'
},
body: JSON.stringify({ estimation_id: estimate.estimation_id })
}).then(response => response.json());Step 5: Run the 3DS Challenge
When the execute response carries three_ds_state, the cardholder completes the issuer challenge before
the top-up settles.
import { createThreeDsChallenge } from '@wirexapp/card-3ds-web';
if (execute.three_ds_state?.url) {
const challenge = createThreeDsChallenge({
url: execute.three_ds_state.url,
onComplete: status => finishTopUp(status),
onError: message => { showToast(message); finishTopUp('Failed'); },
});
challenge.mount(document.querySelector('#threeds-container'));
}The REST response field is
three_ds_statein snake_case. The 3DS element takes itsurlvalue
verbatim. See 3DS Challenge Element.
Field Validation
The frames validate as the user types and render error messages inline inside the frame, styled by the
colorError token and localized by strings. Do not re-implement card validation on your page.
| Field | Rule | Message key |
|---|---|---|
number | Not empty | errRequired |
number | 13–19 digits after stripping non-digits | errCardNumber |
number | Brand detected as Visa or Mastercard | errCardBrand |
number | Passes the Luhn check | errCardLuhn |
expiry | Matches MM/YY and month is 01–12 | errExpiryFormat |
expiry | End of the expiry month is in the future | errExpired |
cvv | 3 or 4 digits | errCvv |
name | Not empty after trimming | errName |
Strings Keys
Pass already-translated values; the frames have no i18n runtime.
| Key | Default |
|---|---|
cardNumberLabel | Card Number |
expiryLabel | MM/YY |
cvvLabel | CVV |
nameLabel | Full Name |
errRequired | Required |
errCardNumber | Card number must be 13-19 digits |
errCardBrand | Only Visa/Mastercard cards are supported |
errCardLuhn | Card number is invalid |
errExpiryFormat | Use MM/YY format |
errExpired | Card has expired |
errCvv | CVV must be 3 or 4 digits |
errName | Enter cardholder name |
Error Handling
Failures the frame reports — validation and backend rejections — reject with PciFrameRequestError. Its
message is a safe, non-sensitive description and status carries the HTTP status of the backend call
where one was made. Transport and lifecycle failures reject with a plain Error.
import { PciFrameRequestError } from '@wirexapp/card-topup-web';
try {
const result = await topUp.tokenizeTopUp({ billingDetails });
} catch (error) {
const isFieldValidation =
error instanceof PciFrameRequestError && error.message.startsWith('Validation failed');
if (!isFieldValidation) {
showToast('Failed to add card');
}
}| Message | Error class | Cause | Resolution |
|---|---|---|---|
Validation failed — … | PciFrameRequestError | A field is empty or invalid at tokenize time | Show nothing extra. The inline message is already visible inside the frame |
Backend error text, status set | PciFrameRequestError | Tokenization rejected by Wirex | Surface a generic failure and let the user retry |
Network error | PciFrameRequestError | The frame could not reach Wirex | Surface a retry action |
PCI frame request timed out | Error | No response within 30 seconds | Surface a retry action |
PCI frame is not mounted | Error | tokenizeTopUp called before mounting or after destroy() | Create a new element |
Mount failures, lifecycle rules and the full error surface are documented in
Events and Error Handling.
Lifecycle
Call destroy() on the element when your view unmounts. Elements are single-use: after destroy(), create
a new one rather than remounting.
topUp.destroy();Updated 15 days ago

