@bvnk/card-details-sdk
v0.0.14
Published
Framework-agnostic web SDK for displaying sensitive card details via secure iframe
Readme
@bvnk/card-details-sdk
Framework-agnostic web SDK for displaying sensitive card details (PAN, CVC, expiry, cardholder name) inside a sandboxed, secure iframe.
The host application never touches the sensitive values — the SDK mounts an isolated iframe served
from the card vault, passes a short-lived access token via postMessage, and renders the details
directly to the user.
Table of contents
- Why an iframe?
- Prerequisites
- Installation
- Quick start
- API
- Lifecycle
- Examples
- Configuration
- Security model
- Troubleshooting
- TypeScript
- Demos and related packages
- License
Why an iframe?
- PCI scope reduction — sensitive data is rendered by the vault origin, not your app.
- Origin isolation — the iframe runs in a
sandbox(allow-scripts allow-same-origin) withreferrerpolicy="no-referrer". - No data leakage — the parent window only ever sees lifecycle events, never the card data.
- Clipboard support — the iframe is granted
allow="clipboard-write"so the vault UI can copy values directly without exposing them to the host.
Prerequisites
Access token
The SDK requires a short-lived access token issued by your backend. The host application is responsible for:
- Requesting the token from your backend (typically a POST to an endpoint like
/card/v1/card-details-tokenthat authenticates the end user and returns a JWT). - Passing the token to
mount()viaaccessToken. - Handling rejection — if the vault rejects the token (
TOKEN_REJECTED), request a fresh one and re-mount.
Tokens are typically valid for 300 seconds. Do not cache or reuse them across page loads or SDK mounts. See your BVNK backend integration docs for the exact issuance endpoint.
Content Security Policy
If your host page sets a CSP, allow the vault origin under frame-src (and child-src for older
browsers):
Content-Security-Policy: frame-src https://card-details.bvnk.com https://card-details.staging.bvnk.com https://card-details.sandbox.bvnk.com;Include only the environments you target. Forgetting this is the most common cause of a blank iframe in production.
Browser support
- Evergreen Chrome, Firefox, Edge, Safari (last 2 major versions).
- Requires
postMessage,MessageChannel-style isolation, and iframesandbox. No IE11. - Client-only — the SDK touches
document,window, andiframeAPIs. Do not callmount()during SSR. In Next.js / Remix, mount insideuseEffect(see the React example) or behind a dynamic import withssr: false.
Installation
pnpm add @bvnk/card-details-sdk
# or
npm install @bvnk/card-details-sdk
# or
yarn add @bvnk/card-details-sdkThe package ships dual ESM + CJS bundles (exports.import / exports.require) and TypeScript
declarations.
Quick start
import { mount } from '@bvnk/card-details-sdk'
const container = document.getElementById('card-details')!
const handle = mount({
container,
accessToken: 'eyJhbGciOi...', // short-lived token from your backend
appId: '8ddfb518-32f1-4462-8441-0c662e01d070', // app identifier
onReady: () => console.log('Card details rendered'),
onError: err => console.error('SDK error', err.code, err.message),
onStatusChange: status => console.log('Status:', status),
})
// Later, when you're done:
handle.unmount()The container must already be sized via CSS — the iframe fills 100% of its parent with
display: block. A minimum height of ~575px is recommended to fit the rendered card details without
scrolling.
API
mount(options): CardDetailsHandle
Creates a sandboxed iframe inside options.container, performs the handshake with the vault, and
renders the card details.
Throws TypeError synchronously if container is not an HTMLElement, or if accessToken or
appId is empty.
MountOptions
| Property | Type | Required | Description |
| -------------------- | ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| container | HTMLElement | yes | DOM node that will own the iframe. Size it via CSS — the iframe is width: 100%; height: 100%. |
| accessToken | string | yes | Short-lived token obtained from your backend. Sent to the iframe over postMessage after the handshake. |
| appId | string | yes | App identifier (UUID) generated by our backend when you register your allowed origins via self-service in the Merchant Portal. Appended as an appId query param on the iframe URL so the vault can restrict frame-ancestors and postMessage origins to those registered to your app. |
| env | Environment | no | Selects the vault environment at runtime: 'production' \| 'staging' \| 'sandbox'. Overrides the URL baked in at build time. |
| handshakeTimeoutMs | number | no | How long to wait for the iframe to complete the handshake before failing. Default: 10_000 (10s). |
| iframeTitle | string | no | <iframe title> for accessibility. Default: "Sensitive card details". |
| onReady | () => void | no | Called once when the iframe has fully rendered the card details. |
| onError | (error: SdkError) => void | no | Called whenever the SDK encounters a terminal or recoverable protocol error. See Error codes. |
| onStatusChange | (status: LifecycleStatus) => void | no | Called on every lifecycle transition. Useful for driving UI state. |
CardDetailsHandle
| Method | Signature | Description |
| --------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| unmount | () => void | Removes the iframe, disposes the message channel, clears timers. Idempotent. If called before onReady, fires onError with UNMOUNTED. |
| status | () => LifecycleStatus | Returns the current lifecycle status synchronously. |
LifecycleStatus
The SDK transitions through these states in order. onStatusChange is fired on every transition.
| Status | Meaning |
| ---------------- | --------------------------------------------------------------------------- |
| mounting | mount() was called; iframe element is being created. |
| awaiting-ready | Iframe attached to the DOM; waiting for the HOST_READY handshake message. |
| token-sent | Handshake received; access token has been posted to the iframe. |
| ready | Iframe reported RENDER_COMPLETE — card details are visible to the user. |
| errored | An error occurred. Inspect the SdkError from onError. |
| unmounted | unmount() was called. |
SdkError
interface SdkError {
code: ErrorCode
message: string
}code matches the string values exported by @sensitive-card-details/protocol. You can compare
against the enum or against string literals:
import { ErrorCode } from '@sensitive-card-details/protocol'
onError: err => {
if (err.code === ErrorCode.TokenRejected) {
// or: if (err.code === 'TOKEN_REJECTED')
refreshToken()
}
}Error codes
Terminal errors tear down the iframe. The SDK transitions to errored (or unmounted for
UNMOUNTED) and the DOM node is removed. Recovery requires calling mount() again.
| Code | When it fires |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| HANDSHAKE_TIMEOUT | Iframe did not respond within handshakeTimeoutMs. Network or vault availability issue. |
| IFRAME_LOAD_FAILED | The <iframe> element fired its error event, or contentWindow was unavailable. Usually CSP, network, or DNS issue. |
| UNMOUNTED | unmount() was called before RENDER_COMPLETE. Treat as a cancellation, not a failure. |
Non-terminal errors leave the iframe attached. Status changes to errored but the DOM node
stays in place, so the vault can keep rendering (e.g., its own error UI). The SDK will not
transition back to ready without a re-mount.
| Code | When it fires |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| INVALID_MESSAGE | The vault sent a message that did not match the protocol schema — usually a protocol-version mismatch. |
| TOKEN_REJECTED | The vault rejected the supplied access token (expired, revoked, scope mismatch). Request a fresh token, then unmount() and mount() again. |
| RENDER_FAILED | Vault failed to render the card details (downstream service error). You may show a retry UI on top of the still-attached iframe. |
Lifecycle
stateDiagram-v2
[*] --> mounting: mount()
mounting --> awaiting_ready: iframe created
awaiting_ready --> token_sent: HOST_READY received
token_sent --> ready: RENDER_COMPLETE
awaiting_ready --> errored: HANDSHAKE_TIMEOUT / IFRAME_LOAD_FAILED
token_sent --> errored: TOKEN_REJECTED / RENDER_FAILED / INVALID_MESSAGE
ready --> errored: INVALID_MESSAGE (rare)
mounting --> unmounted: unmount()
awaiting_ready --> unmounted: unmount()
token_sent --> unmounted: unmount()
ready --> unmounted: unmount()
errored --> unmounted: unmount()
unmounted --> [*]unmount() is idempotent and callable from any state. Calling it before ready also fires
onError with UNMOUNTED so cleanup logic can distinguish cancellations from successful
completion.
Examples
React example
A drop-in component that mounts the iframe and surfaces lifecycle/error state. Note that UNMOUNTED
is filtered from onError — it fires during cleanup (e.g., StrictMode double-mount or prop changes)
and is not a failure.
import { useEffect, useRef, useState } from 'react'
import {
mount,
type CardDetailsHandle,
type LifecycleStatus,
type SdkError,
} from '@bvnk/card-details-sdk'
type Props = {
accessToken: string
appId: string
onReady?: () => void
}
export function CardDetails({ accessToken, appId, onReady }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [status, setStatus] = useState<LifecycleStatus>('mounting')
const [error, setError] = useState<SdkError | null>(null)
useEffect(() => {
const container = containerRef.current
if (!container || !accessToken) return
let handle: CardDetailsHandle | null = null
try {
handle = mount({
container,
accessToken,
appId,
iframeTitle: 'Secure card details',
onStatusChange: setStatus,
onReady: () => {
setError(null)
onReady?.()
},
onError: err => {
if (err.code === 'UNMOUNTED') return // cancellation, not a failure
setError(err)
},
})
} catch (err) {
setError({
code: 'MOUNT_FAILED',
message: err instanceof Error ? err.message : 'Failed to mount SDK',
})
}
return () => {
handle?.unmount()
}
}, [accessToken, appId, onReady])
return (
<div>
<div ref={containerRef} style={{ width: '100%', height: 360, border: '1px solid #ddd' }} />
{error ? (
<p role='alert'>
Error <code>{error.code}</code>: {error.message}
</p>
) : (
<p>Status: {status}</p>
)}
</div>
)
}Usage with TanStack Query
Tokens are typically short-lived and fetched on demand:
import { useMutation } from '@tanstack/react-query'
import { CardDetails } from './CardDetails'
export function RevealCard({ cardId }: { cardId: string }) {
const { mutate, data, isPending } = useMutation({
mutationFn: () => fetch(`/card/v1/card-details-token`).then(r => r.json()),
})
if (!data) {
return (
<button onClick={() => mutate()} disabled={isPending}>
{isPending ? 'Requesting…' : 'Reveal card'}
</button>
)
}
return <CardDetails accessToken={data.token} appId='8ddfb518-32f1-4462-8441-0c662e01d070' />
}A more complete example — modal, status indicators, error UI — lives in
apps/demos/host-web/src/components/RevealCardModal.tsx.
Token refresh and re-mounting
The SDK does not refresh tokens internally. To recover from TOKEN_REJECTED or to display the
details a second time, request a new token and re-mount:
onError: async err => {
if (err.code === 'TOKEN_REJECTED') {
handle.unmount()
const fresh = await fetchToken()
handle = mount({ container, accessToken: fresh, ...rest })
}
}In React, changing the accessToken prop in the example above automatically
triggers the cleanup-and-remount cycle via useEffect.
Configuration
The default vault iframe URL is baked into the bundle at build time via the SCD_ENV environment
variable:
| SCD_ENV | Build script | Iframe URL |
| ------------ | -------------------- | ----------------------- |
| (unset) | pnpm build (dev) | http://localhost:3000 |
| staging | pnpm build:staging | Staging vault URL |
| production | pnpm build:prod | Production vault URL |
Consumers normally don't need to configure the URL — it is selected by the dist-tag of the SDK they
install: latest → production, next → staging.
Selecting an environment at runtime
For apps that target multiple vault environments from the same bundle (e.g. a staging build that
also needs to hit sandbox), pass the env option to mount:
mount({
container,
accessToken,
env: 'sandbox', // 'production' | 'staging' | 'sandbox'
})The runtime env overrides the build-time URL. Supported values map to:
| env | Iframe URL |
| ------------ | --------------------------------------- |
| production | https://card-details.bvnk.com |
| staging | https://card-details.staging.bvnk.com |
| sandbox | https://card-details.sandbox.bvnk.com |
Security model
- The iframe is created with
sandbox="allow-scripts",referrerpolicy="no-referrer", andallow="clipboard-write". - All messages are gated by:
event.source === iframe.contentWindow(parent side) /event.source === parent(iframe side).event.origin === expectedOrigin(parent side; derived from the bundled iframe URL).- Protocol type guards from
@sensitive-card-details/protocol.
HOST_READY(posted by the vault, not the SDK) is sent once per origin on the merchant's allow-list rather than to*— the vault resolves that list itself and never broadcasts. It carries no sensitive data regardless.- The access token is posted with an explicit
expectedOrigin, never*, and only once the vault'sHOST_READYhas been received. - The host page never receives card data; only lifecycle and error events cross the boundary.
appId(a UUID generated by our backend when you register your allowed origins via self-service in the Merchant Portal) is appended to the iframe URL as anappIdquery param. The vault uses it to look up the registered origins for your app and restricts itsframe-ancestorsCSP directive andpostMessagetarget origin accordingly, so only your registered origin(s) can embed the vault for that app.
Troubleshooting
| Symptom | Likely cause and fix |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Iframe renders blank or fails to load | CSP missing frame-src for the vault origin. Add it to your Content-Security-Policy header. See CSP section. |
| HANDSHAKE_TIMEOUT after ~10s | Vault URL unreachable from the browser. Verify network, firewall, and that the correct env is selected. Check the browser network tab for the iframe request. |
| IFRAME_LOAD_FAILED immediately | Wrong URL, DNS failure, or the iframe was blocked (X-Frame-Options, CSP). Inspect the iframe src and console errors. |
| TOKEN_REJECTED | Token expired, revoked, or for the wrong card. Request a fresh token from your backend and re-mount. |
| Container has zero height | The iframe is 100%/100%. Set explicit dimensions on the container (e.g., height: 360px). |
| UNMOUNTED error fires on every render | React StrictMode mounts effects twice in development. Filter UNMOUNTED from onError (see React example) — this is expected and harmless. |
| Mounting throws TypeError synchronously | container is not an HTMLElement or accessToken is an empty string. Confirm the container ref is attached before calling mount(). |
| SSR error: document is not defined | The SDK is client-only. Mount inside useEffect, or dynamically import the module with ssr: false. |
TypeScript
All public types are exported from the package root:
import type {
CardDetailsHandle,
Environment,
LifecycleStatus,
MountOptions,
SdkError,
} from '@bvnk/card-details-sdk'The ErrorCode enum is re-exported from the protocol package:
import { ErrorCode } from '@sensitive-card-details/protocol'Demos and related packages
apps/demos/host-web— full React + Vite demo with token mock, modal flow, and error UI.apps/demos/host-ios— iOS host that embeds the vault viaWKWebView.apps/demos/host-android— Android host viaWebView.apps/web/docs/native-webview.md— protocol notes for native consumers.packages/protocol— shared message schema and type guards used by the SDK and the vault.
License
MIT. See LICENSE.
