> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fluz.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Secure Card Input

> Collect a physical card and add it as a payment method on a user's Fluz account, without the PAN or CVV ever touching your servers or your page's JavaScript.

<Note>
  This page assumes you've read [Secure Elements Overview](/build-a-platform/secure-elements-overview) — it covers loading the SDK and the shared client-token flow, both of which apply here.
</Note>

## See it live

<iframe src="https://demo.secure.fluz.app/collect/" title="Fluz Secure Elements — Secure Card Input demo" loading="lazy" style={{ width: "100%", height: "720px", border: "1px solid #e5e5e5", borderRadius: "8px" }} />

The demo mints its own token and mounts the fields automatically. Enter any card number that passes a Luhn check, fill in the cardholder name, and submit. Use **Remint token & remount** if the form stops responding. [Open it in its own tab →](https://demo.secure.fluz.app/collect/)

## Mint a tokenization token

Call [`POST /v1/client-token`](/build-a-platform/secure-elements-overview#mint-a-client-token) with `"purpose": "tokenization"`:

```json theme={null}
{
  "purpose": "tokenization"
}
```

Unlike a reveal token, this one doesn't need a `virtualCardId`. It does need a different scope on your access token — `MANAGE_PAYMENT`, not `CREATE_VIRTUALCARD` — and it's longer-lived (30 minutes by default) since a user filling out a card form takes longer than a reveal click.

## Render the fields

```js theme={null}
const inputs = renderFieldsForTokenization({
  clientToken,
  loadToken,
  frameHostOrigin: "https://staging.secure.fluz.app",
});
```

`renderFieldsForTokenization` has no `fields` option — PAN, expiry, and CVV always mount together as one combined frame, because CVV validation is brand-aware (an Amex CVV is 4 digits; every other brand is 3), which only works if the field knows the card number typed into the frame next to it. You can't mount them independently the way `createCardViewer`'s fields can be.

| Option                      | Required | Details                                                                                                           |
| :-------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------- |
| `clientToken` / `loadToken` | Yes      | From the mint call above                                                                                          |
| `frameHostOrigin`           | No       | Same allowlisted-origin rules as [Card Reveal](/build-a-platform/card-reveal) — defaults to production if omitted |
| `style`                     | No       | Same `{ color, fontSize, fontFamily, fontWeight }` as `createCardViewer` — see the caveat below                   |
| `mountTimeoutMs`            | No       | Defaults to 10 seconds                                                                                            |
| `submitTimeoutMs`           | No       | Defaults to 15 seconds — see [Submit](#submit)                                                                    |
| `excludedCardBrands`        | No       | e.g. `["amex"]` — see [Track field state](#track-field-state)                                                     |

<Warning>
  A Google Font passed in `style.fontFamily` renders in [Card Reveal](/build-a-platform/card-reveal)'s fields but is silently skipped here — these fields render inside a vendor-hosted vault iframe with no hook for loading external CSS into it. Only the system-font allowlist (`system-ui`, `Arial`, `Georgia`, `monospace`, and so on) actually applies a font in this capability.
</Warning>

## Mount it

```js theme={null}
await inputs.mount(document.getElementById("card-fields"));
```

Same shape as [Card Reveal](/build-a-platform/card-reveal#mount-it): rejects with `FluzElementsError` for `INVALID_STYLE`, `MOUNT_TIMEOUT`, or `MOUNT_FAILED` (frame failed to load, or this instance is already mounted). `frameHostOrigin` is validated synchronously when you call `renderFieldsForTokenization`, the same as `createCardViewer` — an unrecognized origin throws `INVALID_FRAME_HOST_ORIGIN` before you ever reach `mount()`.

## Track field state

```js theme={null}
inputs.onChange((field, state) => {
  // field: "pan" | "expiry" | "cvv"
  // state: { isEmpty, isValid, isDirty, brand? }
});
```

Fires on every keystroke inside the frame. `brand` is only present on `pan`'s state, detected from the digits typed so far: `amex`, `visa`, `mastercard`, `discover`, `diners`, or `jcb`. Use `isValid` to gate your own submit button and drive inline validation messaging — none of these fields expose the underlying value to your page.

`excludedCardBrands` (e.g. `["amex"]`) doesn't block typing — it forces `pan`'s `isValid` to `false` once a matching brand is detected, so a user can still enter the number but `submit()` won't succeed until they use a different card.

## Submit

Collect the cardholder name and billing address as ordinary inputs on your own page — the SDK doesn't render them inside a Fluz-hosted frame, since they aren't card data. Whether handling them yourself affects your own PCI DSS scope depends on your broader cardholder data environment; confirm with your QSA.

```js theme={null}
await inputs.submit({
  cardholderName: "Jane Doe",
  billingAddress: {
    line1: "123 Main St",
    line2: "Apt 4", // optional
    city: "Austin",
    state: "TX", // optional
    zipCode: "78701",
    country: "US",
  },
  isBackupPayment: false, // optional
});
```

`cardholderName` is split into first/last on the first space only — `"Mary Ann Smith"` becomes first name `"Mary"`, last name `"Ann Smith"`; a single-word name is used as both. To reuse an address already on the account instead of collecting a new one, pass `billingAddress: { userAddressId: "<uuid>" }`.

<Note>
  `submit()` almost never rejects, and never for a decline. It only throws synchronously for `MOUNT_FAILED` (not mounted yet) or `SUBMIT_FAILED` ("a submit() call is already in progress" — it ignores a second call while one is in flight). Every other outcome — success, decline, validation failure, timeout — resolves normally and arrives through the callbacks below instead.
</Note>

## Handle results

```js theme={null}
inputs.onSuccess((result) => {
  // result.bankCardId, brand, last4, expirationMonth, expirationYear,
  // cardholderName, billingAddress, createdAt
});

inputs.onDeclined((decline) => {
  // decline.code, decline.message
});

inputs.onError((error) => {
  // error.code, error.message
});
```

`onSuccess` fires once the card is added as a funding source. `onDeclined` fires for a card the processor rejected — still a normal, expected outcome, not an error:

| Decline code            | Meaning                                               |
| :---------------------- | :---------------------------------------------------- |
| `CARD_DECLINED`         | Generic decline                                       |
| `INSUFFICIENT_FUNDS`    | Declined for insufficient funds                       |
| `CARD_EXPIRED`          | Card has expired                                      |
| `CARD_INVALID`          | Card could not be verified                            |
| `CVV_MISMATCH`          | Security code didn't match                            |
| `AVS_MISMATCH`          | Billing address didn't match                          |
| `CONTACT_BANK`          | Declined — contact card issuer                        |
| `DUPLICATE_CARD`        | This card is already on the account                   |
| `PREPAID_REJECTED`      | Prepaid cards aren't accepted                         |
| `FRAUD_FILTER`          | Blocked by a fraud filter                             |
| `BIN_BLOCKED`           | Card's BIN is blocked                                 |
| `EXPANDED_BIN_REQUIRED` | Card requires expanded BIN data Fluz doesn't have yet |
| `KYB_GATE`              | Account isn't yet eligible to add a card              |
| `TRUST_STATUS_FAILED`   | Account isn't eligible to add a card                  |
| `DEVICE_BLOCKED`        | This device isn't eligible to add a card              |
| `MAX_CARDS_REACHED`     | Account has reached its card limit                    |
| `DECLINED_OTHER`        | Catch-all for an unmapped decline reason              |

`onError` is for everything that isn't a normal decline:

| Error code                  | Where it surfaces                               | Meaning                                                                                                                                                                                                                                                                                                                                          |
| :-------------------------- | :---------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INVALID_FRAME_HOST_ORIGIN` | Thrown by `renderFieldsForTokenization`         | `frameHostOrigin` isn't a recognized Fluz frame host                                                                                                                                                                                                                                                                                             |
| `INVALID_STYLE`             | Rejected by `mount()`                           | A `style` value failed validation                                                                                                                                                                                                                                                                                                                |
| `MOUNT_TIMEOUT`             | Rejected by `mount()`                           | The frame didn't complete its handshake within `mountTimeoutMs`                                                                                                                                                                                                                                                                                  |
| `MOUNT_FAILED`              | Rejected by `mount()` / thrown by `submit()`    | The frame failed to load, was already mounted, or `submit()` was called before `mount()` resolved                                                                                                                                                                                                                                                |
| `SUBMIT_FAILED`             | Thrown by `submit()`, or delivered to `onError` | A second `submit()` while one is already in flight; or, via `onError`, the field values failed validation (`VALIDATION_FAILED`), the environment isn't provisioned for collect (`COLLECT_UNAVAILABLE`), Fluz's own backend rejected the request (`FUNDING_SOURCE_UNAUTHORIZED`), or an unexpected processor error (`FUNDING_SOURCE_UNAVAILABLE`) |
| `SUBMIT_TIMEOUT`            | Delivered to `onError`                          | No submit result arrived within `submitTimeoutMs` (default 15 seconds)                                                                                                                                                                                                                                                                           |
| `FIELD_ERROR`               | Delivered to `onError`                          | The underlying card-vault field reported an internal error                                                                                                                                                                                                                                                                                       |
| `RATE_LIMITED`              | Delivered to `onError`                          | Too many submit attempts for this grant                                                                                                                                                                                                                                                                                                          |

## Cleanup

```js theme={null}
inputs.destroy();
```

Removes the frame and detaches all listeners. Call this on unmount, or before minting a fresh token to retry.

## Full example

```html theme={null}
<div id="card-fields"></div>
<input id="cardholder-name" placeholder="Name on card" />
<button id="submit-button">Add card</button>

<script src="https://secure-cdn.fluz.app/secure-elements/v0.1.0/index.global.js"></script>
<script>
  (async () => {
    const { renderFieldsForTokenization } = FluzSecureElements;

    const res = await fetch("/mint-tokenization-token", { method: "POST" });
    const { clientToken, loadToken } = await res.json();

    const inputs = renderFieldsForTokenization({
      clientToken,
      loadToken,
      frameHostOrigin: "https://staging.secure.fluz.app",
      excludedCardBrands: ["amex"],
      style: { fontFamily: "system-ui", fontSize: "16px", color: "#1a1a1a" },
    });

    inputs.onChange((field, state) => console.log(field, state));
    inputs.onDeclined((decline) => alert(decline.message));
    inputs.onError((error) => console.error(error.code, error.message));
    inputs.onSuccess((result) => console.log("card added", result.bankCardId));

    await inputs.mount(document.getElementById("card-fields"));

    const submitButton = document.getElementById("submit-button");
    submitButton.addEventListener("click", async () => {
      submitButton.disabled = true;
      try {
        await inputs.submit({
          cardholderName: document.getElementById("cardholder-name").value,
          billingAddress: { userAddressId: "<existing-address-uuid>" },
        });
      } catch (error) {
        console.error(error.code, error.message);
      } finally {
        submitButton.disabled = false;
      }
    });
  })();
</script>
```

`/mint-tokenization-token` is your own backend route — the one that calls `POST /v1/client-token` with `"purpose": "tokenization"` and your Fluz OAuth access token.

## Next steps

<CardGroup cols={2}>
  <Card title="Secure Elements Overview" icon="book-open" href="/build-a-platform/secure-elements-overview">
    Token minting, SDK loading, and CSP.
  </Card>

  <Card title="Card Reveal" icon="eye" href="/build-a-platform/card-reveal">
    The other Secure Elements capability — showing a user their own card details.
  </Card>

  <Card title="Live demo" icon="play" href="https://demo.secure.fluz.app/collect/">
    Try the card-add form running against staging.
  </Card>

  <Card title="Example integrations" icon="github" href="https://github.com/fluz-app/secure-elements-examples">
    Runnable plain-HTML and React secure-card-input examples with a token-minting server.
  </Card>
</CardGroup>
