Skip to main content

Troubleshooting Web Elements

This guide covers issues specific to v3. For v2, see the v2 Troubleshooting guide.

Need help? Email support@basistheory.com with your package version, browser, framework, and a reproduction.


v3-Specific Gotchas

Initialization is synchronous

v3 init is synchronous — do not await it.

// WRONG — await returns undefined
const bt = await BasisTheory('<PUBLIC_API_KEY>');

// CORRECT
const bt = BasisTheory('<PUBLIC_API_KEY>');

Elements load lazily when first mounted. The bt instance is immediately usable.

CDN global name changed

The CDN global in v3 is BasisTheory (capitalized), not basistheory.

<script>
document.addEventListener('DOMContentLoaded', () => {
const bt = BasisTheory('<PUBLIC_API_KEY>'); // uppercase B and T
});
</script>

Element type names changed

v3 uses different element type strings:

v2v3
'card'Removed — use separate 'cardNumber', 'expiry', 'cvv' elements
'cardExpirationDate''expiry'
'cardVerificationCode''cvv'
'text''text' (unchanged)
// WRONG (v2 types)
bt.createElement('card', { targetId: 'card' });
bt.createElement('cardExpirationDate', { targetId: 'expiry' });

// CORRECT (v3 types)
bt.createElement('cardNumber');
bt.createElement('expiry');
bt.createElement('cvv');

Change event payload changed

The change event in v3 uses event.detail, not direct properties on the event object.

// WRONG (v2 shape)
element.on('change', (event) => {
if (event.complete) { ... }
if (event.error) { ... }
});

// CORRECT (v3 shape)
element.on('change', (event) => {
if (event.detail.isValid) { ... }
if (event.detail.error) { ... }
if (event.detail.isEmpty) { ... }
});

on() returns an unsubscribe function

In v3, element.on() returns () => void — call it to stop listening. It no longer returns a Subscription object.

const unsubscribe = cardNumberEl.on('change', handler);

// Later, to remove the listener:
unsubscribe();

React: Provider API changed

v3 uses apiKey prop directly on BasisTheoryProvider. The useBasisTheory(key, opts) hook pattern from v2 is gone — useBasisTheory() now takes no arguments and must be called inside the provider tree.

// WRONG (v2 pattern)
function PaymentForm() {
const { bt } = useBasisTheory('<PUBLIC_API_KEY>', { legacy: true });
...
}

// CORRECT (v3 pattern)
function App() {
return (
<BasisTheoryProvider apiKey='<PUBLIC_API_KEY>'>
<PaymentForm />
</BasisTheoryProvider>
);
}

function PaymentForm() {
const { bt } = useBasisTheory(); // no arguments
...
}

Common Issues

Elements not appearing after mount

Check these in order:

  1. API key type — must be a Public key with token:create permission. Private and Management keys will fail.

  2. Container exists before mount — the selector must match an element already in the DOM when mount() is called:

    // Mount after the DOM is ready
    document.addEventListener('DOMContentLoaded', async () => {
    const bt = BasisTheory('<PUBLIC_API_KEY>');
    const cardNumberEl = bt.createElement('cardNumber');
    await cardNumberEl.mount('#card-number-element'); // container must exist
    });
  3. Ad blocker or CSP — see Network & Security below.

Tokenization fails immediately without a network request

The SDK validates element state before making any API call. If an element's value is incomplete or invalid when you call bt.tokens.create(), the promise rejects synchronously.

// The change event tells you whether the element is ready
let cardNumberValid = false;
cardNumberEl.on('change', (event) => {
cardNumberValid = event.detail.isValid;
});

// Only call tokens.create when all fields are valid
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!cardNumberValid) return; // guard before calling
const token = await bt.tokens.create({ ... });
});

Element disappears after conditional rendering

If you unmount a container from the DOM and remount it, the element loses its state. Use CSS to show/hide instead:

// WRONG — unmounts the element when showForm is false
{showForm && <div id="card-number-element" />}

// CORRECT — keeps the element mounted, hidden via CSS
<div id="card-number-element" style={{ display: showForm ? 'block' : 'none' }} />

Mount timeout

