@ozura/elements 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1130 +1,1192 @@
1
- # @ozura/elements
2
-
3
- PCI-isolated card and bank account tokenization SDK for the Ozura Vault.
4
-
5
- Card data is collected inside Ozura-hosted iframes so raw numbers never touch your JavaScript bundle, your server logs, or your network traffic. The tokenizer communicates directly with the vault using `MessageChannel` port transfers — the merchant page acts as a layout host only.
6
-
7
- ---
8
-
9
- ## Table of contents
10
-
11
- - [How it works](#how-it-works)
12
- - [Installation](#installation)
13
- - [Quick start — React](#quick-start--react)
14
- - [Quick start — Vanilla JS](#quick-start--vanilla-js)
15
- - [Server setup](#server-setup)
16
- - [Wax key endpoint](#wax-key-endpoint)
17
- - [Card sale endpoint](#card-sale-endpoint)
18
- - [Vanilla JS API](#vanilla-js-api)
19
- - [OzVault.create()](#ozvaultcreate)
20
- - [vault.createElement()](#vaultcreateelementtypeoptions)
21
- - [vault.createToken()](#vaultcreatetokenoptions)
22
- - [vault.createBankElement()](#vaultcreatebankelement)
23
- - [vault.createBankToken()](#vaultcreatebanktokenoptions)
24
- - [vault.destroy()](#vaultdestroy)
25
- - [OzElement events](#ozelement-events)
26
- - [React API](#react-api)
27
- - [OzElements provider](#ozelements-provider)
28
- - [OzCard](#ozcard)
29
- - [Individual field components](#individual-field-components)
30
- - [OzBankCard](#ozbankcard)
31
- - [useOzElements()](#useozelements)
32
- - [Styling](#styling)
33
- - [Per-element styles](#per-element-styles)
34
- - [Global appearance](#global-appearance)
35
- - [Custom fonts](#custom-fonts)
36
- - [Billing details](#billing-details)
37
- - [Error handling](#error-handling)
38
- - [Server utilities](#server-utilities)
39
- - [Ozura class](#ozura-class)
40
- - [Route handler factories](#route-handler-factories)
41
- - [Local development](#local-development)
42
- - [Content Security Policy](#content-security-policy)
43
- - [TypeScript reference](#typescript-reference)
44
-
45
- ---
46
-
47
- ## How it works
48
-
49
- ```
50
- Merchant page
51
- ├── OzVault (manages tokenizer iframe + element iframes)
52
- ├── [hidden] tokenizer-frame.html ← Ozura origin
53
- ├── [visible] element-frame.html ← card number ─┐ MessageChannel
54
- ├── [visible] element-frame.html ← expiry ├─ port transfer
55
- └── [visible] element-frame.html ← CVV ─┘
56
- ```
57
-
58
- 1. `OzVault.create()` mounts a hidden tokenizer iframe and fetches a short-lived **wax key** from your server.
59
- 2. Calling `vault.createElement()` mounts a visible input iframe for each field.
60
- 3. `vault.createToken()` opens a direct `MessageChannel` between each element iframe and the tokenizer iframe. Raw values travel over those ports — they never pass through your JavaScript.
61
- 4. The tokenizer POSTs directly to the vault API over HTTPS and returns a token to your page.
62
-
63
- Your server only ever sees a token, never card data.
64
-
65
- ---
66
-
67
- ## Credentials
68
-
69
- | Credential | Format | Where it lives | Required for |
70
- |---|---|---|---|
71
- | **Vault pub key** | `pk_live_…` or `pk_prod_…` | Frontend env var (safe to expose) | All integrations |
72
- | **Vault API key** | `key_…` | Server env var — **never in the browser** | Minting wax keys (all integrations) |
73
- | **Pay API key** | `ak_…` | Server env var only | OzuraPay merchants (card charging) |
74
- | **Merchant ID** | `ozu_…` | Server env var only | OzuraPay merchants (card charging) |
75
-
76
- If you are not routing payments through OzuraPay you only need the vault pub key (frontend) and vault API key (backend).
77
-
78
- ---
79
-
80
- ## Installation
81
-
82
- ```bash
83
- npm install @ozura/elements
84
- ```
85
-
86
- React and React DOM are peer dependencies (optional only needed for `@ozura/elements/react`):
87
-
88
- ```bash
89
- npm install react react-dom # if not already installed
90
- ```
91
-
92
- **Requirements:** Node 18, React 17 (React peer).
93
-
94
- ---
95
-
96
- ## Quick start React
97
-
98
- ```tsx
99
- // 1. Wrap your checkout in <OzElements>
100
- import { OzElements, OzCard, useOzElements, createFetchWaxKey } from '@ozura/elements/react';
101
-
102
- function CheckoutPage() {
103
- return (
104
- <OzElements
105
- pubKey="pk_live_..."
106
- fetchWaxKey={createFetchWaxKey('/api/mint-wax')}
107
- >
108
- <CheckoutForm />
109
- </OzElements>
110
- );
111
- }
112
-
113
- // 2. Collect card data and tokenize
114
- function CheckoutForm() {
115
- const { createToken, ready } = useOzElements();
116
-
117
- const handleSubmit = async (e: React.FormEvent) => {
118
- e.preventDefault();
119
- const { token, cvcSession, billing } = await createToken({
120
- billing: {
121
- firstName: 'Jane',
122
- lastName: 'Smith',
123
- email: 'jane@example.com',
124
- address: { line1: '123 Main St', city: 'Austin', state: 'TX', zip: '78701', country: 'US' },
125
- },
126
- });
127
-
128
- // Send token to your server
129
- await fetch('/api/charge', {
130
- method: 'POST',
131
- headers: { 'Content-Type': 'application/json' },
132
- body: JSON.stringify({ token, cvcSession, billing }),
133
- });
134
- };
135
-
136
- return (
137
- <form onSubmit={handleSubmit}>
138
- <OzCard onChange={(state) => console.log(state.cardBrand)} />
139
- <button type="submit" disabled={!ready}>Pay</button>
140
- </form>
141
- );
142
- }
143
- ```
144
-
145
- ---
146
-
147
- ## Quick start — Vanilla JS
148
-
149
- ```ts
150
- import { OzVault, createFetchWaxKey } from '@ozura/elements';
151
-
152
- // Declare state BEFORE OzVault.create(). The onReady callback fires when the
153
- // tokenizer iframe loads — this can happen before create() resolves because the
154
- // iframe loads concurrently with fetchWaxKey. At that moment, `vault` is still
155
- // undefined. Do not reference `vault` inside onReady.
156
- let readyCount = 0;
157
- let tokenizerIsReady = false;
158
-
159
- function checkReady() {
160
- if (readyCount === 3 && tokenizerIsReady) enablePayButton();
161
- }
162
-
163
- const vault = await OzVault.create({
164
- pubKey: 'pk_live_...',
165
- fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
166
- onReady: () => { tokenizerIsReady = true; checkReady(); }, // tokenizer iframe loaded
167
- });
168
-
169
- const cardNumberEl = vault.createElement('cardNumber');
170
- const expiryEl = vault.createElement('expirationDate');
171
- const cvvEl = vault.createElement('cvv');
172
-
173
- cardNumberEl.mount('#card-number');
174
- expiryEl.mount('#expiry');
175
- cvvEl.mount('#cvv');
176
-
177
- [cardNumberEl, expiryEl, cvvEl].forEach(el => {
178
- el.on('ready', () => { readyCount++; checkReady(); });
179
- });
180
-
181
- async function pay() {
182
- const { token, cvcSession, card } = await vault.createToken({
183
- billing: { firstName: 'Jane', lastName: 'Smith' },
184
- });
185
- // POST { token, cvcSession } to your server
186
- }
187
-
188
- // Clean up when done
189
- vault.destroy();
190
- ```
191
-
192
- > **Gating the submit button in vanilla JS** requires checking both `vault.isReady` (tokenizer iframe loaded) **and** every element's `ready` event (input iframes loaded). In React, `useOzElements().ready` combines both automatically.
193
-
194
- ---
195
-
196
- ## Server setup
197
-
198
- ### Wax key endpoint
199
-
200
- The SDK calls your `fetchWaxKey` function with a `tokenizationSessionId` UUID. Your backend must exchange it for a wax key from the vault.
201
-
202
- **Next.js App Router (recommended)**
203
-
204
- ```ts
205
- // app/api/mint-wax/route.ts
206
- import { Ozura, createMintWaxHandler } from '@ozura/elements/server';
207
-
208
- const ozura = new Ozura({
209
- merchantId: process.env.MERCHANT_ID!,
210
- apiKey: process.env.MERCHANT_API_KEY!,
211
- vaultKey: process.env.VAULT_API_KEY!,
212
- });
213
-
214
- export const POST = createMintWaxHandler(ozura);
215
- ```
216
-
217
- **Express**
218
-
219
- ```ts
220
- import express from 'express';
221
- import { Ozura, createMintWaxMiddleware } from '@ozura/elements/server';
222
-
223
- const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
224
- const app = express();
225
-
226
- app.use(express.json());
227
- app.post('/api/mint-wax', createMintWaxMiddleware(ozura));
228
- ```
229
-
230
- **Manual implementation**
231
-
232
- ```ts
233
- // POST /api/mint-wax
234
- const { sessionId } = await req.json();
235
- const { waxKey } = await ozura.mintWaxKey({
236
- tokenizationSessionId: sessionId,
237
- maxTokenizeCalls: 3, // vault enforces this limit; must match VaultOptions.maxTokenizeCalls on the client
238
- });
239
- return Response.json({ waxKey });
240
- ```
241
-
242
- ### Card sale endpoint
243
-
244
- After `createToken()` resolves on the frontend, POST `{ token, cvcSession, billing }` to your server to charge the card.
245
-
246
- **Next.js App Router**
247
-
248
- ```ts
249
- // app/api/charge/route.ts
250
- import { Ozura, createCardSaleHandler } from '@ozura/elements/server';
251
-
252
- const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
253
-
254
- export const POST = createCardSaleHandler(ozura, {
255
- getAmount: async (body) => {
256
- const order = await db.orders.findById(body.orderId as string);
257
- return order.total; // decimal string, e.g. "49.00"
258
- },
259
- // getCurrency: async (body) => 'USD', // optional, defaults to "USD"
260
- });
261
- ```
262
-
263
- **Express**
264
-
265
- ```ts
266
- app.post('/api/charge', createCardSaleMiddleware(ozura, {
267
- getAmount: async (body) => {
268
- const order = await db.orders.findById(body.orderId as string);
269
- return order.total; // decimal string, e.g. "49.00"
270
- },
271
- // getCurrency: async (body) => 'USD', // optional, defaults to "USD"
272
- }));
273
- ```
274
-
275
- > `createCardSaleMiddleware` always terminates the request it does not call `next()` and cannot be composed in a middleware chain.
276
-
277
- **Manual implementation**
278
-
279
- ```ts
280
- const { token, cvcSession, billing } = await req.json();
281
- const result = await ozura.cardSale({
282
- token,
283
- cvcSession,
284
- amount: '49.00',
285
- currency: 'USD',
286
- billing,
287
- // Take the first IP from the forwarded-for chain; fall back to socket address.
288
- // req is a Fetch API Request (Next.js App Router / Vercel Edge).
289
- clientIpAddress: req.headers.get('x-forwarded-for')?.split(',')[0].trim()
290
- ?? req.headers.get('x-real-ip')
291
- ?? '',
292
- });
293
- // result.transactionId, result.amount, result.cardLastFour, result.cardBrand
294
- ```
295
-
296
- ---
297
-
298
- ## Vanilla JS API
299
-
300
- ### OzVault.create(options)
301
-
302
- ```ts
303
- const vault = await OzVault.create(options: VaultOptions): Promise<OzVault>
304
- ```
305
-
306
- Mounts the hidden tokenizer iframe and fetches the wax key concurrently. Both happen in parallel — by the time `create()` resolves, the iframe may already be ready.
307
-
308
- | Option | Type | Required | Description |
309
- |---|---|---|---|
310
- | `pubKey` | `string` | ✓ | Your public key from the Ozura admin. |
311
- | `fetchWaxKey` | `(sessionId: string) => Promise<string>` | ✓ | Called with the session ID; must return a wax key string from your server. Use `createFetchWaxKey('/api/mint-wax')` for the common case. |
312
- | `frameBaseUrl` | `string` | — | Base URL for iframe assets. Defaults to production CDN. Override for local dev (see [Local development](#local-development)). |
313
- | `fonts` | `FontSource[]` | — | Custom fonts to inject into all element iframes. |
314
- | `appearance` | `Appearance` | — | Global theme and variable overrides. |
315
- | `loadTimeoutMs` | `number` | — | Tokenizer iframe load timeout in ms. Default: `10000`. Only takes effect when `onLoadError` is also provided. |
316
- | `onLoadError` | `() => void` | — | Called if the tokenizer iframe fails to load within `loadTimeoutMs`. |
317
- | `onWaxRefresh` | `() => void` | — | Called when the SDK silently re-mints an expired wax key mid-tokenization. |
318
- | `onReady` | `() => void` | — | Called once when the tokenizer iframe has loaded and is ready. Use in vanilla JS to re-check submit-button readiness when the tokenizer becomes ready after all element iframes have already fired. In React, `useOzElements().ready` handles this automatically. |
319
- | `maxTokenizeCalls` | `number` | | Maximum successful `createToken` calls per wax key before the key is considered consumed. Default: `3`. Must match `maxTokenizeCalls` in your server-side `mintWaxKey` call. |
320
-
321
- Throws `OzError` if `fetchWaxKey` rejects, returns an empty string, or returns a non-string value.
322
-
323
- > **`createFetchWaxKey` retry behavior:** The built-in helper enforces a **10-second per-attempt timeout** and retries **once after 750 ms on pure network failures** (connection refused, DNS failure, offline). HTTP 4xx/5xx errors are never retried — they signal endpoint misconfiguration or invalid credentials and require developer action. Errors are thrown as `OzError` instances so you can inspect `err.errorCode` and `err.retryable`.
324
-
325
- > **Re-export identity:** `createFetchWaxKey` is exported from both `@ozura/elements` and `@ozura/elements/react`. They are identical — the same function. Use whichever matches your import context.
326
-
327
- ---
328
-
329
- ### vault.createElement(type, options?)
330
-
331
- ```ts
332
- vault.createElement(type: ElementType, options?: ElementOptions): OzElement
333
- ```
334
-
335
- Creates and returns an element iframe. Call `.mount(target)` to attach it to the DOM.
336
-
337
- `ElementType`: `'cardNumber'` | `'cvv'` | `'expirationDate'`
338
-
339
- ```ts
340
- const cardEl = vault.createElement('cardNumber', {
341
- placeholder: '1234 5678 9012 3456',
342
- style: {
343
- base: { color: '#1a1a1a', fontSize: '16px' },
344
- focus: { borderColor: '#6366f1' },
345
- invalid: { color: '#dc2626' },
346
- complete: { color: '#16a34a' },
347
- },
348
- });
349
-
350
- cardEl.mount('#card-number-container');
351
- ```
352
-
353
- `ElementOptions`:
354
-
355
- | Option | Type | Description |
356
- |---|---|---|
357
- | `style` | `ElementStyleConfig` | Per-state style overrides. See [Styling](#styling). |
358
- | `placeholder` | `string` | Placeholder text (max 100 characters). |
359
- | `disabled` | `boolean` | Disables the input. |
360
- | `loadTimeoutMs` | `number` | Iframe load timeout in ms. Default: `10000`. |
361
-
362
- **OzElement methods:**
363
-
364
- | Method | Description |
365
- |---|---|
366
- | `.mount(target)` | Mount the iframe. Accepts a CSS selector string or `HTMLElement`. |
367
- | `.unmount()` | Remove the iframe from the DOM. The element can be re-mounted. |
368
- | `.destroy()` | Permanently destroy the element. Cannot be re-mounted. |
369
- | `.update(options)` | Update placeholder, style, or disabled state without re-mounting. **Merge semantics** — only provided keys are applied; omitted keys retain their values. To reset a property, pass it with an empty string (e.g. `{ style: { base: { color: '' } } }`). To fully reset all styles, destroy and recreate the element. |
370
- | `.clear()` | Clear the field value. |
371
- | `.focus()` | Programmatically focus the input. |
372
- | `.blur()` | Programmatically blur the input. |
373
- | `.on(event, fn)` | Subscribe to an event. Returns `this` for chaining. |
374
- | `.off(event, fn)` | Remove an event handler. |
375
- | `.once(event, fn)` | Subscribe for a single invocation. |
376
- | `.isReady` | `true` once the iframe has loaded and signalled ready. |
377
-
378
- ---
379
-
380
- ### vault.createToken(options?)
381
-
382
- ```ts
383
- vault.createToken(options?: TokenizeOptions): Promise<TokenResponse>
384
- ```
385
-
386
- Tokenizes all mounted and ready card elements. Raw values travel directly from element iframes to the tokenizer iframe via `MessageChannel` — they never pass through your page's JavaScript.
387
-
388
- Returns a `TokenResponse`:
389
-
390
- ```ts
391
- interface TokenResponse {
392
- token: string; // Vault token pass to cardSale
393
- cvcSession: string; // CVC session always present; pass to cardSale
394
- card: { // Card metadata — always present on success
395
- last4: string; // e.g. "4242"
396
- brand: string; // e.g. "visa"
397
- expMonth: string; // e.g. "09"
398
- expYear: string; // e.g. "2027"
399
- };
400
- billing?: BillingDetails; // Normalized billing only present if billing was passed in
401
- }
402
- ```
403
-
404
- `TokenizeOptions`:
405
-
406
- | Option | Type | Description |
407
- |---|---|---|
408
- | `billing` | `BillingDetails` | Validated and normalized billing details. Returned in `TokenResponse.billing`. |
409
- | `firstName` | `string` | **Deprecated.** Pass inside `billing` instead. |
410
- | `lastName` | `string` | **Deprecated.** Pass inside `billing` instead. |
411
-
412
- Throws `OzError` if:
413
- - The vault is not ready (`errorCode: 'unknown'`)
414
- - A tokenization is already in progress
415
- - Billing validation fails (`errorCode: 'validation'`)
416
- - No elements are mounted
417
- - The vault returns an error (`errorCode` reflects the HTTP status)
418
- - The request times out after 30 seconds (`errorCode: 'timeout'`) — this timeout is separate from `loadTimeoutMs` and is not configurable
419
-
420
- **`vault.tokenizeCount`**
421
-
422
- ```ts
423
- vault.tokenizeCount: number // read-only getter
424
- ```
425
-
426
- Returns the number of successful `createToken()` / `createBankToken()` calls made against the current wax key. Resets to `0` each time the wax key is refreshed (proactively or reactively). Use this in vanilla JS to display "attempts remaining" feedback or gate the submit button:
427
-
428
- ```ts
429
- const MAX = 3; // matches maxTokenizeCalls
430
- const remaining = MAX - vault.tokenizeCount;
431
- payButton.textContent = `Pay (${remaining} attempt${remaining === 1 ? '' : 's'} remaining)`;
432
- ```
433
-
434
- In React, use `tokenizeCount` from `useOzElements()` instead — it is a reactive state value and will trigger re-renders automatically.
435
-
436
- ---
437
-
438
- ### vault.createBankElement()
439
-
440
- ```ts
441
- vault.createBankElement(type: BankElementType, options?: ElementOptions): OzElement
442
- ```
443
-
444
- Creates a bank account element. `BankElementType`: `'accountNumber'` | `'routingNumber'`.
445
-
446
- ```ts
447
- const accountEl = vault.createBankElement('accountNumber');
448
- const routingEl = vault.createBankElement('routingNumber');
449
- accountEl.mount('#account-number');
450
- routingEl.mount('#routing-number');
451
- ```
452
-
453
- ---
454
-
455
- ### vault.createBankToken(options)
456
-
457
- ```ts
458
- vault.createBankToken(options: BankTokenizeOptions): Promise<BankTokenResponse>
459
- ```
460
-
461
- Tokenizes the mounted `accountNumber` and `routingNumber` elements. Both must be mounted and ready.
462
-
463
- ```ts
464
- interface BankTokenizeOptions {
465
- firstName: string; // Account holder first name (required, max 50 chars)
466
- lastName: string; // Account holder last name (required, max 50 chars)
467
- }
468
-
469
- interface BankTokenResponse {
470
- token: string;
471
- bank?: {
472
- last4: string; // Last 4 digits of account number
473
- routingNumberLast4: string;
474
- };
475
- }
476
- ```
477
-
478
- > **Note:** OzuraPay does not currently support bank account payments. Use the bank token with your own ACH processor. Bank tokens can be passed to any ACH-capable processor directly.
479
-
480
- > **Card tokens and processors:** Card tokenization returns a `cvcSession` alongside the `token`. OzuraPay's charge API requires `cvcSession`. If you are routing to a non-Ozura processor, pass the `token` directly — your processor's documentation will tell you whether a CVC session token is required.
481
-
482
- ---
483
-
484
- ### vault.destroy()
485
-
486
- ```ts
487
- vault.destroy(): void
488
- ```
489
-
490
- Tears down the vault: removes all element and tokenizer iframes, clears the `message` event listener, rejects any pending `createToken()` / `createBankToken()` promises, and cancels active timeout handles. Call this when the checkout component unmounts.
491
-
492
- ```ts
493
- // React useEffect cleanup — the cancel flag prevents a vault from leaking
494
- // if the component unmounts before OzVault.create() resolves.
495
- useEffect(() => {
496
- let cancelled = false;
497
- let vault: OzVault | null = null;
498
- OzVault.create(options).then(v => {
499
- if (cancelled) { v.destroy(); return; }
500
- vault = v;
501
- });
502
- return () => {
503
- cancelled = true;
504
- vault?.destroy();
505
- };
506
- }, []);
507
- ```
508
-
509
- ---
510
-
511
- ### OzElement events
512
-
513
- ```ts
514
- element.on('change', (event: ElementChangeEvent) => { ... });
515
- element.on('focus', () => { ... });
516
- element.on('blur', () => { ... });
517
- element.on('ready', () => { ... });
518
- element.on('loaderror', (payload: { elementType: string; error: string }) => { ... });
519
- ```
520
-
521
- `ElementChangeEvent`:
522
-
523
- | Field | Type | Description |
524
- |---|---|---|
525
- | `empty` | `boolean` | `true` when the field is empty. |
526
- | `complete` | `boolean` | `true` when the field has enough digits to be complete. |
527
- | `valid` | `boolean` | `true` when the value passes all validation (Luhn, expiry date, etc.). |
528
- | `error` | `string \| undefined` | User-facing error message when `valid` is `false` and the field has been touched. |
529
- | `cardBrand` | `string \| undefined` | Detected brand — only on `cardNumber` fields (e.g. `"visa"`, `"amex"`). |
530
- | `month` | `string \| undefined` | Parsed 2-digit month — only on `expirationDate` fields. |
531
- | `year` | `string \| undefined` | Parsed 2-digit year — only on `expirationDate` fields. |
532
-
533
- > **Expiry data and PCI scope:** The expiry `onChange` event delivers parsed `month` and `year` to your handler for display purposes (e.g. "Card expires MM/YY"). These are not PANs or CVVs, but they are cardholder data. If your PCI scope requires zero cardholder data on the merchant page, do not read, log, or store these fields.
534
-
535
- Auto-advance is built in: the vault automatically moves focus from card number → expiry → CVV when each field completes. No additional code required.
536
-
537
- ---
538
-
539
- ## React API
540
-
541
- ### OzElements provider
542
-
543
- ```tsx
544
- import { OzElements, createFetchWaxKey } from '@ozura/elements/react';
545
-
546
- <OzElements
547
- pubKey="pk_live_..."
548
- fetchWaxKey={createFetchWaxKey('/api/mint-wax')}
549
- appearance={{ theme: 'flat', variables: { colorPrimary: '#6366f1' } }}
550
- onLoadError={() => setPaymentUnavailable(true)}
551
- >
552
- {children}
553
- </OzElements>
554
- ```
555
-
556
- All `VaultOptions` are accepted as props. The provider creates a single `OzVault` instance and destroys it on unmount.
557
-
558
- > **Prop changes and vault lifecycle:** Changing `pubKey`, `frameBaseUrl`, `loadTimeoutMs`, `appearance`, `fonts`, or `maxTokenizeCalls` destroys the current vault and creates a new one — all field iframes will remount. Changing `fetchWaxKey`, `onLoadError`, `onWaxRefresh`, or `onReady` updates the callback in place via refs without recreating the vault.
559
-
560
- > **One card form per provider:** A vault holds one element per field type (`cardNumber`, `expiry`, `cvv`, etc.). Rendering two `<OzCard>` components under the same `<OzElements>` provider will cause the second to silently replace the first's iframes, breaking the first form. If you genuinely need two independent card forms on the same page, wrap each in its own `<OzElements>` provider with separate `pubKey` / `fetchWaxKey` configurations.
561
-
562
- ---
563
-
564
- ### OzCard
565
-
566
- Drop-in combined card component. Renders card number, expiry, and CVV with a configurable layout.
567
-
568
- ```tsx
569
- import { OzCard } from '@ozura/elements/react';
570
-
571
- <OzCard
572
- layout="default" // "default" (number on top, expiry+CVV below) | "rows" (stacked)
573
- onChange={(state) => {
574
- // state.complete all three fields complete + valid
575
- // state.cardBrand — detected brand
576
- // state.error — first error across all fields
577
- // state.fields — per-field ElementChangeEvent objects
578
- }}
579
- onReady={() => console.log('all card fields loaded')}
580
- disabled={isSubmitting}
581
- labels={{ cardNumber: 'Card Number', expiry: 'Expiry', cvv: 'CVV' }}
582
- placeholders={{ cardNumber: '1234 5678 9012 3456', expiry: 'MM/YY', cvv: '···' }}
583
- />
584
- ```
585
-
586
- `OzCardProps` (full):
587
-
588
- | Prop | Type | Description |
589
- |---|---|---|
590
- | `layout` | `'default' \| 'rows'` | `'default'`: number full-width, expiry+CVV side by side. `'rows'`: all stacked. |
591
- | `gap` | `number \| string` | Gap between fields. Default: `8` (px). |
592
- | `style` | `ElementStyleConfig` | Shared style applied to all three inputs. |
593
- | `styles` | `{ cardNumber?, expiry?, cvv? }` | Per-field overrides merged on top of `style`. |
594
- | `classNames` | `{ cardNumber?, expiry?, cvv?, row? }` | CSS class names for field wrappers and the expiry+CVV row. |
595
- | `labels` | `{ cardNumber?, expiry?, cvv? }` | Optional label text above each field. |
596
- | `labelStyle` | `React.CSSProperties` | Style applied to all `<label>` elements. |
597
- | `labelClassName` | `string` | Class applied to all `<label>` elements. |
598
- | `placeholders` | `{ cardNumber?, expiry?, cvv? }` | Custom placeholder text per field. |
599
- | `hideErrors` | `boolean` | Suppress the built-in error display. Handle via `onChange`. |
600
- | `errorStyle` | `React.CSSProperties` | Style for the built-in error container. |
601
- | `errorClassName` | `string` | Class for the built-in error container. |
602
- | `renderError` | `(error: string) => ReactNode` | Custom error renderer. |
603
- | `onChange` | `(state: OzCardState) => void` | Fires on any field change. |
604
- | `onReady` | `() => void` | Fires once all three iframes have loaded. |
605
- | `onFocus` | `(field: 'cardNumber' \| 'expiry' \| 'cvv') => void` | |
606
- | `onBlur` | `(field: 'cardNumber' \| 'expiry' \| 'cvv') => void` | |
607
- | `disabled` | `boolean` | Disable all inputs. |
608
- | `className` | `string` | Class for the outer wrapper. |
609
-
610
- ---
611
-
612
- ### Individual field components
613
-
614
- For custom layouts where `OzCard` is too opinionated:
615
-
616
- ```tsx
617
- import { OzCardNumber, OzExpiry, OzCvv } from '@ozura/elements/react';
618
-
619
- <OzCardNumber onChange={handleChange} placeholder="Card number" />
620
- <OzExpiry onChange={handleChange} />
621
- <OzCvv onChange={handleChange} />
622
- ```
623
-
624
- All accept `OzFieldProps`:
625
-
626
- | Prop | Type | Description |
627
- |---|---|---|
628
- | `style` | `ElementStyleConfig` | Input styles. |
629
- | `placeholder` | `string` | Placeholder text. |
630
- | `disabled` | `boolean` | Disables the input. |
631
- | `loadTimeoutMs` | `number` | Iframe load timeout in ms. |
632
- | `onChange` | `(event: ElementChangeEvent) => void` | |
633
- | `onFocus` | `() => void` | |
634
- | `onBlur` | `() => void` | |
635
- | `onReady` | `() => void` | |
636
- | `onLoadError` | `(error: string) => void` | |
637
- | `className` | `string` | Class for the outer wrapper div. |
638
-
639
- ---
640
-
641
- ### OzBankCard
642
-
643
- ```tsx
644
- import { OzBankCard } from '@ozura/elements/react';
645
-
646
- <OzBankCard
647
- onChange={(state) => {
648
- // state.complete, state.error, state.fields.accountNumber, state.fields.routingNumber
649
- }}
650
- labels={{ accountNumber: 'Account Number', routingNumber: 'Routing Number' }}
651
- />
652
- ```
653
-
654
- Or use individual bank components:
655
-
656
- ```tsx
657
- import { OzBankAccountNumber, OzBankRoutingNumber } from '@ozura/elements/react';
658
-
659
- <OzBankAccountNumber onChange={handleChange} />
660
- <OzBankRoutingNumber onChange={handleChange} />
661
- ```
662
-
663
- ---
664
-
665
- ### useOzElements()
666
-
667
- ```ts
668
- const { createToken, createBankToken, ready, initError, tokenizeCount } = useOzElements();
669
- ```
670
-
671
- Must be called from inside an `<OzElements>` provider tree.
672
-
673
- | Return | Type | Description |
674
- |---|---|---|
675
- | `createToken` | `(options?: TokenizeOptions) => Promise<TokenResponse>` | Tokenize mounted card elements. |
676
- | `createBankToken` | `(options: BankTokenizeOptions) => Promise<BankTokenResponse>` | Tokenize mounted bank elements. |
677
- | `ready` | `boolean` | `true` when the tokenizer **and** all mounted element iframes are ready. Gate your submit button on this. See note below. |
678
- | `initError` | `Error \| null` | Non-null if `OzVault.create()` failed (e.g. `fetchWaxKey` threw). Render a fallback UI. |
679
- | `tokenizeCount` | `number` | Number of successful tokenizations since the last wax key was minted. Resets on wax refresh or provider re-init. Useful for tracking calls against `maxTokenizeCalls`. |
680
-
681
- > **`ready` vs `vault.isReady`:** `ready` from `useOzElements()` is a composite — it combines `vault.isReady` (tokenizer loaded) with element readiness (all mounted input iframes loaded). `vault.isReady` alone is insufficient for gating a submit button. Always use `ready` from `useOzElements()` in React.
682
-
683
- ---
684
-
685
- ## Styling
686
-
687
- ### Per-element styles
688
-
689
- Styles apply inside each iframe's `<input>` element. Only an explicit allowlist of CSS properties is accepted (typography, spacing, borders, box-shadow, cursor, transitions). Values containing `url()`, `var()`, `expression()`, `javascript:`, or CSS breakout characters are blocked on both the SDK and iframe sides. Use literal values instead of CSS custom properties (`var(--token)` is rejected). When a value is stripped, the browser falls back to the element's default (unstyled) appearance for that property — the failure is silent with no error or console warning. Resolve design tokens to literal values before passing them in.
690
-
691
- ```ts
692
- const style: ElementStyleConfig = {
693
- base: {
694
- color: '#1a1a1a',
695
- fontSize: '16px',
696
- fontFamily: '"Inter", sans-serif',
697
- padding: '10px 12px',
698
- backgroundColor: '#ffffff',
699
- borderRadius: '6px',
700
- border: '1px solid #d1d5db',
701
- },
702
- focus: {
703
- borderColor: '#6366f1',
704
- boxShadow: '0 0 0 3px rgba(99,102,241,0.15)',
705
- outline: 'none',
706
- },
707
- invalid: {
708
- borderColor: '#ef4444',
709
- color: '#dc2626',
710
- },
711
- complete: {
712
- borderColor: '#22c55e',
713
- },
714
- placeholder: {
715
- color: '#9ca3af',
716
- },
717
- };
718
- ```
719
-
720
- State precedence: `placeholder` applies to the `::placeholder` pseudo-element. `focus`, `invalid`, and `complete` merge on top of `base`.
721
-
722
- ### Global appearance
723
-
724
- Apply a preset theme and/or variable overrides to all elements at once:
725
-
726
- ```ts
727
- // OzVault.create
728
- const vault = await OzVault.create({
729
- pubKey: '...',
730
- fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
731
- appearance: {
732
- theme: 'flat', // 'default' | 'night' | 'flat'
733
- variables: {
734
- colorText: '#1a1a1a',
735
- colorBackground: '#ffffff',
736
- colorPrimary: '#6366f1', // focus caret + color
737
- colorDanger: '#dc2626', // invalid state
738
- colorSuccess: '#16a34a', // complete state
739
- colorPlaceholder: '#9ca3af',
740
- fontFamily: '"Inter", sans-serif',
741
- fontSize: '15px',
742
- fontWeight: '400',
743
- padding: '10px 14px',
744
- letterSpacing: '0.01em',
745
- },
746
- },
747
- });
748
-
749
- // React provider
750
- <OzElements pubKey="..." fetchWaxKey={...} appearance={{ theme: 'night' }}>
751
- ```
752
-
753
- Per-element `style` takes precedence over `appearance` variables.
754
-
755
- > **`appearance: {}` is not "no theme":** Passing `appearance: {}` or `appearance: { variables: { ... } }` without a `theme` key applies the `'default'` preset as a base. To render elements with no preset styling, omit `appearance` entirely and use per-element `style` overrides.
756
- >
757
- > | `appearance` value | Result |
758
- > |---|---|
759
- > | *(omitted entirely)* | No preset — element uses minimal built-in defaults |
760
- > | `{}` | Equivalent to `{ theme: 'default' }` — full default theme applied |
761
- > | `{ theme: 'night' }` | Night theme |
762
- > | `{ variables: { colorText: '#333' } }` | Default theme + variable overrides |
763
-
764
- ### Custom fonts
765
-
766
- Fonts are injected into each iframe so they render inside the input fields:
767
-
768
- ```ts
769
- fonts: [
770
- // Google Fonts or any HTTPS CSS URL
771
- { cssSrc: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap' },
772
-
773
- // Custom @font-face
774
- {
775
- family: 'BrandFont',
776
- src: 'url(https://cdn.example.com/brand-font.woff2)',
777
- weight: '400',
778
- style: 'normal',
779
- display: 'swap',
780
- },
781
- ]
782
- ```
783
-
784
- Font `src` values must start with `url(https://...)`. HTTP and data URIs are rejected.
785
-
786
- ---
787
-
788
- ## Billing details
789
-
790
- ```ts
791
- interface BillingDetails {
792
- firstName: string; // 1–50 characters
793
- lastName: string; // 1–50 characters
794
- email?: string; // Valid email, max 50 characters
795
- phone?: string; // E.164 format, e.g. "+15551234567", max 50 characters
796
- address?: {
797
- line1: string; // 1–50 characters
798
- line2?: string; // Optional, omitted from cardSale if blank
799
- city: string; // 1–50 characters
800
- state: string; // For US/CA: normalized to 2-letter abbreviation
801
- zip: string; // 1–50 characters
802
- country: string; // ISO 3166-1 alpha-2, e.g. "US"
803
- };
804
- }
805
- ```
806
-
807
- Billing is validated and normalized by both `vault.createToken()` and the server-side handler factories. `TokenResponse.billing` contains the normalized result ready to spread into a `cardSale` call.
808
-
809
- ---
810
-
811
- ## Error handling
812
-
813
- All SDK errors are instances of `OzError`:
814
-
815
- ```ts
816
- import { OzError } from '@ozura/elements';
817
-
818
- try {
819
- const { token } = await vault.createToken({ billing });
820
- } catch (err) {
821
- if (err instanceof OzError) {
822
- switch (err.errorCode) {
823
- case 'network': // Connection failure — show retry UI
824
- case 'timeout': // 30s deadline exceeded — safe to retry
825
- case 'server': // 5xx from vault — transient, safe to retry
826
- if (err.retryable) showRetryPrompt();
827
- break;
828
-
829
- case 'auth': // Bad pub key / API key — configuration issue
830
- case 'validation': // Bad card data — show field-level error
831
- case 'config': // frameBaseUrl not in permitted allowlist
832
- case 'unknown':
833
- showError(err.message);
834
- break;
835
- }
836
- }
837
- }
838
- ```
839
-
840
- `OzError` fields:
841
-
842
- | Field | Type | Description |
843
- |---|---|---|
844
- | `message` | `string` | Human-readable, consumer-facing error message. |
845
- | `errorCode` | `OzErrorCode` | `'network' \| 'timeout' \| 'auth' \| 'validation' \| 'server' \| 'config' \| 'unknown'` |
846
- | `raw` | `string` | Raw error string from the vault API, if available. |
847
- | `retryable` | `boolean` | `true` for `network`, `timeout`, `server`. `false` for `auth`, `validation`, `config`, `unknown`. |
848
-
849
- > **Wax key expiry is handled automatically.** When a wax key expires or is consumed between initialization and the user clicking Pay, the SDK silently re-mints a fresh key and retries the tokenization once. You will only receive an `auth` error if the re-mint itself fails — for example, if your `/api/mint-wax` backend endpoint is unreachable. A healthy `auth` error in production means your mint endpoint needs attention, not that the user's card is bad.
850
-
851
- **Error normalisation helpers** (for displaying errors from `cardSale` to users):
852
-
853
- ```ts
854
- import { normalizeVaultError, normalizeBankVaultError, normalizeCardSaleError } from '@ozura/elements';
855
-
856
- // Maps vault tokenize error strings to user-facing copy
857
- const display = normalizeVaultError(err.raw); // card flows
858
- const display = normalizeBankVaultError(err.raw); // bank/ACH flows
859
- const display = normalizeCardSaleError(err.message); // cardSale API errors
860
- ```
861
-
862
- ---
863
-
864
- ## Server utilities
865
-
866
- ### Ozura class
867
-
868
- ```ts
869
- import { Ozura, OzuraError } from '@ozura/elements/server';
870
-
871
- const ozura = new Ozura({
872
- merchantId: process.env.MERCHANT_ID!,
873
- apiKey: process.env.MERCHANT_API_KEY!,
874
- vaultKey: process.env.VAULT_API_KEY!,
875
- // apiUrl: 'https://api.ozura.com', // override Pay API URL
876
- // vaultUrl: 'https://vault.ozura.com', // override vault URL
877
- timeoutMs: 30000, // default
878
- retries: 2, // max retry attempts for 5xx/network errors (3 total attempts)
879
- });
880
- ```
881
-
882
- > **Tokenize-only integrations** (mint wax keys + tokenize cards, no charging) only need `vaultKey`. The `merchantId` and `apiKey` fields are optional — they are validated lazily and only required when `cardSale()` is called.
883
- >
884
- > ```ts
885
- > const ozura = new Ozura({ vaultKey: process.env.VAULT_API_KEY! });
886
- > ```
887
-
888
- **Methods:**
889
-
890
- ```ts
891
- // Charge a tokenized card
892
- const result = await ozura.cardSale({
893
- token: tokenResponse.token,
894
- cvcSession: tokenResponse.cvcSession,
895
- amount: '49.00',
896
- currency: 'USD', // default: 'USD'
897
- billing: tokenResponse.billing,
898
- clientIpAddress: '1.2.3.4', // fetch server-side, never from the browser
899
- // surchargePercent, tipAmount, salesTaxExempt, processor
900
- });
901
- // result.transactionId, result.amount, result.cardLastFour, result.cardBrand
902
- // result.surchargeAmount and result.tipAmount are optional — only present when non-zero
903
- const surcharge = result.surchargeAmount ?? '0.00';
904
- const tip = result.tipAmount ?? '0.00';
905
-
906
- // Mint a wax key (for custom fetchWaxKey implementations)
907
- const { waxKey, expiresInSeconds } = await ozura.mintWaxKey({
908
- tokenizationSessionId: sessionId,
909
- maxTokenizeCalls: 3, // must match VaultOptions.maxTokenizeCalls on the client (default: 3)
910
- });
911
-
912
- // Revoke a wax key — call on all three session-end paths
913
- // Best-effort — never throws. Shortens the exposure window before the vault's ~30 min TTL.
914
- await ozura.revokeWaxKey(waxKey);
915
-
916
- // Suggested pattern wire all three exit paths:
917
- // 1. Payment success
918
- const result = await ozura.cardSale({ ... });
919
- await ozura.revokeWaxKey(waxKey); // key is spent; close the window immediately
920
-
921
- // 2. User cancels checkout
922
- router.post('/api/cancel', async (req) => {
923
- const { waxKey } = await db.session.get(req.sessionId);
924
- await ozura.revokeWaxKey(waxKey);
925
- return Response.json({ ok: true });
926
- });
927
-
928
- // 3. Page/tab close (best-effort — browser may not deliver this)
929
- // Use sendBeacon so the request survives navigation / tab close.
930
- window.addEventListener('visibilitychange', () => {
931
- if (document.visibilityState === 'hidden') {
932
- navigator.sendBeacon('/api/cancel', JSON.stringify({ sessionId }));
933
- }
934
- });
935
-
936
- // List transactions
937
- const { transactions, pagination } = await ozura.listTransactions({
938
- dateFrom: '2025-01-01',
939
- dateTo: '2025-12-31',
940
- transactionType: 'CreditCardSale',
941
- page: 1,
942
- limit: 50,
943
- });
944
- ```
945
-
946
- **`OzuraError`** (thrown by all `Ozura` methods):
947
-
948
- ```ts
949
- try {
950
- await ozura.cardSale(input);
951
- } catch (err) {
952
- if (err instanceof OzuraError) {
953
- err.statusCode; // HTTP status code
954
- err.message; // Normalized message
955
- err.raw; // Raw API response string
956
- err.retryAfter; // Seconds (only present on 429)
957
- }
958
- }
959
- ```
960
-
961
- Rate limits: `cardSale` — 100 req/min per merchant. `listTransactions` — 200 req/min per merchant.
962
-
963
- > **Retry behavior:** `mintWaxKey` and `listTransactions` retry on 5xx and network errors using exponential backoff (1 s / 2 s / 4 s…) up to `retries + 1` total attempts. **`cardSale` is never retried** — it is a non-idempotent financial operation and the result of a duplicate charge cannot be predicted.
964
-
965
- ---
966
-
967
- ### Route handler factories
968
-
969
- The server package exports four factory functions covering two runtimes × two endpoints:
970
-
971
- | Function | Runtime | Endpoint |
972
- |---|---|---|
973
- | `createMintWaxHandler` | Fetch API (Next.js App Router, Cloudflare, Vercel Edge) | `POST /api/mint-wax` |
974
- | `createMintWaxMiddleware` | Express / Connect | `POST /api/mint-wax` |
975
- | `createCardSaleHandler` | Fetch API | `POST /api/charge` |
976
- | `createCardSaleMiddleware` | Express / Connect | `POST /api/charge` |
977
-
978
- `createCardSaleHandler` / `createCardSaleMiddleware` accept a `CardSaleHandlerOptions` object:
979
-
980
- ```ts
981
- interface CardSaleHandlerOptions {
982
- /**
983
- * Required. Return the charge amount as a decimal string.
984
- * Never trust the amount from the request body — resolve it from your database.
985
- */
986
- getAmount: (body: Record<string, unknown>) => Promise<string>;
987
-
988
- /**
989
- * Optional. Return the ISO 4217 currency code. Default: "USD".
990
- */
991
- getCurrency?: (body: Record<string, unknown>) => Promise<string>;
992
- }
993
- ```
994
-
995
- Both handler factories validate `Content-Type: application/json`, run `validateBilling()`, extract the client IP from standard proxy headers, and return normalized `{ transactionId, amount, cardLastFour, cardBrand }` on success.
996
-
997
- ---
998
-
999
- ## Local development
1000
-
1001
- The repository includes a development server at `dev-server.mjs` that serves the built frame assets and proxies vault API requests:
1002
-
1003
- ```bash
1004
- npm run dev # build + start dev server on http://localhost:4242
1005
- ```
1006
-
1007
- Set `frameBaseUrl` to point your vault at the local server:
1008
-
1009
- ```ts
1010
- const vault = await OzVault.create({
1011
- pubKey: 'pk_test_...',
1012
- fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
1013
- frameBaseUrl: 'http://localhost:4242', // local dev only
1014
- });
1015
- ```
1016
-
1017
- Or in React:
1018
-
1019
- ```tsx
1020
- <OzElements
1021
- pubKey="pk_test_..."
1022
- fetchWaxKey={createFetchWaxKey('/api/mint-wax')}
1023
- frameBaseUrl="http://localhost:4242"
1024
- >
1025
- ```
1026
-
1027
- Configure environment variables for the dev server:
1028
-
1029
- ```bash
1030
- VAULT_URL=https://vault-staging.example.com
1031
- VAULT_API_KEY=vk_test_...
1032
- ```
1033
-
1034
- ---
1035
-
1036
- ## Content Security Policy
1037
-
1038
- The SDK loads iframes from the Ozura frame origin. Add the following directives to your CSP:
1039
-
1040
- ```
1041
- frame-src https://elements.ozura.com;
1042
- ```
1043
-
1044
- If loading custom fonts via `fonts[].cssSrc`, also allow the font stylesheet origin:
1045
-
1046
- ```
1047
- style-src https://fonts.googleapis.com;
1048
- font-src https://fonts.gstatic.com;
1049
- ```
1050
-
1051
- To verify your CSP after a build:
1052
-
1053
- ```bash
1054
- npm run check:csp
1055
- ```
1056
-
1057
- ---
1058
-
1059
- ## TypeScript reference
1060
-
1061
- All public types are exported from `@ozura/elements`:
1062
-
1063
- ```ts
1064
- import type {
1065
- // Element types
1066
- ElementType, // 'cardNumber' | 'cvv' | 'expirationDate'
1067
- BankElementType, // 'accountNumber' | 'routingNumber'
1068
- ElementOptions,
1069
- ElementStyleConfig,
1070
- ElementStyle,
1071
- ElementChangeEvent,
1072
-
1073
- // Vault config
1074
- VaultOptions,
1075
- FontSource,
1076
- CssFontSource,
1077
- CustomFontSource,
1078
- Appearance,
1079
- AppearanceVariables,
1080
- OzTheme, // 'default' | 'night' | 'flat'
1081
-
1082
- // Tokenization
1083
- TokenizeOptions,
1084
- BankTokenizeOptions,
1085
- TokenResponse,
1086
- BankTokenResponse,
1087
- CardMetadata,
1088
- BankAccountMetadata,
1089
-
1090
- // Billing
1091
- BillingDetails,
1092
- BillingAddress,
1093
-
1094
- // Card sale
1095
- CardSaleRequest,
1096
- CardSaleResponseData,
1097
- CardSaleApiResponse,
1098
-
1099
- // Transactions
1100
- TransactionQueryParams,
1101
- TransactionQueryPagination,
1102
- TransactionQueryResponse,
1103
- TransactionType,
1104
- TransactionData,
1105
- CardTransactionData,
1106
- AchTransactionData,
1107
- CryptoTransactionData,
1108
-
1109
- // Errors
1110
- OzErrorCode,
1111
- } from '@ozura/elements';
1112
- ```
1113
-
1114
- Server-specific types are exported from `@ozura/elements/server`:
1115
-
1116
- ```ts
1117
- import type {
1118
- OzuraConfig,
1119
- CardSaleInput,
1120
- MintWaxKeyOptions,
1121
- MintWaxKeyResult,
1122
- ListTransactionsInput,
1123
- } from '@ozura/elements/server';
1124
- ```
1125
-
1126
- React-specific types are exported from `@ozura/elements/react`:
1127
-
1128
- ```ts
1129
- import type { OzFieldProps, OzCardProps, OzCardState, OzBankCardProps, OzBankCardState } from '@ozura/elements/react';
1130
- ```
1
+ # @ozura/elements
2
+
3
+ PCI-isolated card and bank account tokenization SDK for the Ozura Vault.
4
+
5
+ Card data is collected inside Ozura-hosted iframes so raw numbers never touch your JavaScript bundle, your server logs, or your network traffic. The tokenizer communicates directly with the vault using `MessageChannel` port transfers — the merchant page acts as a layout host only.
6
+
7
+ > **📖 Full documentation:** [docs.ozura.com/sdks/elements/overview](https://docs.ozura.com/sdks/elements/overview)
8
+ >
9
+ > The docs include step-by-step guides, interactive examples, a complete API reference, and styling walkthroughs. If you are starting a new integration, the docs are the best place to begin.
10
+
11
+ ---
12
+
13
+ ## Table of contents
14
+
15
+ - [Full documentation](#full-documentation)
16
+ - [How it works](#how-it-works)
17
+ - [Installation](#installation)
18
+ - [Quick start — React](#quick-start--react)
19
+ - [Quick start — Vanilla JS](#quick-start--vanilla-js)
20
+ - [Server setup](#server-setup)
21
+ - [Wax key endpoint](#wax-key-endpoint)
22
+ - [Card sale endpoint](#card-sale-endpoint)
23
+ - [Vanilla JS API](#vanilla-js-api)
24
+ - [OzVault.create()](#ozvaultcreate)
25
+ - [vault.createElement()](#vaultcreateelementtypeoptions)
26
+ - [vault.createToken()](#vaultcreatetokenoptions)
27
+ - [vault.createBankElement()](#vaultcreatebankelement)
28
+ - [vault.createBankToken()](#vaultcreatebanktokenoptions)
29
+ - [vault.destroy()](#vaultdestroy)
30
+ - [OzElement events](#ozelement-events)
31
+ - [React API](#react-api)
32
+ - [OzElements provider](#ozelements-provider)
33
+ - [OzCard](#ozcard)
34
+ - [Individual field components](#individual-field-components)
35
+ - [OzBankCard](#ozbankcard)
36
+ - [useOzElements()](#useozelements)
37
+ - [Styling](#styling)
38
+ - [Per-element styles](#per-element-styles)
39
+ - [Global appearance](#global-appearance)
40
+ - [Custom fonts](#custom-fonts)
41
+ - [Billing details](#billing-details)
42
+ - [Error handling](#error-handling)
43
+ - [Server utilities](#server-utilities)
44
+ - [Ozura class](#ozura-class)
45
+ - [Route handler factories](#route-handler-factories)
46
+ - [Local development](#local-development)
47
+ - [Content Security Policy](#content-security-policy)
48
+ - [TypeScript reference](#typescript-reference)
49
+
50
+ ---
51
+
52
+ ## Full documentation
53
+
54
+ The README covers the most common integration patterns. The hosted docs at **[docs.ozura.com/sdks/elements](https://docs.ozura.com/sdks/elements/overview)** go deeper on every topic:
55
+
56
+ | Page | URL |
57
+ |---|---|
58
+ | Overview | [docs.ozura.com/sdks/elements/overview](https://docs.ozura.com/sdks/elements/overview) |
59
+ | Installation & setup | [docs.ozura.com/sdks/elements/installation](https://docs.ozura.com/sdks/elements/installation) |
60
+ | Card elements | [docs.ozura.com/sdks/elements/card-elements](https://docs.ozura.com/sdks/elements/card-elements) |
61
+ | Bank elements | [docs.ozura.com/sdks/elements/bank-elements](https://docs.ozura.com/sdks/elements/bank-elements) |
62
+ | React integration | [docs.ozura.com/sdks/elements/react](https://docs.ozura.com/sdks/elements/react) |
63
+ | Styling | [docs.ozura.com/sdks/elements/styling](https://docs.ozura.com/sdks/elements/styling) |
64
+ | Error handling | [docs.ozura.com/sdks/elements/error-handling](https://docs.ozura.com/sdks/elements/error-handling) |
65
+ | Server SDK | [docs.ozura.com/sdks/elements/server](https://docs.ozura.com/sdks/elements/server) |
66
+ | API reference | [docs.ozura.com/sdks/elements/api-reference](https://docs.ozura.com/sdks/elements/api-reference) |
67
+
68
+ ---
69
+
70
+ ## How it works
71
+
72
+ ```
73
+ Merchant page
74
+ ├── OzVault (manages tokenizer iframe + element iframes)
75
+ ├── [hidden] tokenizer-frame.html ← Ozura origin
76
+ ├── [visible] element-frame.html ← card number ─┐ MessageChannel
77
+ ├── [visible] element-frame.html ← expiry ├─ port transfer
78
+ └── [visible] element-frame.html ← CVV ─┘
79
+ ```
80
+
81
+ 1. `OzVault.create()` mounts a hidden tokenizer iframe and fetches a short-lived **wax key** from your server.
82
+ 2. Calling `vault.createElement()` mounts a visible input iframe for each field.
83
+ 3. `vault.createToken()` opens a direct `MessageChannel` between each element iframe and the tokenizer iframe. Raw values travel over those ports — they never pass through your JavaScript.
84
+ 4. The tokenizer POSTs directly to the vault API over HTTPS and returns a token to your page.
85
+
86
+ Your server only ever sees a token, never card data.
87
+
88
+ ---
89
+
90
+ ## Credentials
91
+
92
+ | Credential | Format | Where it lives | Required for |
93
+ |---|---|---|---|
94
+ | **Vault pub key** | `pk_live_…` or `pk_prod_…` | Frontend env var (safe to expose) | All integrations |
95
+ | **Vault API key** | `key_…` | Server env var — **never in the browser** | Minting wax keys (all integrations) |
96
+ | **Pay API key** | `ak_…` | Server env var only | OzuraPay merchants (card charging) |
97
+ | **Merchant ID** | `ozu_…` | Server env var only | OzuraPay merchants (card charging) |
98
+
99
+ If you are not routing payments through OzuraPay you only need the vault pub key (frontend) and vault API key (backend).
100
+
101
+ ---
102
+
103
+ ## Installation
104
+
105
+ > 📖 [Installation guide](https://docs.ozura.com/sdks/elements/installation) — npm, yarn, CDN setup, and TypeScript configuration.
106
+
107
+ ```bash
108
+ npm install @ozura/elements
109
+ ```
110
+
111
+ React and React DOM are peer dependencies (optional — only needed for `@ozura/elements/react`):
112
+
113
+ ```bash
114
+ npm install react react-dom # if not already installed
115
+ ```
116
+
117
+ **Requirements:** Node 18, React ≥ 17 (React peer).
118
+
119
+ ---
120
+
121
+ ## Quick start — React
122
+
123
+ > 📖 [React integration guide](https://docs.ozura.com/sdks/elements/react) — provider setup, pre-built components, hook reference, and full examples.
124
+
125
+ ```tsx
126
+ // 1. Wrap your checkout in <OzElements>
127
+ import { OzElements, OzCard, useOzElements, createFetchWaxKey } from '@ozura/elements/react';
128
+
129
+ function CheckoutPage() {
130
+ return (
131
+ <OzElements
132
+ pubKey="pk_live_..."
133
+ fetchWaxKey={createFetchWaxKey('/api/mint-wax')}
134
+ >
135
+ <CheckoutForm />
136
+ </OzElements>
137
+ );
138
+ }
139
+
140
+ // 2. Collect card data and tokenize
141
+ function CheckoutForm() {
142
+ const { createToken, ready } = useOzElements();
143
+
144
+ const handleSubmit = async (e: React.FormEvent) => {
145
+ e.preventDefault();
146
+ const { token, cvcSession, billing } = await createToken({
147
+ billing: {
148
+ firstName: 'Jane',
149
+ lastName: 'Smith',
150
+ email: 'jane@example.com',
151
+ address: { line1: '123 Main St', city: 'Austin', state: 'TX', zip: '78701', country: 'US' },
152
+ },
153
+ });
154
+
155
+ // Send token to your server
156
+ await fetch('/api/charge', {
157
+ method: 'POST',
158
+ headers: { 'Content-Type': 'application/json' },
159
+ body: JSON.stringify({ token, cvcSession, billing }),
160
+ });
161
+ };
162
+
163
+ return (
164
+ <form onSubmit={handleSubmit}>
165
+ <OzCard onChange={(state) => console.log(state.cardBrand)} />
166
+ <button type="submit" disabled={!ready}>Pay</button>
167
+ </form>
168
+ );
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Quick start — Vanilla JS
175
+
176
+ > 📖 [Card elements guide](https://docs.ozura.com/sdks/elements/card-elements) — full end-to-end example, field events, submit-button gating, and auto-advance behaviour.
177
+
178
+ ```ts
179
+ import { OzVault, createFetchWaxKey } from '@ozura/elements';
180
+
181
+ // Declare state BEFORE OzVault.create(). The onReady callback fires when the
182
+ // tokenizer iframe loads this can happen before create() resolves because the
183
+ // iframe loads concurrently with fetchWaxKey. At that moment, `vault` is still
184
+ // undefined. Do not reference `vault` inside onReady.
185
+ let readyCount = 0;
186
+ let tokenizerIsReady = false;
187
+
188
+ function checkReady() {
189
+ if (readyCount === 3 && tokenizerIsReady) enablePayButton();
190
+ }
191
+
192
+ const vault = await OzVault.create({
193
+ pubKey: 'pk_live_...',
194
+ fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
195
+ onReady: () => { tokenizerIsReady = true; checkReady(); }, // tokenizer iframe loaded
196
+ });
197
+
198
+ const cardNumberEl = vault.createElement('cardNumber');
199
+ const expiryEl = vault.createElement('expirationDate');
200
+ const cvvEl = vault.createElement('cvv');
201
+
202
+ cardNumberEl.mount('#card-number');
203
+ expiryEl.mount('#expiry');
204
+ cvvEl.mount('#cvv');
205
+
206
+ [cardNumberEl, expiryEl, cvvEl].forEach(el => {
207
+ el.on('ready', () => { readyCount++; checkReady(); });
208
+ });
209
+
210
+ async function pay() {
211
+ const { token, cvcSession, card } = await vault.createToken({
212
+ billing: { firstName: 'Jane', lastName: 'Smith' },
213
+ });
214
+ // POST { token, cvcSession } to your server
215
+ }
216
+
217
+ // Clean up when done
218
+ vault.destroy();
219
+ ```
220
+
221
+ > **Gating the submit button in vanilla JS** requires checking both `vault.isReady` (tokenizer iframe loaded) **and** every element's `ready` event (input iframes loaded). In React, `useOzElements().ready` combines both automatically.
222
+
223
+ ---
224
+
225
+ ## Server setup
226
+
227
+ > 📖 [Server SDK guide](https://docs.ozura.com/sdks/elements/server) — wax key minting, card sale handler factories, IP extraction, and manual implementation patterns.
228
+
229
+ ### Wax key endpoint
230
+
231
+ The SDK calls your `fetchWaxKey` function with a `tokenizationSessionId` UUID. Your backend must exchange it for a wax key from the vault.
232
+
233
+ **Next.js App Router (recommended)**
234
+
235
+ ```ts
236
+ // app/api/mint-wax/route.ts
237
+ import { Ozura, createMintWaxHandler } from '@ozura/elements/server';
238
+
239
+ const ozura = new Ozura({
240
+ merchantId: process.env.MERCHANT_ID!,
241
+ apiKey: process.env.MERCHANT_API_KEY!,
242
+ vaultKey: process.env.VAULT_API_KEY!,
243
+ });
244
+
245
+ export const POST = createMintWaxHandler(ozura);
246
+ ```
247
+
248
+ **Express**
249
+
250
+ ```ts
251
+ import express from 'express';
252
+ import { Ozura, createMintWaxMiddleware } from '@ozura/elements/server';
253
+
254
+ const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
255
+ const app = express();
256
+
257
+ app.use(express.json());
258
+ app.post('/api/mint-wax', createMintWaxMiddleware(ozura));
259
+ ```
260
+
261
+ **Manual implementation**
262
+
263
+ ```ts
264
+ // POST /api/mint-wax
265
+ const { sessionId } = await req.json();
266
+ const { waxKey } = await ozura.mintWaxKey({
267
+ tokenizationSessionId: sessionId,
268
+ maxTokenizeCalls: 3, // vault enforces this limit; must match VaultOptions.maxTokenizeCalls on the client
269
+ });
270
+ return Response.json({ waxKey });
271
+ ```
272
+
273
+ ### Card sale endpoint
274
+
275
+ After `createToken()` resolves on the frontend, POST `{ token, cvcSession, billing }` to your server to charge the card.
276
+
277
+ **Next.js App Router**
278
+
279
+ ```ts
280
+ // app/api/charge/route.ts
281
+ import { Ozura, createCardSaleHandler } from '@ozura/elements/server';
282
+
283
+ const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
284
+
285
+ export const POST = createCardSaleHandler(ozura, {
286
+ getAmount: async (body) => {
287
+ const order = await db.orders.findById(body.orderId as string);
288
+ return order.total; // decimal string, e.g. "49.00"
289
+ },
290
+ // getCurrency: async (body) => 'USD', // optional, defaults to "USD"
291
+ });
292
+ ```
293
+
294
+ **Express**
295
+
296
+ ```ts
297
+ app.post('/api/charge', createCardSaleMiddleware(ozura, {
298
+ getAmount: async (body) => {
299
+ const order = await db.orders.findById(body.orderId as string);
300
+ return order.total; // decimal string, e.g. "49.00"
301
+ },
302
+ // getCurrency: async (body) => 'USD', // optional, defaults to "USD"
303
+ }));
304
+ ```
305
+
306
+ > `createCardSaleMiddleware` always terminates the request it does not call `next()` and cannot be composed in a middleware chain.
307
+
308
+ **Manual implementation**
309
+
310
+ ```ts
311
+ const { token, cvcSession, billing } = await req.json();
312
+ const result = await ozura.cardSale({
313
+ token,
314
+ cvcSession,
315
+ amount: '49.00',
316
+ currency: 'USD',
317
+ billing,
318
+ // Take the first IP from the forwarded-for chain; fall back to socket address.
319
+ // req is a Fetch API Request (Next.js App Router / Vercel Edge).
320
+ clientIpAddress: req.headers.get('x-forwarded-for')?.split(',')[0].trim()
321
+ ?? req.headers.get('x-real-ip')
322
+ ?? '',
323
+ });
324
+ // result.transactionId, result.amount, result.cardLastFour, result.cardBrand
325
+ ```
326
+
327
+ ---
328
+
329
+ ## Vanilla JS API
330
+
331
+ > 📖 [API reference](https://docs.ozura.com/sdks/elements/api-reference) — complete type definitions and method signatures for every class.
332
+
333
+ ### OzVault.create(options)
334
+
335
+ ```ts
336
+ const vault = await OzVault.create(options: VaultOptions): Promise<OzVault>
337
+ ```
338
+
339
+ Mounts the hidden tokenizer iframe and fetches the wax key concurrently. Both happen in parallel — by the time `create()` resolves, the iframe may already be ready.
340
+
341
+ | Option | Type | Required | Description |
342
+ |---|---|---|---|
343
+ | `pubKey` | `string` | ✓ | Your public key from the Ozura admin. |
344
+ | `fetchWaxKey` | `(sessionId: string) => Promise<string>` | ✓ | Called with the session ID; must return a wax key string from your server. Use `createFetchWaxKey('/api/mint-wax')` for the common case. |
345
+ | `frameBaseUrl` | `string` | — | Base URL for iframe assets. Defaults to production CDN. Override for local dev (see [Local development](#local-development)). |
346
+ | `fonts` | `FontSource[]` | — | Custom fonts to inject into all element iframes. |
347
+ | `appearance` | `Appearance` | — | Global theme and variable overrides. |
348
+ | `loadTimeoutMs` | `number` | — | Tokenizer iframe load timeout in ms. Default: `10000`. Only takes effect when `onLoadError` is also provided. |
349
+ | `onLoadError` | `() => void` | — | Called if the tokenizer iframe fails to load within `loadTimeoutMs`. |
350
+ | `onWaxRefresh` | `() => void` | — | Called when the SDK silently re-mints an expired wax key mid-tokenization. |
351
+ | `onReady` | `() => void` | — | Called once when the tokenizer iframe has loaded and is ready. Use in vanilla JS to re-check submit-button readiness when the tokenizer becomes ready after all element iframes have already fired. In React, `useOzElements().ready` handles this automatically. |
352
+ | `maxTokenizeCalls` | `number` | — | Maximum successful `createToken` calls per wax key before the key is considered consumed. Default: `3`. Must match `maxTokenizeCalls` in your server-side `mintWaxKey` call. |
353
+
354
+ Throws `OzError` if `fetchWaxKey` rejects, returns an empty string, or returns a non-string value.
355
+
356
+ > **`createFetchWaxKey` retry behavior:** The built-in helper enforces a **10-second per-attempt timeout** and retries **once after 750 ms on pure network failures** (connection refused, DNS failure, offline). HTTP 4xx/5xx errors are never retried — they signal endpoint misconfiguration or invalid credentials and require developer action. Errors are thrown as `OzError` instances so you can inspect `err.errorCode` and `err.retryable`.
357
+
358
+ > **Re-export identity:** `createFetchWaxKey` is exported from both `@ozura/elements` and `@ozura/elements/react`. They are identical — the same function. Use whichever matches your import context.
359
+
360
+ ---
361
+
362
+ ### vault.createElement(type, options?)
363
+
364
+ ```ts
365
+ vault.createElement(type: ElementType, options?: ElementOptions): OzElement
366
+ ```
367
+
368
+ Creates and returns an element iframe. Call `.mount(target)` to attach it to the DOM.
369
+
370
+ `ElementType`: `'cardNumber'` | `'cvv'` | `'expirationDate'`
371
+
372
+ ```ts
373
+ const cardEl = vault.createElement('cardNumber', {
374
+ placeholder: '1234 5678 9012 3456',
375
+ style: {
376
+ base: { color: '#1a1a1a', fontSize: '16px' },
377
+ focus: { borderColor: '#6366f1' },
378
+ invalid: { color: '#dc2626' },
379
+ complete: { color: '#16a34a' },
380
+ },
381
+ });
382
+
383
+ cardEl.mount('#card-number-container');
384
+ ```
385
+
386
+ `ElementOptions`:
387
+
388
+ | Option | Type | Description |
389
+ |---|---|---|
390
+ | `style` | `ElementStyleConfig` | Per-state style overrides. See [Styling](#styling). |
391
+ | `placeholder` | `string` | Placeholder text (max 100 characters). |
392
+ | `disabled` | `boolean` | Disables the input. |
393
+ | `loadTimeoutMs` | `number` | Iframe load timeout in ms. Default: `10000`. |
394
+
395
+ **OzElement methods:**
396
+
397
+ | Method | Description |
398
+ |---|---|
399
+ | `.mount(target)` | Mount the iframe. Accepts a CSS selector string or `HTMLElement`. |
400
+ | `.unmount()` | Remove the iframe from the DOM. The element can be re-mounted. |
401
+ | `.destroy()` | Permanently destroy the element. Cannot be re-mounted. |
402
+ | `.update(options)` | Update placeholder, style, or disabled state without re-mounting. **Merge semantics** — only provided keys are applied; omitted keys retain their values. To reset a property, pass it with an empty string (e.g. `{ style: { base: { color: '' } } }`). To fully reset all styles, destroy and recreate the element. |
403
+ | `.clear()` | Clear the field value. |
404
+ | `.focus()` | Programmatically focus the input. |
405
+ | `.blur()` | Programmatically blur the input. |
406
+ | `.on(event, fn)` | Subscribe to an event. Returns `this` for chaining. |
407
+ | `.off(event, fn)` | Remove an event handler. |
408
+ | `.once(event, fn)` | Subscribe for a single invocation. |
409
+ | `.isReady` | `true` once the iframe has loaded and signalled ready. |
410
+
411
+ ---
412
+
413
+ ### vault.createToken(options?)
414
+
415
+ ```ts
416
+ vault.createToken(options?: TokenizeOptions): Promise<TokenResponse>
417
+ ```
418
+
419
+ Tokenizes all mounted and ready card elements. Raw values travel directly from element iframes to the tokenizer iframe via `MessageChannel` — they never pass through your page's JavaScript.
420
+
421
+ Returns a `TokenResponse`:
422
+
423
+ ```ts
424
+ interface TokenResponse {
425
+ token: string; // Vault token — pass to cardSale
426
+ cvcSession: string; // CVC session always present; pass to cardSale
427
+ card?: { // Card metadata — present when vault returns all four fields
428
+ last4: string; // e.g. "4242"
429
+ brand: string; // e.g. "visa"
430
+ expMonth: string; // e.g. "09"
431
+ expYear: string; // e.g. "2027"
432
+ };
433
+ billing?: BillingDetails; // Normalized billing — only present if billing was passed in
434
+ }
435
+ ```
436
+
437
+ `TokenizeOptions`:
438
+
439
+ | Option | Type | Description |
440
+ |---|---|---|
441
+ | `billing` | `BillingDetails` | Validated and normalized billing details. Returned in `TokenResponse.billing`. |
442
+ | `firstName` | `string` | **Deprecated.** Pass inside `billing` instead. |
443
+ | `lastName` | `string` | **Deprecated.** Pass inside `billing` instead. |
444
+
445
+ Throws `OzError` if:
446
+ - The vault is not ready (`errorCode: 'unknown'`)
447
+ - A tokenization is already in progress
448
+ - Billing validation fails (`errorCode: 'validation'`)
449
+ - No elements are mounted
450
+ - The vault returns an error (`errorCode` reflects the HTTP status)
451
+ - The request times out after 30 seconds (`errorCode: 'timeout'`) — this timeout is separate from `loadTimeoutMs` and is not configurable
452
+
453
+ **`vault.tokenizeCount`**
454
+
455
+ ```ts
456
+ vault.tokenizeCount: number // read-only getter
457
+ ```
458
+
459
+ Returns the number of successful `createToken()` / `createBankToken()` calls made against the current wax key. Resets to `0` each time the wax key is refreshed (proactively or reactively). Use this in vanilla JS to display "attempts remaining" feedback or gate the submit button:
460
+
461
+ ```ts
462
+ const MAX = 3; // matches maxTokenizeCalls
463
+ const remaining = MAX - vault.tokenizeCount;
464
+ payButton.textContent = `Pay (${remaining} attempt${remaining === 1 ? '' : 's'} remaining)`;
465
+ ```
466
+
467
+ In React, use `tokenizeCount` from `useOzElements()` instead — it is a reactive state value and will trigger re-renders automatically.
468
+
469
+ ---
470
+
471
+ ### vault.createBankElement()
472
+
473
+ ```ts
474
+ vault.createBankElement(type: BankElementType, options?: ElementOptions): OzElement
475
+ ```
476
+
477
+ Creates a bank account element. `BankElementType`: `'accountNumber'` | `'routingNumber'`.
478
+
479
+ ```ts
480
+ const accountEl = vault.createBankElement('accountNumber');
481
+ const routingEl = vault.createBankElement('routingNumber');
482
+ accountEl.mount('#account-number');
483
+ routingEl.mount('#routing-number');
484
+ ```
485
+
486
+ ---
487
+
488
+ ### vault.createBankToken(options)
489
+
490
+ ```ts
491
+ vault.createBankToken(options: BankTokenizeOptions): Promise<BankTokenResponse>
492
+ ```
493
+
494
+ Tokenizes the mounted `accountNumber` and `routingNumber` elements. Both must be mounted and ready.
495
+
496
+ ```ts
497
+ interface BankTokenizeOptions {
498
+ firstName: string; // Account holder first name (required, max 50 chars)
499
+ lastName: string; // Account holder last name (required, max 50 chars)
500
+ }
501
+
502
+ interface BankTokenResponse {
503
+ token: string;
504
+ bank?: {
505
+ last4: string; // Last 4 digits of account number
506
+ routingNumberLast4: string;
507
+ };
508
+ }
509
+ ```
510
+
511
+ > **Note:** OzuraPay does not currently support bank account payments. Use the bank token with your own ACH processor. Bank tokens can be passed to any ACH-capable processor directly.
512
+
513
+ > **Card tokens and processors:** Card tokenization returns a `cvcSession` alongside the `token`. OzuraPay's charge API requires `cvcSession`. If you are routing to a non-Ozura processor, pass the `token` directly — your processor's documentation will tell you whether a CVC session token is required.
514
+
515
+ ---
516
+
517
+ ### vault.destroy()
518
+
519
+ ```ts
520
+ vault.destroy(): void
521
+ ```
522
+
523
+ Tears down the vault: removes all element and tokenizer iframes, clears the `message` event listener, rejects any pending `createToken()` / `createBankToken()` promises, and cancels active timeout handles. Call this when the checkout component unmounts.
524
+
525
+ ```ts
526
+ // React useEffect cleanup the cancel flag prevents a vault from leaking
527
+ // if the component unmounts before OzVault.create() resolves.
528
+ useEffect(() => {
529
+ let cancelled = false;
530
+ let vault: OzVault | null = null;
531
+ OzVault.create(options).then(v => {
532
+ if (cancelled) { v.destroy(); return; }
533
+ vault = v;
534
+ });
535
+ return () => {
536
+ cancelled = true;
537
+ vault?.destroy();
538
+ };
539
+ }, []);
540
+ ```
541
+
542
+ ---
543
+
544
+ ### OzElement events
545
+
546
+ ```ts
547
+ element.on('change', (event: ElementChangeEvent) => { ... });
548
+ element.on('focus', () => { ... });
549
+ element.on('blur', () => { ... });
550
+ element.on('ready', () => { ... });
551
+ element.on('loaderror', (payload: { elementType: string; error: string }) => { ... });
552
+ ```
553
+
554
+ `ElementChangeEvent`:
555
+
556
+ | Field | Type | Description |
557
+ |---|---|---|
558
+ | `empty` | `boolean` | `true` when the field is empty. |
559
+ | `complete` | `boolean` | `true` when the field has enough digits to be complete. |
560
+ | `valid` | `boolean` | `true` when the value passes all validation (Luhn, expiry date, etc.). |
561
+ | `error` | `string \| undefined` | User-facing error message when `valid` is `false` and the field has been touched. |
562
+ | `cardBrand` | `string \| undefined` | Detected brand — only on `cardNumber` fields (e.g. `"visa"`, `"amex"`). |
563
+ | `month` | `string \| undefined` | Parsed 2-digit month — only on `expirationDate` fields. |
564
+ | `year` | `string \| undefined` | Parsed 2-digit year — only on `expirationDate` fields. |
565
+
566
+ > **Expiry data and PCI scope:** The expiry `onChange` event delivers parsed `month` and `year` to your handler for display purposes (e.g. "Card expires MM/YY"). These are not PANs or CVVs, but they are cardholder data. If your PCI scope requires zero cardholder data on the merchant page, do not read, log, or store these fields.
567
+
568
+ Auto-advance is built in: the vault automatically moves focus from card number → expiry → CVV when each field completes. No additional code required.
569
+
570
+ ---
571
+
572
+ ## React API
573
+
574
+ > 📖 [React guide](https://docs.ozura.com/sdks/elements/react) `OzElements` provider, `useOzElements` hook, pre-built `OzCard` and `OzBankCard` components, and StrictMode behaviour.
575
+
576
+ ### OzElements provider
577
+
578
+ ```tsx
579
+ import { OzElements, createFetchWaxKey } from '@ozura/elements/react';
580
+
581
+ <OzElements
582
+ pubKey="pk_live_..."
583
+ fetchWaxKey={createFetchWaxKey('/api/mint-wax')}
584
+ appearance={{ theme: 'flat', variables: { colorPrimary: '#6366f1' } }}
585
+ onLoadError={() => setPaymentUnavailable(true)}
586
+ >
587
+ {children}
588
+ </OzElements>
589
+ ```
590
+
591
+ All `VaultOptions` are accepted as props. The provider creates a single `OzVault` instance and destroys it on unmount.
592
+
593
+ > **Prop changes and vault lifecycle:** Changing `pubKey`, `frameBaseUrl`, `loadTimeoutMs`, `appearance`, `fonts`, or `maxTokenizeCalls` destroys the current vault and creates a new one — all field iframes will remount. Changing `fetchWaxKey`, `onLoadError`, `onWaxRefresh`, or `onReady` updates the callback in place via refs without recreating the vault.
594
+
595
+ > **One card form per provider:** A vault holds one element per field type (`cardNumber`, `expiry`, `cvv`, etc.). Rendering two `<OzCard>` components under the same `<OzElements>` provider will cause the second to silently replace the first's iframes, breaking the first form. If you genuinely need two independent card forms on the same page, wrap each in its own `<OzElements>` provider with separate `pubKey` / `fetchWaxKey` configurations.
596
+
597
+ ---
598
+
599
+ ### OzCard
600
+
601
+ Drop-in combined card component. Renders card number, expiry, and CVV with a configurable layout.
602
+
603
+ ```tsx
604
+ import { OzCard } from '@ozura/elements/react';
605
+
606
+ <OzCard
607
+ layout="default" // "default" (number on top, expiry+CVV below) | "rows" (stacked)
608
+ onChange={(state) => {
609
+ // state.complete — all three fields complete + valid
610
+ // state.cardBrand — detected brand
611
+ // state.error — first error across all fields
612
+ // state.fields — per-field ElementChangeEvent objects
613
+ }}
614
+ onReady={() => console.log('all card fields loaded')}
615
+ disabled={isSubmitting}
616
+ labels={{ cardNumber: 'Card Number', expiry: 'Expiry', cvv: 'CVV' }}
617
+ placeholders={{ cardNumber: '1234 5678 9012 3456', expiry: 'MM/YY', cvv: '···' }}
618
+ />
619
+ ```
620
+
621
+ `OzCardProps` (full):
622
+
623
+ | Prop | Type | Description |
624
+ |---|---|---|
625
+ | `layout` | `'default' \| 'rows'` | `'default'`: number full-width, expiry+CVV side by side. `'rows'`: all stacked. |
626
+ | `gap` | `number \| string` | Gap between fields. Default: `8` (px). |
627
+ | `style` | `ElementStyleConfig` | Shared style applied to all three inputs. |
628
+ | `styles` | `{ cardNumber?, expiry?, cvv? }` | Per-field overrides merged on top of `style`. |
629
+ | `classNames` | `{ cardNumber?, expiry?, cvv?, row? }` | CSS class names for field wrappers and the expiry+CVV row. |
630
+ | `labels` | `{ cardNumber?, expiry?, cvv? }` | Optional label text above each field. |
631
+ | `labelStyle` | `React.CSSProperties` | Style applied to all `<label>` elements. |
632
+ | `labelClassName` | `string` | Class applied to all `<label>` elements. |
633
+ | `placeholders` | `{ cardNumber?, expiry?, cvv? }` | Custom placeholder text per field. |
634
+ | `hideErrors` | `boolean` | Suppress the built-in error display. Handle via `onChange`. |
635
+ | `errorStyle` | `React.CSSProperties` | Style for the built-in error container. |
636
+ | `errorClassName` | `string` | Class for the built-in error container. |
637
+ | `renderError` | `(error: string) => ReactNode` | Custom error renderer. |
638
+ | `onChange` | `(state: OzCardState) => void` | Fires on any field change. |
639
+ | `onReady` | `() => void` | Fires once all three iframes have loaded. |
640
+ | `onFocus` | `(field: 'cardNumber' \| 'expiry' \| 'cvv') => void` | |
641
+ | `onBlur` | `(field: 'cardNumber' \| 'expiry' \| 'cvv') => void` | |
642
+ | `disabled` | `boolean` | Disable all inputs. |
643
+ | `className` | `string` | Class for the outer wrapper. |
644
+
645
+ ---
646
+
647
+ ### Individual field components
648
+
649
+ For custom layouts where `OzCard` is too opinionated:
650
+
651
+ ```tsx
652
+ import { OzCardNumber, OzExpiry, OzCvv } from '@ozura/elements/react';
653
+
654
+ <OzCardNumber onChange={handleChange} placeholder="Card number" />
655
+ <OzExpiry onChange={handleChange} />
656
+ <OzCvv onChange={handleChange} />
657
+ ```
658
+
659
+ All accept `OzFieldProps`:
660
+
661
+ | Prop | Type | Description |
662
+ |---|---|---|
663
+ | `style` | `ElementStyleConfig` | Input styles. |
664
+ | `placeholder` | `string` | Placeholder text. |
665
+ | `disabled` | `boolean` | Disables the input. |
666
+ | `loadTimeoutMs` | `number` | Iframe load timeout in ms. |
667
+ | `onChange` | `(event: ElementChangeEvent) => void` | |
668
+ | `onFocus` | `() => void` | |
669
+ | `onBlur` | `() => void` | |
670
+ | `onReady` | `() => void` | |
671
+ | `onLoadError` | `(error: string) => void` | |
672
+ | `className` | `string` | Class for the outer wrapper div. |
673
+
674
+ ---
675
+
676
+ ### OzBankCard
677
+
678
+ ```tsx
679
+ import { OzBankCard } from '@ozura/elements/react';
680
+
681
+ <OzBankCard
682
+ onChange={(state) => {
683
+ // state.complete, state.error, state.fields.accountNumber, state.fields.routingNumber
684
+ }}
685
+ labels={{ accountNumber: 'Account Number', routingNumber: 'Routing Number' }}
686
+ />
687
+ ```
688
+
689
+ Or use individual bank components:
690
+
691
+ ```tsx
692
+ import { OzBankAccountNumber, OzBankRoutingNumber } from '@ozura/elements/react';
693
+
694
+ <OzBankAccountNumber onChange={handleChange} />
695
+ <OzBankRoutingNumber onChange={handleChange} />
696
+ ```
697
+
698
+ ---
699
+
700
+ ### useOzElements()
701
+
702
+ ```ts
703
+ const { createToken, createBankToken, ready, initError, tokenizeCount } = useOzElements();
704
+ ```
705
+
706
+ Must be called from inside an `<OzElements>` provider tree.
707
+
708
+ | Return | Type | Description |
709
+ |---|---|---|
710
+ | `createToken` | `(options?: TokenizeOptions) => Promise<TokenResponse>` | Tokenize mounted card elements. |
711
+ | `createBankToken` | `(options: BankTokenizeOptions) => Promise<BankTokenResponse>` | Tokenize mounted bank elements. |
712
+ | `ready` | `boolean` | `true` when the tokenizer **and** all mounted element iframes are ready. Gate your submit button on this. See note below. |
713
+ | `initError` | `Error \| null` | Non-null if `OzVault.create()` failed (e.g. `fetchWaxKey` threw). Render a fallback UI. |
714
+ | `tokenizeCount` | `number` | Number of successful tokenizations since the last wax key was minted. Resets on wax refresh or provider re-init. Useful for tracking calls against `maxTokenizeCalls`. |
715
+
716
+ > **`ready` vs `vault.isReady`:** `ready` from `useOzElements()` is a composite — it combines `vault.isReady` (tokenizer loaded) with element readiness (all mounted input iframes loaded). `vault.isReady` alone is insufficient for gating a submit button. Always use `ready` from `useOzElements()` in React.
717
+
718
+ ---
719
+
720
+ ## Styling
721
+
722
+ > 📖 [Styling guide](https://docs.ozura.com/sdks/elements/styling) — full property allowlist, theme variables, `appearance` options, custom fonts, and `var()` limitations.
723
+
724
+ ### Per-element styles
725
+
726
+ Styles apply inside each iframe's `<input>` element. Only an explicit allowlist of CSS properties is accepted (typography, spacing, borders, box-shadow, cursor, transitions). Values containing `url()`, `var()`, `expression()`, `javascript:`, or CSS breakout characters are blocked on both the SDK and iframe sides. Use literal values instead of CSS custom properties (`var(--token)` is rejected). When a value is stripped, the browser falls back to the element's default (unstyled) appearance for that property — the failure is silent with no error or console warning. Resolve design tokens to literal values before passing them in.
727
+
728
+ ```ts
729
+ const style: ElementStyleConfig = {
730
+ base: {
731
+ color: '#1a1a1a',
732
+ fontSize: '16px',
733
+ fontFamily: '"Inter", sans-serif',
734
+ padding: '10px 12px',
735
+ backgroundColor: '#ffffff',
736
+ borderRadius: '6px',
737
+ border: '1px solid #d1d5db',
738
+ },
739
+ focus: {
740
+ borderColor: '#6366f1',
741
+ boxShadow: '0 0 0 3px rgba(99,102,241,0.15)',
742
+ outline: 'none',
743
+ },
744
+ invalid: {
745
+ borderColor: '#ef4444',
746
+ color: '#dc2626',
747
+ },
748
+ complete: {
749
+ borderColor: '#22c55e',
750
+ },
751
+ placeholder: {
752
+ color: '#9ca3af',
753
+ },
754
+ };
755
+ ```
756
+
757
+ State precedence: `placeholder` applies to the `::placeholder` pseudo-element. `focus`, `invalid`, and `complete` merge on top of `base`.
758
+
759
+ ### Global appearance
760
+
761
+ Apply a preset theme and/or variable overrides to all elements at once:
762
+
763
+ ```ts
764
+ // OzVault.create
765
+ const vault = await OzVault.create({
766
+ pubKey: '...',
767
+ fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
768
+ appearance: {
769
+ theme: 'flat', // 'default' | 'night' | 'flat'
770
+ variables: {
771
+ colorText: '#1a1a1a',
772
+ colorBackground: '#ffffff',
773
+ colorPrimary: '#6366f1', // focus caret + color
774
+ colorDanger: '#dc2626', // invalid state
775
+ colorSuccess: '#16a34a', // complete state
776
+ colorPlaceholder: '#9ca3af',
777
+ fontFamily: '"Inter", sans-serif',
778
+ fontSize: '15px',
779
+ fontWeight: '400',
780
+ padding: '10px 14px',
781
+ letterSpacing: '0.01em',
782
+ },
783
+ },
784
+ });
785
+
786
+ // React provider
787
+ <OzElements pubKey="..." fetchWaxKey={...} appearance={{ theme: 'night' }}>
788
+ ```
789
+
790
+ Per-element `style` takes precedence over `appearance` variables.
791
+
792
+ > **`appearance: {}` is not "no theme":** Passing `appearance: {}` or `appearance: { variables: { ... } }` without a `theme` key applies the `'default'` preset as a base. To render elements with no preset styling, omit `appearance` entirely and use per-element `style` overrides.
793
+ >
794
+ > | `appearance` value | Result |
795
+ > |---|---|
796
+ > | *(omitted entirely)* | No preset — element uses minimal built-in defaults |
797
+ > | `{}` | Equivalent to `{ theme: 'default' }` — full default theme applied |
798
+ > | `{ theme: 'night' }` | Night theme |
799
+ > | `{ variables: { colorText: '#333' } }` | Default theme + variable overrides |
800
+
801
+ ### Custom fonts
802
+
803
+ Fonts are injected into each iframe so they render inside the input fields:
804
+
805
+ ```ts
806
+ fonts: [
807
+ // Google Fonts or any HTTPS CSS URL
808
+ { cssSrc: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap' },
809
+
810
+ // Custom @font-face
811
+ {
812
+ family: 'BrandFont',
813
+ src: 'url(https://cdn.example.com/brand-font.woff2)',
814
+ weight: '400',
815
+ style: 'normal',
816
+ display: 'swap',
817
+ },
818
+ ]
819
+ ```
820
+
821
+ Font `src` values must start with `url(https://...)`. HTTP and data URIs are rejected.
822
+
823
+ ---
824
+
825
+ ## Billing details
826
+
827
+ ```ts
828
+ interface BillingDetails {
829
+ firstName: string; // 1–50 characters
830
+ lastName: string; // 1–50 characters
831
+ email?: string; // Valid email, max 50 characters
832
+ phone?: string; // E.164 format, e.g. "+15551234567", max 50 characters
833
+ address?: {
834
+ line1: string; // 1–50 characters
835
+ line2?: string; // Optional, omitted from cardSale if blank
836
+ city: string; // 1–50 characters
837
+ state: string; // For US/CA: normalized to 2-letter abbreviation
838
+ zip: string; // 1–50 characters
839
+ country: string; // ISO 3166-1 alpha-2, e.g. "US"
840
+ };
841
+ }
842
+ ```
843
+
844
+ Billing is validated and normalized by both `vault.createToken()` and the server-side handler factories. `TokenResponse.billing` contains the normalized result ready to spread into a `cardSale` call.
845
+
846
+ ---
847
+
848
+ ## Error handling
849
+
850
+ > 📖 [Error handling guide](https://docs.ozura.com/sdks/elements/error-handling) — `OzError` fields, every `errorCode` value, retry guidance, and wax key expiry behaviour.
851
+
852
+ All SDK errors are instances of `OzError`:
853
+
854
+ ```ts
855
+ import { OzError } from '@ozura/elements';
856
+
857
+ try {
858
+ const { token } = await vault.createToken({ billing });
859
+ } catch (err) {
860
+ if (err instanceof OzError) {
861
+ switch (err.errorCode) {
862
+ case 'network': // Connection failure — show retry UI
863
+ case 'timeout': // 30s deadline exceeded — safe to retry
864
+ case 'server': // 5xx from vault — transient, safe to retry
865
+ if (err.retryable) showRetryPrompt();
866
+ break;
867
+
868
+ case 'auth': // Bad pub key / API key — configuration issue
869
+ case 'validation': // Bad card data show field-level error
870
+ case 'config': // frameBaseUrl not in permitted allowlist
871
+ case 'unknown':
872
+ showError(err.message);
873
+ break;
874
+ }
875
+ }
876
+ }
877
+ ```
878
+
879
+ `OzError` fields:
880
+
881
+ | Field | Type | Description |
882
+ |---|---|---|
883
+ | `message` | `string` | Human-readable, consumer-facing error message. |
884
+ | `errorCode` | `OzErrorCode` | `'network' \| 'timeout' \| 'auth' \| 'validation' \| 'server' \| 'config' \| 'unknown'` |
885
+ | `raw` | `string` | Raw error string from the vault API, if available. |
886
+ | `retryable` | `boolean` | `true` for `network`, `timeout`, `server`. `false` for `auth`, `validation`, `config`, `unknown`. |
887
+
888
+ > **Wax key expiry is handled automatically.** When a wax key expires or is consumed between initialization and the user clicking Pay, the SDK silently re-mints a fresh key and retries the tokenization once. You will only receive an `auth` error if the re-mint itself fails — for example, if your `/api/mint-wax` backend endpoint is unreachable. A healthy `auth` error in production means your mint endpoint needs attention, not that the user's card is bad.
889
+
890
+ **Error normalisation helpers** (for displaying errors from `cardSale` to users):
891
+
892
+ ```ts
893
+ import { normalizeVaultError, normalizeBankVaultError, normalizeCardSaleError } from '@ozura/elements';
894
+
895
+ // Maps vault tokenize error strings to user-facing copy
896
+ const display = normalizeVaultError(err.raw); // card flows
897
+ const display = normalizeBankVaultError(err.raw); // bank/ACH flows
898
+ const display = normalizeCardSaleError(err.message); // cardSale API errors
899
+ ```
900
+
901
+ ---
902
+
903
+ ## Server utilities
904
+
905
+ > 📖 [Server SDK guide](https://docs.ozura.com/sdks/elements/server) — `Ozura` class methods, route handler factories, `getClientIp`, error types, and rate limits.
906
+
907
+ ### Ozura class
908
+
909
+ ```ts
910
+ import { Ozura, OzuraError } from '@ozura/elements/server';
911
+
912
+ const ozura = new Ozura({
913
+ merchantId: process.env.MERCHANT_ID!,
914
+ apiKey: process.env.MERCHANT_API_KEY!,
915
+ vaultKey: process.env.VAULT_API_KEY!,
916
+ // apiUrl: 'https://api.ozura.com', // override Pay API URL
917
+ // vaultUrl: 'https://vault.ozura.com', // override vault URL
918
+ timeoutMs: 30000, // default
919
+ retries: 2, // max retry attempts for 5xx/network errors (3 total attempts)
920
+ });
921
+ ```
922
+
923
+ > **Tokenize-only integrations** (mint wax keys + tokenize cards, no charging) only need `vaultKey`. The `merchantId` and `apiKey` fields are optional — they are validated lazily and only required when `cardSale()` is called.
924
+ >
925
+ > ```ts
926
+ > const ozura = new Ozura({ vaultKey: process.env.VAULT_API_KEY! });
927
+ > ```
928
+
929
+ **Methods:**
930
+
931
+ ```ts
932
+ // Charge a tokenized card
933
+ const result = await ozura.cardSale({
934
+ token: tokenResponse.token,
935
+ cvcSession: tokenResponse.cvcSession,
936
+ amount: '49.00',
937
+ currency: 'USD', // default: 'USD'
938
+ billing: tokenResponse.billing,
939
+ clientIpAddress: '1.2.3.4', // fetch server-side, never from the browser
940
+ // surchargePercent, tipAmount, salesTaxExempt, processor
941
+ });
942
+ // result.transactionId, result.amount, result.cardLastFour, result.cardBrand
943
+ // result.surchargeAmount and result.tipAmount are optional — only present when non-zero
944
+ const surcharge = result.surchargeAmount ?? '0.00';
945
+ const tip = result.tipAmount ?? '0.00';
946
+
947
+ // Mint a wax key (for custom fetchWaxKey implementations)
948
+ const { waxKey, expiresInSeconds } = await ozura.mintWaxKey({
949
+ tokenizationSessionId: sessionId,
950
+ maxTokenizeCalls: 3, // must match VaultOptions.maxTokenizeCalls on the client (default: 3)
951
+ });
952
+
953
+ // Revoke a wax key — call on all three session-end paths
954
+ // Best-effort — never throws. Shortens the exposure window before the vault's ~30 min TTL.
955
+ await ozura.revokeWaxKey(waxKey);
956
+
957
+ // Suggested pattern — wire all three exit paths:
958
+ // 1. Payment success
959
+ const result = await ozura.cardSale({ ... });
960
+ await ozura.revokeWaxKey(waxKey); // key is spent; close the window immediately
961
+
962
+ // 2. User cancels checkout
963
+ router.post('/api/cancel', async (req) => {
964
+ const { waxKey } = await db.session.get(req.sessionId);
965
+ await ozura.revokeWaxKey(waxKey);
966
+ return Response.json({ ok: true });
967
+ });
968
+
969
+ // 3. Page/tab close (best-effort browser may not deliver this)
970
+ // Use sendBeacon so the request survives navigation / tab close.
971
+ window.addEventListener('visibilitychange', () => {
972
+ if (document.visibilityState === 'hidden') {
973
+ navigator.sendBeacon('/api/cancel', JSON.stringify({ sessionId }));
974
+ }
975
+ });
976
+
977
+ // List transactions
978
+ const { transactions, pagination } = await ozura.listTransactions({
979
+ dateFrom: '2025-01-01',
980
+ dateTo: '2025-12-31',
981
+ transactionType: 'CreditCardSale',
982
+ page: 1,
983
+ limit: 50,
984
+ });
985
+ ```
986
+
987
+ **`OzuraError`** (thrown by all `Ozura` methods):
988
+
989
+ ```ts
990
+ try {
991
+ await ozura.cardSale(input);
992
+ } catch (err) {
993
+ if (err instanceof OzuraError) {
994
+ err.statusCode; // HTTP status code
995
+ err.message; // Normalized message
996
+ err.raw; // Raw API response string
997
+ err.retryAfter; // Seconds (only present on 429)
998
+ }
999
+ }
1000
+ ```
1001
+
1002
+ Rate limits: `cardSale` — 100 req/min per merchant. `listTransactions` — 200 req/min per merchant.
1003
+
1004
+ > **Retry behavior:** `mintWaxKey` and `listTransactions` retry on 5xx and network errors using exponential backoff (1 s / 2 s / 4 s…) up to `retries + 1` total attempts. **`cardSale` is never retried** — it is a non-idempotent financial operation and the result of a duplicate charge cannot be predicted.
1005
+
1006
+ ---
1007
+
1008
+ ### Route handler factories
1009
+
1010
+ The server package exports four factory functions covering two runtimes × two endpoints:
1011
+
1012
+ | Function | Runtime | Endpoint |
1013
+ |---|---|---|
1014
+ | `createMintWaxHandler` | Fetch API (Next.js App Router, Cloudflare, Vercel Edge) | `POST /api/mint-wax` |
1015
+ | `createMintWaxMiddleware` | Express / Connect | `POST /api/mint-wax` |
1016
+ | `createCardSaleHandler` | Fetch API | `POST /api/charge` |
1017
+ | `createCardSaleMiddleware` | Express / Connect | `POST /api/charge` |
1018
+
1019
+ `createCardSaleHandler` / `createCardSaleMiddleware` accept a `CardSaleHandlerOptions` object:
1020
+
1021
+ ```ts
1022
+ interface CardSaleHandlerOptions {
1023
+ /**
1024
+ * Required. Return the charge amount as a decimal string.
1025
+ * Never trust the amount from the request body — resolve it from your database.
1026
+ */
1027
+ getAmount: (body: Record<string, unknown>) => Promise<string>;
1028
+
1029
+ /**
1030
+ * Optional. Return the ISO 4217 currency code. Default: "USD".
1031
+ */
1032
+ getCurrency?: (body: Record<string, unknown>) => Promise<string>;
1033
+ }
1034
+ ```
1035
+
1036
+ Both handler factories validate `Content-Type: application/json`, run `validateBilling()`, extract the client IP from standard proxy headers, and return normalized `{ transactionId, amount, cardLastFour, cardBrand }` on success.
1037
+
1038
+ ---
1039
+
1040
+ ## Local development
1041
+
1042
+ The repository includes a development server at `dev-server.mjs` that serves the built frame assets and proxies vault API requests:
1043
+
1044
+ ```bash
1045
+ npm run dev # build + start dev server on http://localhost:4242
1046
+ ```
1047
+
1048
+ Set `frameBaseUrl` to point your vault at the local server:
1049
+
1050
+ ```ts
1051
+ const vault = await OzVault.create({
1052
+ pubKey: 'pk_test_...',
1053
+ fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
1054
+ frameBaseUrl: 'http://localhost:4242', // local dev only
1055
+ });
1056
+ ```
1057
+
1058
+ Or in React:
1059
+
1060
+ ```tsx
1061
+ <OzElements
1062
+ pubKey="pk_test_..."
1063
+ fetchWaxKey={createFetchWaxKey('/api/mint-wax')}
1064
+ frameBaseUrl="http://localhost:4242"
1065
+ >
1066
+ ```
1067
+
1068
+ Configure environment variables for the dev server:
1069
+
1070
+ ```bash
1071
+ VAULT_URL=https://vault-staging.example.com
1072
+ VAULT_API_KEY=vk_test_...
1073
+ ```
1074
+
1075
+ ---
1076
+
1077
+ ## Content Security Policy
1078
+
1079
+ The SDK loads iframes from the Ozura frame origin. Add the following directives to your CSP:
1080
+
1081
+ ```
1082
+ frame-src https://elements.ozura.com;
1083
+ ```
1084
+
1085
+ If loading custom fonts via `fonts[].cssSrc`, also allow the font stylesheet origin:
1086
+
1087
+ ```
1088
+ style-src https://fonts.googleapis.com;
1089
+ font-src https://fonts.gstatic.com;
1090
+ ```
1091
+
1092
+ To verify your CSP after a build:
1093
+
1094
+ ```bash
1095
+ npm run check:csp
1096
+ ```
1097
+
1098
+ ---
1099
+
1100
+ ## TypeScript reference
1101
+
1102
+ > 📖 [API reference](https://docs.ozura.com/sdks/elements/api-reference) — every interface, union, and enum fully annotated with JSDoc.
1103
+
1104
+ All public types are exported from `@ozura/elements`:
1105
+
1106
+ ```ts
1107
+ import type {
1108
+ // Element types
1109
+ ElementType, // 'cardNumber' | 'cvv' | 'expirationDate'
1110
+ BankElementType, // 'accountNumber' | 'routingNumber'
1111
+ ElementOptions,
1112
+ ElementStyleConfig,
1113
+ ElementStyle,
1114
+ ElementChangeEvent,
1115
+
1116
+ // Vault config
1117
+ VaultOptions,
1118
+ FontSource,
1119
+ CssFontSource,
1120
+ CustomFontSource,
1121
+ Appearance,
1122
+ AppearanceVariables,
1123
+ OzTheme, // 'default' | 'night' | 'flat'
1124
+
1125
+ // Tokenization
1126
+ TokenizeOptions,
1127
+ BankTokenizeOptions,
1128
+ TokenResponse,
1129
+ BankTokenResponse,
1130
+ CardMetadata,
1131
+ BankAccountMetadata,
1132
+
1133
+ // Billing
1134
+ BillingDetails,
1135
+ BillingAddress,
1136
+
1137
+ // Card sale
1138
+ CardSaleRequest,
1139
+ CardSaleResponseData,
1140
+ CardSaleApiResponse,
1141
+
1142
+ // Transactions
1143
+ TransactionQueryParams,
1144
+ TransactionQueryPagination,
1145
+ TransactionQueryResponse,
1146
+ TransactionType,
1147
+ TransactionData,
1148
+ CardTransactionData,
1149
+ AchTransactionData,
1150
+ CryptoTransactionData,
1151
+
1152
+ // Errors
1153
+ OzErrorCode,
1154
+ } from '@ozura/elements';
1155
+ ```
1156
+
1157
+ Server-specific types are exported from `@ozura/elements/server`:
1158
+
1159
+ ```ts
1160
+ import type {
1161
+ OzuraConfig,
1162
+ CardSaleInput,
1163
+ MintWaxKeyOptions,
1164
+ MintWaxKeyResult,
1165
+ ListTransactionsInput,
1166
+ } from '@ozura/elements/server';
1167
+ ```
1168
+
1169
+ React-specific types are exported from `@ozura/elements/react`:
1170
+
1171
+ ```ts
1172
+ import type { OzFieldProps, OzCardProps, OzCardState, OzBankCardProps, OzBankCardState } from '@ozura/elements/react';
1173
+ ```
1174
+
1175
+ ---
1176
+
1177
+ ## Need help?
1178
+
1179
+ The full documentation — including interactive examples, a complete API reference, and integration walkthroughs — lives at:
1180
+
1181
+ **[docs.ozura.com/sdks/elements/overview](https://docs.ozura.com/sdks/elements/overview)**
1182
+
1183
+ | I want to… | Go to |
1184
+ |---|---|
1185
+ | Get started from scratch | [Installation](https://docs.ozura.com/sdks/elements/installation) |
1186
+ | Build a card payment form | [Card elements](https://docs.ozura.com/sdks/elements/card-elements) |
1187
+ | Build an ACH / bank form | [Bank elements](https://docs.ozura.com/sdks/elements/bank-elements) |
1188
+ | Use the React components | [React guide](https://docs.ozura.com/sdks/elements/react) |
1189
+ | Style the input fields | [Styling](https://docs.ozura.com/sdks/elements/styling) |
1190
+ | Handle errors correctly | [Error handling](https://docs.ozura.com/sdks/elements/error-handling) |
1191
+ | Set up the server side | [Server SDK](https://docs.ozura.com/sdks/elements/server) |
1192
+ | Look up a type or method | [API reference](https://docs.ozura.com/sdks/elements/api-reference) |