Set PIN SDK
Overview
The Highnote Set PIN SDK lets cardholders securely set or change their card PIN from your UI using an iframe. The PIN is typed into a Highnote-hosted iframe, and the iframe sends it to the Highnote API itself. Your page only learns whether the field is complete and how the submit ended. 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 for an
ACTIVEpayment card - A page served over HTTPS (
localhostis allowed for development)
Installation
The Highnote Set PIN 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
The Set PIN SDK replaces PIN entry in version 1 of the Secure Inputs SDK (@highnoteplatform/secure-inputs). It is a new package with a different API, so an upgrade needs code changes. See Migrate from the Secure Inputs SDK.
Still running the Secure Inputs SDK for PINs? See the Secure Inputs SDK for PINs reference.
Package manager install
The Set PIN SDK supports the following JavaScript package managers: npm, yarn, and pnpm.
npm i @highnoteplatform/set-pin
yarn add @highnoteplatform/set-pin
pnpm add @highnoteplatform/set-pin
CDN install
You can load the Set PIN SDK directly from a content delivery network (CDN) such as JSDelivr.
The Set PIN SDK is published as ES modules and CommonJS only. There is no browser-global (UMD) build. Load it with <script type="module"> and an import; a classic <script src="…"> tag will fail.
<script type="module">
import { createSetPin } from "https://cdn.jsdelivr.net/npm/@highnoteplatform/set-pin@2/+esm";
const setPin = createSetPin({
/* options — see Initialize Set PIN below */
});
await setPin.mount();
</script>
@2 loads the newest 2.x release, so a new release reaches your page without a deploy. In production, pin an exact version, for example set-pin@2.0.0/+esm, and update it deliberately.
Usage
To let a cardholder set their PIN in your UI, set up the PIN field as follows:
- Prepare your HTML.
- Fetch a client token.
- Initialize Set PIN.
- Submit the PIN.
Prepare your HTML
To use the Set PIN SDK, provide an element for the PIN field and a button to submit it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Set Card PIN</title>
</head>
<body>
<p>New PIN</p>
<div id="pin">
<!-- An iframe will be injected here -->
</div>
<button id="submit" disabled>Set PIN</button>
</body>
</html>
The PIN field fills the iframe the SDK injects, and your page sizes that iframe. Without any CSS, browsers draw it at their default 300×150 px:
#pin > iframe {
display: block;
width: 200px;
height: 25px;
}
Fetch a client token
To obtain a client token from your server for the Set PIN SDK, use the generatePaymentCardClientToken mutation.
Refer to the following guidelines when generating a payment card client token:
- Provide the
paymentCardIdof the payment card whose PIN is being set. - Provide
permissionswith the value["SET_PAYMENT_CARD_PIN"]and nothing else. - The client token is only valid for 15 minutes, but can be used multiple times.
- Authenticate the caller on your server, and take the payment card from their session, never from a parameter your page sends.
- Generate a new token each time your page asks for one. Don't store tokens.
- The token lets whoever holds it set this card's PIN until it expires. Only return it to an authenticated cardholder, and rate-limit the endpoint.
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": [ "SET_PAYMENT_CARD_PIN" ] } }
Result
{
"data": {
"generatePaymentCardClientToken": {
"value": "TOKEN",
"expirationDate": "2022-02-07T20:04:50.633Z"
}
},
"extensions": {
"requestId": "<REQUEST_ID>"
}
}
Initialize Set PIN
The Set PIN SDK works by injecting an iframe into your PIN element. You can style the input inside the iframe by passing any combination of styling options.
Initialize the SDK with the createSetPin function. It returns a SetPin instance synchronously. Register your event listeners on it, then call mount() to inject the iframe.
mount() returns a Promise that resolves once the field accepts input. mount() makes no API call and doesn't fetch a client token: the SDK calls getClientToken when the PIN is submitted.
import { createSetPin } from "@highnoteplatform/set-pin";
const setPin = createSetPin({
// Called by the SDK at submit time, and once more if the API rejects the
// token. Return a new client token each time.
getClientToken: async () => {
const response = await fetch("/api/pin-token", { method: "POST" });
// Throw on an error response so the SDK reports TOKEN_FETCH_FAILED,
// rather than sending the error body as the token
if (!response.ok)
throw new Error(`Token request failed: ${response.status}`);
return response.text();
},
// This is the same paymentCardId used to generate the token
paymentCardId: "<PAYMENT_CARD_ID>",
elements: {
pin: {
selector: "#pin",
// Accessible name of the iframe. Default: "Card PIN".
label: "New PIN",
// Mask the PIN digits like a password field. Default: false.
masked: true,
// Render a show/hide toggle inside the field. Only takes effect when
// masked is true. Default: false.
showToggle: true,
},
},
});
const submitButton = document.querySelector("#submit");
let ready = false;
let complete = false;
const updateButton = () => {
submitButton.disabled = !ready || !complete;
};
// Register listeners before calling mount()
setPin.on("ready", () => {
ready = true;
updateButton();
});
// Reports completeness only. The digits never leave the iframe.
setPin.on("change", (state) => {
complete = state.complete;
updateButton();
});
await setPin.mount();
The field is read-only until ready fires, so don't focus it or prompt the cardholder to type before then. The PIN is exactly 4 digits. Until 4 are entered, change reports complete: false.
Set PIN options
Pass these options to createSetPin:
| Option | Required | Type | Default | Description |
|---|---|---|---|---|
getClientToken | x | function | — | Returns a promise of a client token with SET_PAYMENT_CARD_PIN for this card. See When the SDK fetches a token. |
paymentCardId | x | string | — | ID of the payment card whose PIN is set. Must match the ID used for the token, and the card must be ACTIVE. |
elements.pin | x | object | — | The PIN element. See PIN element properties. |
environment | string | "test" | "test" or "live". Must match the environment the token was generated in. See Using the Live environment. | |
onMetric | function | — | Receives timing and count measurements. See Telemetry. |
PIN element properties
Pass these properties on the pin element:
| Property | Required | Type | Default | Description |
|---|---|---|---|---|
selector | x | string | — | CSS selector for the container element that will hold the PIN iframe. |
label | string | "Card PIN" | Accessible name of the iframe. | |
masked | boolean | false | Mask PIN digits like a password field while the cardholder types. | |
showToggle | boolean | false | Render a toggle inside the field so the cardholder can reveal or hide the PIN. masked must be true. | |
styles | object | — | Styles applied inside the iframe. See Styling options. |
When the SDK fetches a token
The SDK calls getClientToken when the PIN is submitted, not at mount. It caches the token until shortly before the token's exp claim. If the Highnote API rejects the token, the SDK calls getClientToken once more, with context.reason set to "retry-after-auth-error", and retries the submit with the new token.
Submit the PIN
Call submit() when the cardholder submits. It returns a promise that ends one of three ways, and your UI must tell them apart:
| Outcome | How you see it | What to tell the cardholder |
|---|---|---|
| Set | submit() resolves | The PIN was set. The field clears itself. |
| Not set | submit() rejects with outcomeUnknown: false | The PIN wasn't set. For INVALID_PIN, ask for a different PIN. |
| May have been set | submit() rejects with outcomeUnknown: true | Don't tell them it failed: it may have worked. If they try again, they should keep the same PIN. |
import { SetPinError } from "@highnoteplatform/set-pin";
// showPinSet / showPinNotSet / showPinMayHaveBeenSet are your own page's functions.
submitButton.addEventListener("click", async () => {
// A second submit() while one is running rejects with SUBMIT_IN_PROGRESS
submitButton.disabled = true;
try {
await setPin.submit();
showPinSet(); // The PIN was set
} catch (err) {
if (err instanceof SetPinError && !err.outcomeUnknown) {
showPinNotSet(err.code); // The PIN was NOT set, for example INVALID_PIN
} else {
showPinMayHaveBeenSet(); // The PIN MAY have been set
}
} finally {
updateButton();
}
});
"May have been set" happens when the request may have reached the Highnote API but no answer came back. Examples: the connection dropped, the API failed internally, a gateway timed out, or no answer came in time (SUBMIT_TIMEOUT: the iframe stops waiting after 32 seconds, and the SDK gives up at 38). The SDK fails closed: an answer it can't classify counts as "may have been set", never as "not set".
To find out what happened, contact Highnote support with the error's context.requestId when it has one.
outcomeUnknown, not the error classerr.outcomeUnknown works even when your bundle ends up with two copies of the SDK. err instanceof OutcomeUnknownError also works, and it also catches SubmitTimeoutError.
React
The SDK ships a React binding at the /react subpath. It is optional, since createSetPin works inside a React app as well, but the component owns mount and teardown for you.
import { SetPin, SetPinError } from "@highnoteplatform/set-pin/react";
function PinForm({ paymentCardId, getClientToken }) {
return (
<SetPin
getClientToken={getClientToken}
paymentCardId={paymentCardId}
masked
showToggle
onError={(err) => console.error(err.code)}
>
{({ pinRef, submit, isReady, isComplete, isSubmitting }) => (
<>
<p id="pinLabel">New PIN</p>
{/* Size this container's iframe, as in Prepare your HTML */}
<div ref={pinRef} id="pin" aria-labelledby="pinLabel" />
<button
disabled={!isReady || !isComplete || isSubmitting}
onClick={() => {
submit().then(onPinSet, (err) =>
err instanceof SetPinError && !err.outcomeUnknown
? onPinNotSet(err)
: onPinMayHaveBeenSet(),
);
}}
>
Set PIN
</button>
</>
)}
</SetPin>
);
}
<SetPin> accepts the same options as createSetPin, except elements: attach pinRef to the container instead, and pass label, masked, showToggle and styles as props. It adds onReady, onChange and onError props in place of the events you would register with setPin.on(...).
paymentCardIdandenvironmentremount the iframe automatically when they change.getClientTokenand the callbacks are held in refs refreshed on every render, so a new function on each render neither remounts nor leaves you with a stale closure.label,masked,showToggleandstylesare read once at mount.
A second submit() while one is in flight returns the same promise. To retry a failed mount, change the component's key. Render one <SetPin> per page.
React 18 and 19 are supported. react and react-dom are optional peer dependencies.
Telemetry
Pass onMetric to receive the SDK's own measurements. Telemetry is opt-in: when you don't supply a handler, the SDK creates no performance marks or measures at all. No metric ever carries the PIN.
The Metric payload is the same discriminated union as the Card Viewer SDK's: branch on type, and read durationMs for "timing" and count for "count". Its tags always carry sdk, sdkVersion and iframeHash.
| Metric | Kind | Meaning |
|---|---|---|
total.mount | timing | End-to-end mount() duration. |
iframe.load | timing | Time for the PIN iframe to load. |
handshake | timing | The postMessage handshake with the iframe. |
configure | timing | Time to apply the configuration to the field. |
submit | timing | End-to-end submit() duration, including fetching the token, whatever the outcome. |
repaint.nudge:<reason> | count | A repaint prompt was issued for the field. |
repaint.nudge.inert:<reason>:<cause> | count | A repaint prompt was issued but could not be verified as landing. |
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.
Error handling
The Set PIN SDK doesn't render error messages or update your UI inside the iframe when errors occur. You must introspect and handle errors accordingly.
Errors surface in three places:
mount()rejections: the field couldn't be rendered. Wrapmount()in atry/catch.submit()rejections: the submit ended "not set" or "may have been set". See Submit the PIN.- The
errorevent: a failure outside anymount()orsubmit()call, such as the channel to the iframe breaking. Register asetPin.on("error", ...)listener.
Every error is a SetPinError, which extends the built-in Error. It carries a stable err.code, an optional err.context, and err.outcomeUnknown.
Error codes
err.code is typed as string, not a closed union: the iframe can report codes of its own. Always include a default branch when switching on it, and treat an unrecognized code by its outcomeUnknown.
| Code | Surface | Description |
|---|---|---|
| CONTAINER_NOT_FOUND | mount() rejection | The element referenced by selector could not be found in the DOM. |
| INSECURE_CONTEXT | mount() rejection | The page isn't served over HTTPS or from localhost. |
| MOUNT_TIMEOUT | mount() rejection | The field wasn't ready within 15 seconds. |
| HANDSHAKE_FAILED | mount() rejection | The iframe failed to complete its initial handshake with the host page. |
| CONFIGURE_FAILED | mount() rejection | The configuration could not be delivered to the iframe. |
| CONFIGURE_ERROR | mount() rejection | Defensive-only: the iframe rejected a malformed configuration. |
| ALREADY_MOUNTED | mount() rejection | mount() was called more than once on the same instance. |
| MOUNT_FAILED | mount() rejection | Unexpected failure. The React binding also uses it for an error that isn't a SetPinError. |
| DESTROYED | mount() or submit() rejection | destroy() was called before or during mount(), or during a submit() before the PIN was sent. |
| INCOMPLETE_PIN | submit(), not set | Fewer than 4 digits were entered. |
| INVALID_PIN | submit(), not set | The Highnote API rejected the PIN. Ask the cardholder for a different PIN. |
| INVALID_CARD_STATUS | submit(), not set | The payment card ID is unknown, or the card isn't ACTIVE. |
| ACCESS_DENIED | submit(), not set | The client token isn't for this payment card. |
| INVALID_CREDENTIAL | submit(), not set | The Highnote API rejected the client token again after one retry with a new token. Raised as an InvalidCredentialError. |
| SUBMIT_FAILED | submit(), not set | The Highnote API refused the request, for example because the token lacks SET_PAYMENT_CARD_PIN. context.userErrorCode carries the API's own code when there is one; use it for logs only. |
| TOKEN_FETCH_FAILED | submit(), not set | Your getClientToken function rejected, returned an empty token, or didn't resolve within 15 seconds. The PIN wasn't sent. |
| NOT_MOUNTED | submit(), not set | submit() was called before mount() resolved, or after destroy(). Raised as a NotMountedError. |
| SUBMIT_IN_PROGRESS | submit(), not set | A second submit() while one is running. Only the second call failed: the first may still set the PIN. Disable your button while submitting. |
| NOT_CONFIGURED | submit(), not set | Defensive-only: the SDK's own checks normally catch this first. |
| CHANNEL_ERROR | submit(), not set, or error event | The postMessage channel between the host page and the iframe failed. On submit(), the PIN wasn't sent. |
| OUTCOME_UNKNOWN | submit(), may have been set | The request may have reached the Highnote API, but no answer came back. Raised as an OutcomeUnknownError. |
| SUBMIT_TIMEOUT | submit(), may have been set | No answer in time. Raised as a SubmitTimeoutError, a subclass of OutcomeUnknownError. |
When the Highnote API returned a request ID, the error carries it as err.context.requestId. Include it when you contact Highnote support. Errors never carry the PIN.
Example errors
The following code sample provides examples of handling Set PIN errors from every surface:
import { SetPinError, createSetPin } from "@highnoteplatform/set-pin";
const setPin = createSetPin({
getClientToken: async () => "client token from server",
paymentCardId: "<PAYMENT_CARD_ID>",
elements: { pin: { selector: "#pin" } },
});
// Failures outside mount() and submit()
setPin.on("error", (err) => console.error(err.code, err.message));
try {
await setPin.mount();
} catch (err) {
// The field couldn't be rendered, for example CONTAINER_NOT_FOUND
console.error(err instanceof SetPinError ? err.code : err);
}
async function submitPin() {
try {
await setPin.submit();
showPinSet();
} catch (err) {
if (!(err instanceof SetPinError) || err.outcomeUnknown) {
// OUTCOME_UNKNOWN, SUBMIT_TIMEOUT, or anything unrecognized
showPinMayHaveBeenSet(err?.context?.requestId);
return;
}
switch (err.code) {
case "INVALID_PIN":
showPinNotSet("Choose a different PIN.");
break;
case "INVALID_CARD_STATUS":
case "ACCESS_DENIED":
case "INVALID_CREDENTIAL":
// Include the request ID when contacting support
console.error(err.code, err.context?.requestId);
showPinNotSet("Your PIN wasn't changed.");
break;
default:
showPinNotSet("Your PIN wasn't changed.");
}
}
}
Styling options
Pass styles on elements.pin.styles. The SDK checks them against an allowlist, and the iframe checks them again. Anything not on the list is dropped. The SDK logs a console.warn for everything it drops itself.
The following styling options are available for the PIN field:
| Property | Examples | Docs |
|---|---|---|
backgroundColor | #ffffff, transparent | MDN Docs |
border | 1px solid #E2E0D6, none | MDN Docs |
borderRadius | 4px, 0.5rem | MDN Docs |
color | #55f5a3, rgba(85,245,163,1), springgreen | MDN Docs |
cursor | text, pointer | MDN Docs |
fontFamily | sans-serif, monospace System fonts only | MDN Docs |
fontSize | 12px, 1em, 1.1rem | MDN Docs |
fontWeight | bold, normal | MDN Docs |
letterSpacing | normal, .4rem | MDN Docs |
lineHeight | normal, 150% | MDN Docs |
padding | 4px 8px | MDN Docs |
textAlign | left, center | MDN Docs |
userSelect | none, auto | MDN Docs |
You can also style the :hover, :focus, :valid and :invalid states by nesting an object under the pseudo-class. :disabled is accepted but never applies: the field is never disabled, only made read-only while a submit is in flight.
const setPin = createSetPin({
// ...config
elements: {
pin: {
selector: "#pin",
styles: {
fontSize: "18px",
letterSpacing: ".4rem",
border: "1px solid #E2E0D6",
":focus": { border: "2px solid #000" },
":invalid": { border: "2px solid #F0314B" },
},
},
},
});
Values containing url(, expression(, -moz-binding, braces, semicolons or comments are dropped. Values containing \ or !, such as !important, are dropped inside the iframe with no warning, so compare the rendered field with your design.
iframe defaults
Highnote injects the PIN iframe with border: none. Its width and height are the browser defaults of 300×150 px until your CSS sizes it, as shown in Prepare your HTML.
Custom fonts
The Set PIN SDK only supports system fonts.
Layout
The PIN input fills the iframe. Size the iframe with a child combinator on your container:
#pin {
margin: 1em;
}
#pin > iframe {
display: block;
width: 200px;
height: 25px;
}
Using the Live environment
By default, the Set PIN SDK makes requests against the Test environment. When you are ready to switch to the Live environment, set the environment option. Generate the client token in the same environment:
const setPin = createSetPin({
getClientToken,
paymentCardId: "<PAYMENT_CARD_ID>",
// Set this to `live`
environment: "live",
elements: { pin: { selector: "#pin" } },
});
Lifecycle
If you need to remove the PIN field, call the destroy() method on the SetPin 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. Calling it during a submit rejects that submit: with DESTROYED if the PIN hadn't been sent yet, and with an OutcomeUnknownError once it had.
import { createSetPin } from "@highnoteplatform/set-pin";
const setPin = createSetPin({
// ...config
});
await setPin.mount();
// Later...destroy
setPin.destroy();
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. The iframe calls the Highnote API itself, so your page's connect-src needs nothing for it:
Content-Security-Policy: frame-src https://cdn.highnote.com
If you load the SDK from a CDN, as in CDN install, your script-src must also allow that CDN's origin (for example https://cdn.jsdelivr.net), and the inline module script needs a nonce or hash. Bundling the SDK from npm avoids both, and keeps the SDK version you ship under your own review.
Migrate from the Secure Inputs SDK
If you set PINs with version 1 of the Secure Inputs SDK (@highnoteplatform/secure-inputs), make the following changes. Keep @highnoteplatform/secure-inputs installed if you also use it for card tokenization: the Set PIN SDK only replaces PIN entry.
Version 1 (renderFields) | Set PIN SDK (createSetPin) |
|---|---|
npm i @highnoteplatform/secure-inputs | npm i @highnoteplatform/set-pin |
await renderFields({ ... }) renders immediately | createSetPin({ ... }), register listeners with on(...), then await mount() |
elements.pin.clientToken string | getClientToken function at the top level, called at submit |
elements.pin.paymentCardId | paymentCardId at the top level |
onSuccess and onError callbacks | The promise submit() returns, plus the error event |
Errors told apart by name | SetPinError with a stable code and outcomeUnknown |
onInput | The change event |
await unmount() | destroy(), synchronous |
<script src=".../lib/index.js"> from a CDN | <script type="module"> with an import |
selector, styles, masked and showToggle stay under elements.pin, and masked and showToggle still default to false. Keep your iframe sizing CSS.
Both of these compile and run without an error, but behave differently from version 1:
onSuccessandonErrorare never called. The Set PIN SDK doesn't take them, and plain JavaScript won't warn you.submit()now returns a promise, so a baresubmit();carried over from version 1 never sees the result. Move that handling onto the promise.- "May have been set" is new. Version 1 called
onErrorfor dropped connections, API outages and gateway timeouts, when the PIN may already have been set. If you copy youronErrorhandling into acatchunchanged, you keep telling those cardholders their PIN wasn't changed. Branch onerr.outcomeUnknown, as in Submit the PIN.
Version 1 errors map to Set PIN error codes as follows:
| Version 1 error | Set PIN code |
|---|---|
SecureInputsFieldInputError (invalid PIN) | INVALID_PIN |
SecureInputsRequestError (invalid payment card ID) | INVALID_CARD_STATUS |
InvalidCredentialError | INVALID_CREDENTIAL, after one automatic retry with a new token |
| Any other submit failure | SUBMIT_FAILED (not set) or OUTCOME_UNKNOWN (may have been set) |
SecureInputsHandshakeError | mount() rejects with HANDSHAKE_FAILED, MOUNT_TIMEOUT or CONFIGURE_FAILED |
SecureInputsSubmitError | submit() rejects with NOT_MOUNTED |
Version 1 had no React binding. To use one, see React.