Card Viewer SDK (v2 alpha)
You are viewing pre-release documentation for Card Viewer SDK version 2 (alpha) — install with @alpha. Version 1 (1.x) is the current release.
Overview
The Highnote Card Viewer SDK lets you embed sensitive card data into your UI using iframes. This prevents PCI-scoped data from flowing through your servers or from being accessible to scripts running on your page.
Prerequisites
- A Highnote account
- A server-side integration using an API key
- Payment card ID
Installation
The Highnote Card Viewer SDK follows semver and can be installed using the following:
- JavaScript package manager:
npm,yarn, orpnpm - Content Delivery Network (CDN), imported as an ES module
This guide documents the version 2 API (createCardViewer). Version 2 is
currently published under the alpha dist-tag — the latest tag still
points at version 1, whose API differs. Install with the @alpha tag shown
below so the examples on this page resolve.
Running version 1? See the Card Viewer SDK v1 reference.
Package manager install
The Card Viewer SDK supports the following JavaScript package managers: npm, yarn, and pnpm.
npm i @highnoteplatform/card-viewer@alpha
yarn add @highnoteplatform/card-viewer@alpha
pnpm add @highnoteplatform/card-viewer@alpha
CDN install
You can load the Card Viewer SDK directly from a content delivery network (CDN) such as JSDelivr.
Version 2 is published as ES modules and CommonJS only — there is no browser-global (UMD) build, and the v1 lib/index.js path no longer exists. Load it with <script type="module"> and an import; a classic <script src="…"> tag will fail.
<script type="module">
import { createCardViewer } from "https://cdn.jsdelivr.net/npm/@highnoteplatform/card-viewer@alpha/+esm";
const viewer = createCardViewer({
/* options — see Initialize card viewer below */
});
await viewer.mount();
</script>
Usage
To render card details in your UI, you must set up elements to hold each field as follows:
- Prepare your HTML with card detail elements.
- Fetch a client token.
- Initialize card viewer.
Prepare your HTML
To use the Card Viewer SDK, provide an element for each field you want to render in an iframe. The following example uses Card number, CVV, Expiration date.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Payment Card</title>
</head>
<body>
<p>Card Number</p>
<div id="cardNumber">
<!-- An iframe will be injected here -->
</div>
<p>CVV</p>
<div id="cvv">
<!-- An iframe will be injected here -->
</div>
<p>Expiration Date</p>
<div id="expirationDate">
<!-- An iframe will be injected here -->
</div>
</body>
</html>
Fetch a client token
To obtain a client token from your server for the Card Viewer SDK, use the generatePaymentCardClientToken mutation.
Refer to the following guidelines when generating a payment card client token:
- Provide the
paymentCardIdof the payment card you are rendering. - Provide
permissions. It is required — for the Card Viewer SDK the value is["READ_RESTRICTED_DETAILS"]. Omitting it fails schema validation before the request reaches Highnote. - The client token is only valid for 15 minutes, but can be used multiple times.
- Once a token expires, you must generate a new one to render payment card details.
Use the following mutation to generate a payment card client token:
GeneratePaymentCardClientToken
Query
mutation GeneratePaymentCardClientToken(
$input: GeneratePaymentCardClientTokenInput!
) {
generatePaymentCardClientToken(input: $input) {
... on ClientToken {
value
expirationDate
}
}
}
Variables
{ "input": { "paymentCardId": "<PAYMENT_CARD_ID>", "permissions": [ "READ_RESTRICTED_DETAILS" ] } }
Result
{
"data": {
"generatePaymentCardClientToken": {
"value": "TOKEN",
"expirationDate": "2022-02-07T20:04:50.633Z"
}
},
"extensions": {
"requestId": "<REQUEST_ID>"
}
}
Initialize card viewer
The card viewer library works by injecting an iframe into each provided element to render the appropriate data. You can style the content inside each iframe by passing any combination of styling options.
You can initialize the card viewer by using the createCardViewer function. This returns a CardViewer instance synchronously — register your event listeners on it, then call mount() to fetch the client token and inject the iframes.
mount() returns a Promise that resolves once every requested field has rendered. The CardViewer instance is useful for lifecycle management and interactions such as card number masking.
The following example shows how to initialize the card viewer:
import { createCardViewer } from "@highnoteplatform/card-viewer";
const viewer = createCardViewer({
// Called by the SDK at mount time — return a valid client token
getClientToken: async () => "client token from server",
// This is the same paymentCardId used to generate the token
paymentCardId: "<PAYMENT_CARD_ID>",
// Specify the individual fields to render data into
elements: {
cardNumber: {
selector: "#cardNumber",
},
cvv: {
selector: "#cvv",
},
expirationDate: {
selector: "#expirationDate",
},
},
});
// Register listeners before calling mount()
viewer.on("ready", () => {
// Called after data has been fetched and the sdk is ready to display information
// For eg, can be used to show a custom loading indicator
document.querySelector(".loading").classList.add("hidden");
document.querySelector(".cardFields").classList.remove("hidden");
});
viewer.on("error", (error) => {
// Handle field-level errors (these do not reject mount())
});
await viewer.mount();
React
The SDK ships a React binding at the /react subpath. It is optional — createCardViewer works inside a React app as well — but the component owns mount and teardown for you, so you do not manage the lifecycle by hand.
import { CardViewer } from "@highnoteplatform/card-viewer/react";
import { useState } from "react";
function PaymentCard({ getClientToken }) {
const [masked, setMasked] = useState(true);
return (
<CardViewer
getClientToken={getClientToken}
paymentCardId="pc_456"
maskedOnLoad
onReady={() => console.log("ready")}
onError={(err) => console.error(err)}
onClipboardError={({ field, message }) => console.warn(field, message)}
>
{({ cardNumberRef, cvvRef, expirationDateRef, setMask, isReady }) => (
<div>
<p id="cardNumberLabel">Card Number</p>
<div ref={cardNumberRef} aria-labelledby="cardNumberLabel" />
<p id="cvvLabel">CVV</p>
<div ref={cvvRef} aria-labelledby="cvvLabel" />
<p id="expirationDateLabel">Expiration Date</p>
<div ref={expirationDateRef} aria-labelledby="expirationDateLabel" />
<button
disabled={!isReady}
aria-pressed={!masked}
onClick={() => {
setMask("cardNumber", !masked);
setMasked(!masked);
}}
>
{masked ? "Reveal" : "Hide"}
</button>
</div>
)}
</CardViewer>
);
}
Attach only the refs you want rendered — that is how you render a subset of the fields.
Props fall into three groups, and the difference matters — reaching for a remount you do not need re-fetches a client token and re-renders PCI iframes.
paymentCardIdandenvironmentremount the iframes automatically when they change.- The callbacks —
getClientToken,onReady,onError,onClipboardSuccess,onClipboardErrorandonMetric— are held in refs refreshed on every render, so they always fire the latest function you passed. A changing function identity neither remounts nor leaves you with a stale closure. - Everything else —
enableClipboard,maskedOnLoad,styles, and which refs you attach — is read once at mount. Changing one later takes effect on the next remount, so conditionally revealing a field requires a remount rather than a re-render.
onMetric carries one caveat: it is only wired up if it is defined at mount. Going from undefined to a function later will not start delivering metrics, so pass one from the first render if you want them.
Wrap getClientToken in useCallback. The component holds it in a ref, so a changing function identity will not remount the iframes, but it keeps your dependency arrays honest.
<CardViewer> accepts the same options as createCardViewer — environment, enableClipboard, maskedOnLoad and onMetric — and adds five props of its own.
Four of them replace the events you would register with viewer.on(...) in the imperative API: onReady, onError, onClipboardSuccess (receives { field }) and onClipboardError (receives { field, message }). There is no on() to call on the component, so subscribe through these props instead.
The fifth is styles, which takes the same styling options keyed by field.
Telemetry
Pass onMetric to receive the card viewer's own mount-phase measurements. Telemetry is opt-in: when you don't supply a handler, the SDK creates no performance marks or measures at all, so leaving it off costs nothing.
const viewer = createCardViewer({
// ...
onMetric: (metric) => {
switch (metric.type) {
case "timing":
recordDuration(metric.name, metric.durationMs, metric.tags);
break;
case "count":
incrementCounter(metric.name, metric.count, metric.tags);
break;
default:
// New metric kinds are additive — ignore what you don't recognize
// rather than assuming a value field that may not be there.
break;
}
},
});
The Metric payload
Metric is a discriminated union on type. The value field is named per variant rather than shared, so a handler written against durationMs cannot silently receive a count in it.
| Field | Type | Notes |
|---|---|---|
type | "timing" | "count" | The discriminator. Branch on it. |
name | string | Dot-namespaced and stable. |
durationMs | number | type: "timing" only. Milliseconds. |
count | number | type: "count" only. The increment, defaulting to 1. |
tags | Record<string, string> | Low-cardinality dimensions. Always carries sdk, sdkVersion, iframeHash. |
timestamp | number | Epoch milliseconds at emit. |
Metric, MetricType, TimingMetric, and CountMetric are all exported for TypeScript consumers.
What gets emitted
| Metric | Kind | Meaning |
|---|---|---|
total.mount | timing | End-to-end mount() duration. |
iframe.load:<field> | timing | Time for that field's iframe to load. |
handshake:<field> | timing | postMessage handshake with that field. |
token.fetch:<field> | timing | Client-token fetch attributed to that field. |
configure:<field> | timing | Time to apply configuration to that field. |
repaint.nudge:<reason> | count | A repaint prompt was issued for a field. |
repaint.nudge.inert:<reason>:<cause> | count | A repaint prompt was issued but could not be verified as landing. |
The field name is currently baked into the metric name after a colon. If you are building dashboards, split on the first colon and record the field as a separate dimension — that keeps one series per phase instead of one per phase × field.
Do not split the counter names: their <cause> suffix is meaningful on its own.
<cause> | What it means |
|---|---|
no-layout-box | Routine and self-healing. Any field mounted inside a closed accordion or an unopened modal emits it. |
unmeasurable / stale-baseline | Rare and transient. |
unverified | Permanent for that host — the repaint prompt never lands, so the field keeps the blank-until-repainted symptom. This is the one worth alerting on. |
New keys may be added to tags in a minor release. If you forward metrics to a third-party monitoring vendor, copy across the specific keys you want rather than spreading tags wholesale — otherwise a future SDK version can send a dimension to that vendor without anyone having decided it should.
Error handling
The card viewer doesn't render error messages or update your UI inside iframes when errors occur. You must introspect and handle errors accordingly.
Errors surface in two places, and you must handle both:
-
Field-level errors — register a
viewer.on("error", (err) => { ... })listener before callingmount(). This fires for errors scoped to an individual field (a missing container element, a broken postMessage channel, a token fetch failure, a card-data fetch failure, a failed handshake, or a relay failure). A field-level error does not itself rejectmount().when a field-level error does end in a rejectionAll requested fields share a single
getClientToken()call, so a failing token callback fails every field. When no field can signal ready,mount()waits out its internal timeout and then rejects withMOUNT_TIMEOUT— so aTOKEN_FETCH_FAILEDon theerrorevent is typically followed by amount()rejection. Always wrapmount()intry/catchas well as registering the listener. -
Whole-mount failures —
await viewer.mount()rejects when the mount as a whole cannot complete (for example, none of the requested fields could be rendered, or the SDK's internal mount timeout is exceeded). Wrapmount()in atry/catchto handle these.
Every error the SDK raises — from either surface — is a CardViewerError. CardViewerError extends the built-in Error, so err instanceof Error holds, and it carries a stable err.code you can branch on along with the standard err.message.
Error codes
err.code is typed as string, not a closed union — the iframe can surface codes over the protocol, so always include a default branch when switching on it. The codes below are the ones the SDK raises today:
| Code | Surface | Description |
|---|---|---|
| CONTAINER_NOT_FOUND | viewer.on("error") | The element referenced by a field's selector could not be found in the DOM. |
| CHANNEL_ERROR | viewer.on("error") | The postMessage channel between the host page and the iframe encountered an error. |
| TOKEN_FETCH_FAILED | viewer.on("error") | Client-side, before any request: your getClientToken callback rejected, or returned a token that was malformed or already expired. The SDK never obtained a usable token to send. |
| FETCH_ERROR | viewer.on("error") | Server-side, during the card-data request: the iframe sent the request but the Highnote API rejected the token/request or returned no card data. The most common data-retrieval failure. |
| HANDSHAKE_FAILED | viewer.on("error") | The iframe failed to complete its initial handshake with the host page. |
| RELAY_FAILED | viewer.on("error") | A field never received its share of the card data relayed from the source field's fetch. |
| NO_FIELDS_RENDERED | mount() rejection | Thrown as a NoFieldsRenderedError when none of the requested fields could be mounted. |
| MOUNT_TIMEOUT | mount() rejection | Thrown as a MountTimeoutError when the SDK's internal mount timeout is exceeded. |
| ALREADY_MOUNTED | mount() rejection | Thrown as an AlreadyMountedError when mount() is called more than once on the same instance. |
| CONFIGURE_FAILED | viewer.on("error") | A field's CONFIGURE message could not be delivered — for example a reloaded frame or a transferred-port edge case. |
| CONFIGURE_ERROR | viewer.on("error") | Defensive-only: the iframe rejected a malformed or forged CONFIGURE message. Not reachable from the documented API — handle it via your default branch. |
| FIELD_NOT_MOUNTED | viewer.on("error") | An operation targeted a field that was not mounted — reachable from the documented API by calling setMask("cvv", …) when cvv was not included in elements. |
| MOUNT_FAILED | mount() rejection | The mount failed with an unexpected non-CardViewerError throw (also surfaced by the React binding). |
Example errors
The following code sample provides examples of handling card viewer errors from both surfaces:
import {
type CardViewerError,
createCardViewer,
} from "@highnoteplatform/card-viewer";
const handleError = (err: CardViewerError) => {
switch (err.code) {
case "TOKEN_FETCH_FAILED":
// Fetch a new client token and re-initialize
console.error(err.message);
break;
case "FETCH_ERROR":
case "CONTAINER_NOT_FOUND":
case "HANDSHAKE_FAILED":
case "CHANNEL_ERROR":
case "RELAY_FAILED":
console.error(err.message);
break;
default:
console.error(err);
}
};
const viewer = createCardViewer({
getClientToken: async () => "client token from server",
// This is the same paymentCardId used to generate the token
paymentCardId: "<PAYMENT_CARD_ID>",
// Specify the individual fields to render data into
elements: {
cardNumber: {
selector: "#cardNumber",
},
cvv: {
selector: "#cvv",
},
expirationDate: {
selector: "#expirationDate",
},
},
});
// Field-level errors — do not reject mount()
viewer.on("error", handleError);
viewer.on("ready", () => {
// Called after data has been fetched and sdk is ready to display information
});
try {
// Whole-mount failures reject here
await viewer.mount();
} catch (err) {
handleError(err as CardViewerError);
}
Styling options
The following styling options are available for use in elements for your card viewer:
| Property | Examples | Docs |
|---|---|---|
color | #55f5a3, rgba(85,245,163,1), #springgreen | MDN Docs |
cursor | pointer, none | MDN Docs |
fontFamily | sans-serif, serif, monospace System fonts only | MDN Docs |
fontSize | 12px, 1em, 1.1rem | MDN Docs |
fontWeight | bold, normal | MDN Docs |
letterSpacing | normal, .2rem | MDN Docs |
lineHeight | normal, 150% | MDN Docs |
userSelect | none, auto, inherit | MDN Docs |
iframe defaults
Highnote injects card viewer iframes with the following defaults. Your CSS styling can override each of these:
border: nonewidth: 300px(browser default)height: 150px(browser default)
The document and body inside the frame will have transparent backgrounds and default to margin: 0, padding: 0.
Custom fonts
The Card Viewer SDK only supports system fonts.
Layout
The layout of payment card fields in the card viewer is customizable. Highnote injects iframes into the provided container elements, which will inherit the width of the container.
You can customize the width and height of the container to accommodate your UI as needed.
The following code sample provides an example of custom layout styling:
#cardNumber {
margin: 1em;
}
/* You can target the iframe with a child combinator. */
#cardNumber > iframe {
height: 140px;
}
Using the Live environment
By default, the card viewer library will make requests against the Test environment. When you are ready to switch to the Live environment, set the environment configuration option using the following function call:
const viewer = createCardViewer({
getClientToken: async () => "client token from server",
paymentCardId: "<PAYMENT_CARD_ID>",
// Set this to `live`
environment: "live",
elements,
});
viewer.on("error", handleError);
await viewer.mount();
Lifecycle
The Card Viewer SDK has a lifecycle that consists of a loading state, which resolves with payment card details. You can also destroy payment card fields as needed.
Loading state
When rendering card viewer fields, the library writes the required iframes into your UI and renders them in a loading state. While in the loading state, the fields will be populated with placeholder characters:
- Card number:
•••• •••• •••• •••• - CVV:
••• - Expiration Date:
••/••
You can't change these values. Once the configuration has been validated and the fields rendered, the library will request the card details from the Highnote API and replace the loading values with the corresponding data.
Destroy fields
If you need to remove payment card fields, call the destroy() method on the CardViewer instance.
This is useful when you need to "restart" the integration, or navigate to a new view client-side.
Using this will ensure the cleanup of any DOM and event handlers.
destroy() is synchronous and safe to call at any point in the lifecycle — before, during, or after mount().
The following code sample provides an example function to destroy fields:
import { createCardViewer } from "@highnoteplatform/card-viewer";
const viewer = createCardViewer({
// ...config
});
await viewer.mount();
// Later...destroy
viewer.destroy();
Interactions
The Card Viewer SDK supports the following end-user interactions:
- Account holders can toggle sensitive data via card number masking
- Account holders can copy their payment card details to their clipboard to complete payments
Card number masking
By default, the payment card number will be masked (•••• •••• •••• ••••).
Only the last four digits will be shown when the card number is available.
You can mask or reveal a field's value using the setMask method on the CardViewer instance. setMask takes the field name and an explicit boolean — true to mask, false to reveal — so your integration owns the masked/revealed state instead of blindly toggling it. Calling setMask with the current state is a no-op (idempotent). While this example focuses on cardNumber, setMask works for any rendered field.
The following code sample is an example of a card number masking configuration:
import { createCardViewer } from "@highnoteplatform/card-viewer";
const viewer = createCardViewer({
// ...config
});
await viewer.mount();
// Track masked state in your own UI, then call setMask explicitly
let masked = true;
const toggleCardNumberVisibility = () => {
masked = !masked;
viewer.setMask("cardNumber", masked);
};
Copy to clipboard
Users can select and copy payment card detail values to their clipboard.
You can use the enableClipboard configuration property to toggle the "copy to clipboard" feature on or off. enableClipboard defaults to true — pass enableClipboard: false to disable it. Earlier 2.0.0-alpha.* builds defaulted it to false; 2.0.0-alpha.14 restored the version 1 default, so each field iframe carries allow="clipboard-write" unless you opt out.
When enabled, clicking the card number, expiration date, or CVV field will copy the underlying value to the account holder's clipboard. This results in a copy event.
In addition to the copy event, two CardViewer events allow customization when a user copies or fails to copy a value:
| event | description |
|---|---|
clipboard:success | Fires when an account holder successfully copies a value to their clipboard. The handler receives { field }. |
clipboard:error | Fires when the copy fails. The handler receives { field, message }. |
The following code sample is an example of a copy to clipboard configuration:
import { createCardViewer } from "@highnoteplatform/card-viewer";
const viewer = createCardViewer({
getClientToken: async () => "client token from server",
// This is the same paymentCardId used to generate the token
paymentCardId: "<PAYMENT_CARD_ID>",
// Defaults to true — set to false to disable copy to clipboard
enableClipboard: true,
// Specify the individual fields to render data into
elements: {
cardNumber: {
selector: "#cardNumber",
},
cvv: {
selector: "#cvv",
},
expirationDate: {
selector: "#expirationDate",
},
},
});
viewer.on("clipboard:success", ({ field }) => {
console.log(`${field} value copied!`); // cardNumber value copied!
});
viewer.on("clipboard:error", ({ field, message }) => {
console.error(`Failed to copy ${field}: ${message}`);
});
viewer.on("error", (error) => {
// Handle errors
});
viewer.on("ready", () => {
// Called after data has been fetched and sdk is ready to display information
});
await viewer.mount();
Content security policy
If your application enforces a content security policy, you must set the frame-src header to allow iframes from the Highnote domain:
Content-Security-Policy: frame-src https://cdn.highnote.com