npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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?

  • 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) with referrerpolicy="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:

  1. Requesting the token from your backend (typically a POST to an endpoint like /card/v1/card-details-token that authenticates the end user and returns a JWT).
  2. Passing the token to mount() via accessToken.
  3. 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 iframe sandbox. No IE11.
  • Client-only — the SDK touches document, window, and iframe APIs. Do not call mount() during SSR. In Next.js / Remix, mount inside useEffect (see the React example) or behind a dynamic import with ssr: false.

Installation

pnpm add @bvnk/card-details-sdk
# or
npm install @bvnk/card-details-sdk
# or
yarn add @bvnk/card-details-sdk

The 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", and allow="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's HOST_READY has 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 an appId query param. The vault uses it to look up the registered origins for your app and restricts its frame-ancestors CSP directive and postMessage target 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


License

MIT. See LICENSE.