Skip to main content

Set PIN SDK

Loading SDK demo...

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​

  1. A Highnote account
  2. A server-side integration using an API key
  3. Payment card ID for an ACTIVE payment card
  4. A page served over HTTPS (localhost is allowed for development)

Installation​

The Highnote Set PIN SDK follows semver and can be installed using the following:

  • JavaScript package manager: npm, yarn, or pnpm
  • Content Delivery Network (CDN), imported as an ES module
Upgrading from the Secure Inputs SDK

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
npm i @highnoteplatform/set-pin
yarn
yarn add @highnoteplatform/set-pin
pnpm
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 SDK ships as a module

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.

Module script for Set PIN SDK
<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:

  1. Prepare your HTML.
  2. Fetch a client token.
  3. Initialize Set PIN.
  4. 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:

Prepare HTML for PIN entry
<!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:

Size the PIN iframe
#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 paymentCardId of the payment card whose PIN is being set.
  • Provide permissions with 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"
    ]
  }
}
⚠️ Please login to execute queries. Visit the dashboard to authenticate.
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.

Create Set PIN
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:

OptionRequiredTypeDefaultDescription
getClientTokenxfunction—Returns a promise of a client token with SET_PAYMENT_CARD_PIN for this card. See When the SDK fetches a token.
paymentCardIdxstring—ID of the payment card whose PIN is set. Must match the ID used for the token, and the card must be ACTIVE.
elements.pinxobject—The PIN element. See PIN element properties.
environmentstring"test""test" or "live". Must match the environment the token was generated in. See Using the Live environment.
onMetricfunction—Receives timing and count measurements. See Telemetry.

PIN element properties​

Pass these properties on the pin element:

PropertyRequiredTypeDefaultDescription
selectorxstring—CSS selector for the container element that will hold the PIN iframe.
labelstring"Card PIN"Accessible name of the iframe.
maskedbooleanfalseMask PIN digits like a password field while the cardholder types.
showTogglebooleanfalseRender a toggle inside the field so the cardholder can reveal or hide the PIN. masked must be true.
stylesobject—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:

OutcomeHow you see itWhat to tell the cardholder
Setsubmit() resolvesThe PIN was set. The field clears itself.
Not setsubmit() rejects with outcomeUnknown: falseThe PIN wasn't set. For INVALID_PIN, ask for a different PIN.
May have been setsubmit() rejects with outcomeUnknown: trueDon't tell them it failed: it may have worked. If they try again, they should keep the same PIN.
Submit the 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.

Check outcomeUnknown, not the error class

err.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.

React binding
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(...).

Which props take effect after mount
  • paymentCardId and environment remount the iframe automatically when they change.
  • getClientToken and 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, showToggle and styles are 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.

MetricKindMeaning
total.mounttimingEnd-to-end mount() duration.
iframe.loadtimingTime for the PIN iframe to load.
handshaketimingThe postMessage handshake with the iframe.
configuretimingTime to apply the configuration to the field.
submittimingEnd-to-end submit() duration, including fetching the token, whatever the outcome.
repaint.nudge:<reason>countA repaint prompt was issued for the field.
repaint.nudge.inert:<reason>:<cause>countA repaint prompt was issued but could not be verified as landing.
Allowlist your tag keys

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​

info

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. Wrap mount() in a try/catch.
  • submit() rejections: the submit ended "not set" or "may have been set". See Submit the PIN.
  • The error event: a failure outside any mount() or submit() call, such as the channel to the iframe breaking. Register a setPin.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.