Elements have a 15-second timeout for mounting. If the iframe doesn't load in time:

  • Check network requests to *.basistheory.com in DevTools — look for blocked or failing requests
  • Disable ad blockers / privacy extensions and retry
  • Verify CSP allows frame-src and script-src for https://*.basistheory.com
  • Ensure DevTools isn't set to block requests or disable cache
try {
await cardNumberEl.mount('#card-number-element');
} catch (error) {
if (error.message.includes('timeout') || error.message.includes('Failed to load')) {
// likely ad blocker or network block
showNetworkErrorMessage();
}
}

Value resolution timeout during tokenization

If bt.tokens.create() hangs and then times out, the secure iframe communication is being interrupted:

  • Most common cause: ad blocker or privacy extension blocking cross-origin postMessage
  • Test in incognito mode to rule out extensions
  • Corporate proxies with TLS inspection can break iframe communication

Network & Security

Required CSP directives

<meta http-equiv="Content-Security-Policy"
content="frame-src https://*.basistheory.com;
script-src https://*.basistheory.com;
connect-src https://*.basistheory.com" />

Optionally, to allow Datadog telemetry (helps Basis Theory diagnose issues):

connect-src https://*.browser-intake-datadoghq.com

Trusted Types

If your app uses Trusted Types, add this policy before calling BasisTheory():

trustedTypes.createPolicy('default', {
createScriptURL: (input) => {
if (new URL(input).origin === 'https://js.basistheory.com') return input;
return undefined;
},
});

Firewall / corporate network

Whitelist these domains:

  • *.basistheory.com
  • *.browser-intake-datadoghq.com (telemetry)

TLS inspection (deep packet inspection) can break iframe cross-origin communication. Work with your network team to exempt *.basistheory.com.

Ad blockers

Ad blockers can block the Elements script or iframe from loading. Signs:

  • Element containers are empty with no console errors
  • Network requests to basistheory.com are missing in DevTools
  • No errors thrown, but elements never appear

Ask users to temporarily disable ad blockers, or show a graceful fallback:

try {
const bt = BasisTheory('<PUBLIC_API_KEY>');
const cardNumberEl = bt.createElement('cardNumber');
await cardNumberEl.mount('#card-number-element');
} catch (error) {
if (error.message.includes('Failed to load') || error.message.includes('timeout')) {
showAdBlockerMessage();
}
}

Server-Side Rendering (SSR)

Elements create secure iframes and rely on window, document, and postMessage — APIs that don't exist in Node.js. The SDK cannot render server-side, but every major SSR framework provides a way to defer client-only code to hydration.

Next.js App Router

Mark the component that uses Elements with 'use client'. Next.js will skip it during SSR and only render it in the browser.

'use client';

import { BasisTheoryProvider, CardNumberElement, ExpiryElement, CVVElement } from '@basis-theory/react-elements';

export function PaymentForm() {
return (
<BasisTheoryProvider apiKey='<PUBLIC_API_KEY>'>
<CardNumberElement />
<ExpiryElement />
<CVVElement />
</BasisTheoryProvider>
);
}

Next.js Pages Router

Use dynamic with ssr: false to exclude the component from the server render entirely.

import dynamic from 'next/dynamic';

const PaymentForm = dynamic(() => import('../components/PaymentForm'), { ssr: false });

export default function CheckoutPage() {
return <PaymentForm />;
}

Remix

Render Elements inside a useEffect or behind a clientOnly check so they only mount in the browser.

import { useState, useEffect } from 'react';
import { BasisTheoryProvider, CardNumberElement } from '@basis-theory/react-elements';

export default function PaymentForm() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);

if (!mounted) return null;

return (
<BasisTheoryProvider apiKey='<PUBLIC_API_KEY>'>
<CardNumberElement />
</BasisTheoryProvider>
);
}

Vue / Nuxt

In Nuxt, wrap Elements in a <ClientOnly> component to prevent SSR. In plain Vue with SSR, use onMounted to initialize after hydration.

<!-- Nuxt: ClientOnly prevents server rendering -->
<template>
<ClientOnly>
<PaymentForm />
</ClientOnly>
</template>
<!-- Vue + Web Elements: initialize in onMounted -->
<script setup>
import { onMounted, ref } from 'vue';

