---
updatedAt: 2026-08-20T10:06:12.000Z
agentTools:
  projectIndex: https://docs.wirexapp.com/llms.txt
---

# 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](https://docs.wirexapp.com/docs/retail-pci-sdk)                | Isolation model and package map            |
| [SDK Setup](https://docs.wirexapp.com/docs/retail-pci-sdk-setup)                  | Client id, environments, installation, CSP |
| [Card Top-Up](https://docs.wirexapp.com/docs/retail-card-topup)                   | The REST flow the collected card feeds     |
| [Events and Error Handling](https://docs.wirexapp.com/docs/retail-pci-sdk-events) | 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](https://docs.wirexapp.com/docs/retail-topup-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

```javascript
createCardTopUpFields(
  { environment, clientId },
  {
    auth: { getBearerToken },
    appearance,
    strings,
    onChange,
  },
);
```

| Field                 | Type                    | Required | Description                                                                                                                                                                     |
| --------------------- | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auth.getBearerToken` | `() => Promise<string>` | Yes      | Returns a fresh [user token](https://docs.wirexapp.com/docs/retail-pci-sdk-setup#which-token-the-sdk-needs). 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](https://docs.wirexapp.com/docs/retail-pci-sdk-appearance)                                                                                   |
| `strings`             | object                  | No       | Translated labels and validation messages — see [Strings Keys](#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

```javascript
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:

```javascript
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.

```javascript
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.

```javascript
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:**

```json
{
  "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](https://docs.wirexapp.com/docs/retail-topup-transfer).

```javascript
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.

```javascript
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_state` in snake\_case. The 3DS element takes its `url` value
> verbatim. See [3DS Challenge Element](https://docs.wirexapp.com/docs/retail-pci-sdk-3ds).

***

## 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`.

```javascript
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](https://docs.wirexapp.com/docs/retail-pci-sdk-events).

***

## Lifecycle

Call `destroy()` on the element when your view unmounts. Elements are single-use: after `destroy()`, create
a new one rather than remounting.

```javascript
topUp.destroy();
```