CodeSurfaceDescription
CONTAINER_NOT_FOUNDmount() rejectionThe element referenced by selector could not be found in the DOM.
INSECURE_CONTEXTmount() rejectionThe page isn't served over HTTPS or from localhost.
MOUNT_TIMEOUTmount() rejectionThe field wasn't ready within 15 seconds.
HANDSHAKE_FAILEDmount() rejectionThe iframe failed to complete its initial handshake with the host page.
CONFIGURE_FAILEDmount() rejectionThe configuration could not be delivered to the iframe.
CONFIGURE_ERRORmount() rejectionDefensive-only: the iframe rejected a malformed configuration.
ALREADY_MOUNTEDmount() rejectionmount() was called more than once on the same instance.
MOUNT_FAILEDmount() rejectionUnexpected failure. The React binding also uses it for an error that isn't a SetPinError.
DESTROYEDmount() or submit() rejectiondestroy() was called before or during mount(), or during a submit() before the PIN was sent.
INCOMPLETE_PINsubmit(), not setFewer than 4 digits were entered.
INVALID_PINsubmit(), not setThe Highnote API rejected the PIN. Ask the cardholder for a different PIN.
INVALID_CARD_STATUSsubmit(), not setThe payment card ID is unknown, or the card isn't ACTIVE.
ACCESS_DENIEDsubmit(), not setThe client token isn't for this payment card.
INVALID_CREDENTIALsubmit(), not setThe Highnote API rejected the client token again after one retry with a new token. Raised as an InvalidCredentialError.
SUBMIT_FAILEDsubmit(), not setThe 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_FAILEDsubmit(), not setYour getClientToken function rejected, returned an empty token, or didn't resolve within 15 seconds. The PIN wasn't sent.
NOT_MOUNTEDsubmit(), not setsubmit() was called before mount() resolved, or after destroy(). Raised as a NotMountedError.
SUBMIT_IN_PROGRESSsubmit(), not setA second submit() while one is running. Only the second call failed: the first may still set the PIN. Disable your button while submitting.
NOT_CONFIGUREDsubmit(), not setDefensive-only: the SDK's own checks normally catch this first.
CHANNEL_ERRORsubmit(), not set, or error eventThe postMessage channel between the host page and the iframe failed. On submit(), the PIN wasn't sent.
OUTCOME_UNKNOWNsubmit(), may have been setThe request may have reached the Highnote API, but no answer came back. Raised as an OutcomeUnknownError.
SUBMIT_TIMEOUTsubmit(), may have been setNo 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:

Set PIN Errors
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:

PropertyExamplesDocs
backgroundColor#ffffff, transparentMDN Docs
border1px solid #E2E0D6, noneMDN Docs
borderRadius4px, 0.5remMDN Docs
color#55f5a3, rgba(85,245,163,1), springgreenMDN Docs
cursortext, pointerMDN Docs
fontFamilysans-serif, monospace System fonts onlyMDN Docs
fontSize12px, 1em, 1.1remMDN Docs
fontWeightbold, normalMDN Docs
letterSpacingnormal, .4remMDN Docs
lineHeightnormal, 150%MDN Docs
padding4px 8pxMDN Docs
textAlignleft, centerMDN Docs
userSelectnone, autoMDN 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.

PIN field styles
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" },
},
},
},
});
Unsafe values are dropped

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:

Styling
#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:

Live 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.

Destroying the field
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
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-inputsnpm i @highnoteplatform/set-pin
await renderFields({ ... }) renders immediatelycreateSetPin({ ... }), register listeners with on(...), then await mount()
elements.pin.clientToken stringgetClientToken function at the top level, called at submit
elements.pin.paymentCardIdpaymentCardId at the top level
onSuccess and onError callbacksThe promise submit() returns, plus the error event
Errors told apart by nameSetPinError with a stable code and outcomeUnknown
onInputThe 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.

Two changes are silent

Both of these compile and run without an error, but behave differently from version 1:

  • onSuccess and onError are never called. The Set PIN SDK doesn't take them, and plain JavaScript won't warn you. submit() now returns a promise, so a bare submit(); carried over from version 1 never sees the result. Move that handling onto the promise.
  • "May have been set" is new. Version 1 called onError for dropped connections, API outages and gateway timeouts, when the PIN may already have been set. If you copy your onError handling into a catch unchanged, you keep telling those cardholders their PIN wasn't changed. Branch on err.outcomeUnknown, as in Submit the PIN.

Version 1 errors map to Set PIN error codes as follows:

Version 1 errorSet PIN code
SecureInputsFieldInputError (invalid PIN)INVALID_PIN
SecureInputsRequestError (invalid payment card ID)INVALID_CARD_STATUS
InvalidCredentialErrorINVALID_CREDENTIAL, after one automatic retry with a new token
Any other submit failureSUBMIT_FAILED (not set) or OUTCOME_UNKNOWN (may have been set)
SecureInputsHandshakeErrormount() rejects with HANDSHAKE_FAILED, MOUNT_TIMEOUT or CONFIGURE_FAILED
SecureInputsSubmitErrorsubmit() rejects with NOT_MOUNTED

Version 1 had no React binding. To use one, see React.