const mounted = ref(false);

onMounted(async () => {
const bt = BasisTheory('<PUBLIC_API_KEY>');
const cardNumberEl = bt.createElement('cardNumber');
await cardNumberEl.mount('#card-number-element');
mounted.value = true;
});
</script>

<template>
<div id="card-number-element" />
</template>

Web Elements (no framework)

The DOMContentLoaded pattern already handles this — the script only runs in the browser.

<script>
document.addEventListener('DOMContentLoaded', () => {
const bt = BasisTheory('<PUBLIC_API_KEY>');
const cardNumberEl = bt.createElement('cardNumber');
cardNumberEl.mount('#card-number-element');
});
</script>

Mobile & WebView

  • iOS WKWebView — ensure cross-origin iframe communication is allowed. The WebView must not restrict postMessage.
  • Android WebView (Cordova) — avoid file:// protocol; host on https://.
  • Aggressive tab suspension — mobile OSes may suspend background tabs and interrupt iframe communication. Re-mount elements when the page becomes visible again using the visibilitychange event.
  • Touch input — elements handle touch natively. Avoid custom touch handlers on element containers.

Debug mode

Pass debug: true when initializing the SDK to enable verbose logging from the secure element iframes.

const bt = BasisTheory('<PUBLIC_API_KEY>', { debug: true });

All logs appear in the browser console with a 🔍 prefix and include a timestamp and the source package:

🔍 [14:23:01.452] [Elements] CMD_SET_CONFIG received { debug: true, ... }
🔍 [14:23:01.471] [Element] Config received { elementType: 'cardNumber', ... }
🔍 [14:23:01.490] [Element] Element ready { elementType: 'cardNumber', elementId: '...' }
🔍 [14:23:04.112] [Element] Input event { isValid: false, isEmpty: false, ... }
🔍 [14:23:06.380] [Element] Input event { isValid: true, isEmpty: false, ... }
🔍 [14:23:07.201] [Elements] GET_VALUE broadcast initiated { elementCount: 3 }

Log prefixes:

  • [Elements] — coordinator iframe (orchestrates tokenization, manages sessions)
  • [Element] — individual input iframe (cardNumber, expiry, cvv, text)

Safe to share with support. Logs are PCI-sanitized — sensitive field values are never logged. Instead, only their length is recorded (e.g. number_length: 16). Structural metadata like element type, event flags, and error codes are safe to include in a support ticket.

Interpreting common log sequences

Normal mount:

[Elements] CMD_SET_CONFIG received     ← SDK sent config to coordinator
[Element] Config received ← element iframe received config
[Element] Element ready ← iframe loaded and interactive

Mount timeout or failure:

[Elements] CMD_SET_CONFIG received     ← coordinator got config
← no [Element] logs follow = iframe blocked

If you see coordinator logs but no [Element] logs, the input iframe failed to load — check for ad blockers, CSP issues, or network blocks on *.basistheory.com.

Tokenization:

[Elements] GET_VALUE broadcast initiated
[Element] Value resolved ← element sent value to coordinator
[Elements] Tokenization request sent

If GET_VALUE broadcast initiated appears but no Value resolved follows, the BroadcastChannel between iframes is being interrupted (typically a corporate proxy with TLS inspection).


Debugging events

function debugElement(element, name) {
element.on('ready', () => console.log(`${name}: ready`));
element.on('change', (e) => console.log(`${name}: change`, e.detail));
element.on('focus', () => console.log(`${name}: focus`));
element.on('blur', () => console.log(`${name}: blur`));
element.on('error', (e) => console.error(`${name}: error`, e.detail));
}

const cardNumberEl = bt.createElement('cardNumber');
debugElement(cardNumberEl, 'cardNumber');
await cardNumberEl.mount('#card-number-element');

Getting Support

Email support@basistheory.com with:

  • Package and version (@basis-theory/web-elements@x.x.x or @basis-theory/react-elements@x.x.x)
  • Install method (npm / CDN)
  • Framework and version (React 18, Next.js 14, etc.)
  • Browser and OS
  • Whether ad blockers or corporate proxies are active
  • Console output and network request logs from DevTools
  • Steps to reproduce