Embed card number, expiry, and CVV fields and tokenize in the browser using OzVault.
Card elements let you embed secure card input fields in your own page. Each field runs in a cross-origin iframe — raw card data never exists in your JavaScript or reaches your server.
OzVault.create() is the async static factory — call it once when your checkout loads. It resolves once the wax key is obtained; mount elements immediately after. The vault is ready to tokenize once onReady fires (or vault.isReady === true). Gate your submit button on both vault readiness and field readiness.
import { OzVault, OzError, createFetchWaxKey } from '@ozura/elements';let vault;try { vault = await OzVault.create({ pubKey: 'YOUR_PUB_KEY', fetchWaxKey: createFetchWaxKey('/api/mint-wax'), });} catch (err) { // Pub key blocked, fetchWaxKey failed, or backend unreachable console.error('Vault init failed:', err instanceof OzError ? err.message : err); // Show fallback UI — do not proceed return;}const cardNumber = vault.createElement('cardNumber');const expiry = vault.createElement('expirationDate');const cvv = vault.createElement('cvv');// Mount by CSS selector or HTMLElement referencecardNumber.mount('#card-number');expiry.mount('#expiry');cvv.mount('#cvv');
You can also pass element options at creation time:
Call createToken() after the user has filled all fields. The SDK transmits the field values directly to the Ozura Vault API — raw data never passes through your code.
{ token: string; // vault token cvcSession?: string; // CVC session ID — present on success; pass to cardSale card?: { last4: string; // last 4 digits of card number brand: string; // 'visa' | 'mastercard' | 'amex' | 'discover' | … expMonth: string; // '01'–'12' expYear: string; // '2026', '2027', … }; billing?: BillingDetails; // echoed back (validated + normalized) if you passed billing}
cvcSession is always present on a successful tokenization. Although the TypeScript type is string | undefined, the SDK rejects the result before returning if cvcSession is absent — this would indicate a vault misconfiguration. Always forward both token and cvcSession to your charge endpoint:
The SDK normalizes state to its standard 2-letter abbreviation for US and Canadian addresses. Full names (e.g. "California", "British Columbia") and 2-letter codes are both accepted. For country: 'US', the state must be a valid US state, territory (PR, GU, VI, AS, MP), or military address code (AE, AP, AA) — other values are rejected. For country: 'CA', the state must be a valid Canadian province code.
Each element emits events you can listen to with .on():
cardNumber.on('change', (event) => { // event.empty — boolean: true when the field has no input // event.complete — boolean: field has reached the required length/format (may still be invalid — check `valid`) // event.valid — boolean: value passes full validation (e.g. Luhn check, expiry in future) // event.error — string | undefined: human-readable error message // event.cardBrand — string: 'visa' | 'mastercard' | 'amex' | … (cardNumber only)});expiry.on('change', (event) => { // event.month — string: parsed month '01'–'12' (expirationDate only) // event.year — string: parsed 2-digit year e.g. '27' (expirationDate only)});
The expiry onChange event includes month and year values. These are parsed from the masked expiry input and delivered to your handler for display purposes (e.g. showing the detected expiry date). If your PCI scope requires zero cardholder data on the merchant page, do not read or log these fields.
cardNumber.on('focus', () => { /* field received focus */ });cardNumber.on('blur', (data) => { /* field lost focus; data = { empty, complete, valid, error } */ });cardNumber.on('ready', () => { /* iframe loaded and is interactive */ });cardNumber.on('loaderror', ({ elementType, error }) => { /* iframe failed to load */ });
When the checkout component unmounts, call vault.destroy() to remove all iframes and listeners. In vanilla JS use a cancel flag to handle the async nature of OzVault.create():
let cancelled = false;let vault: OzVault | null = null;OzVault.create({ pubKey: 'YOUR_PUB_KEY', fetchWaxKey: createFetchWaxKey('/api/mint-wax'),}).then(v => { if (cancelled) { v.destroy(); return; } vault = v; // mount elements...});// On unmount:function cleanup() { cancelled = true; vault?.destroy();}
// Focus a field programmaticallycardNumber.focus();// Clear a fieldcardNumber.clear();// Check if the iframe is readyif (cardNumber.isReady) { /* safe to proceed */ }// Get the element typecardNumber.type; // 'cardNumber'// Tear down the iframe and remove listenerscardNumber.destroy();
onReady fires before OzVault.create() resolves. The tokenizer iframe loads concurrently with your fetchWaxKey call, so the onReady callback can fire while await OzVault.create(...) is still pending — meaning vault is undefined inside the callback. Never reference vault inside onReady. Declare your readiness flags before calling create() so they exist when the callback runs. See the example below for the correct pattern.
A complete working example: HTML page with card fields, Express backend with /api/mint-wax and /api/charge routes, and the full tokenize → charge flow.
// server.jsimport express from 'express';import { Ozura, OzuraError, getClientIp, createMintWaxMiddleware } from '@ozura/elements/server';const app = express();app.use(express.json());// Serve the SDK from the same origin for localhost CDN-free loadingapp.get('/oz-elements.esm.js', (_, res) => res.sendFile('node_modules/@ozura/elements/dist/oz-elements.esm.js', { root: '.' }));const ozura = new Ozura({ merchantId: process.env.MERCHANT_ID, apiKey: process.env.MERCHANT_API_KEY, vaultKey: process.env.VAULT_API_KEY,});// Wax key route — called automatically by the frontend SDKapp.post('/api/mint-wax', createMintWaxMiddleware(ozura));// Charge route — called after tokenizationapp.post('/api/charge', async (req, res) => { try { const { token, cvcSession, billing } = req.body; // Always fetch the authoritative amount from your own database const amount = '49.00'; const result = await ozura.cardSale({ token, cvcSession, amount, currency: 'USD', billing, clientIpAddress: getClientIp(req), }); // Store result.transactionId — required for refunds res.json({ success: true, transactionId: result.transactionId }); } catch (err) { const status = err instanceof OzuraError ? (err.statusCode || 500) : 500; const message = err instanceof OzuraError ? err.message : 'Internal server error'; res.status(status).json({ error: message }); }});app.listen(3000, () => console.log('Listening on http://localhost:3000'));
This example serves the SDK locally to avoid CDN CORS issues on localhost. In production, use the CDN URL or your bundler — remove the /oz-elements.esm.js static route. See Installation → CDN for details.