@ozura/elements 0.1.0-beta.7 → 1.0.0

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.
Files changed (50) hide show
  1. package/README.md +1121 -887
  2. package/dist/frame/element-frame.js +77 -57
  3. package/dist/frame/element-frame.js.map +1 -1
  4. package/dist/frame/tokenizer-frame.html +1 -1
  5. package/dist/frame/tokenizer-frame.js +206 -85
  6. package/dist/frame/tokenizer-frame.js.map +1 -1
  7. package/dist/oz-elements.esm.js +806 -230
  8. package/dist/oz-elements.esm.js.map +1 -1
  9. package/dist/oz-elements.umd.js +806 -229
  10. package/dist/oz-elements.umd.js.map +1 -1
  11. package/dist/react/frame/tokenizerFrame.d.ts +32 -0
  12. package/dist/react/index.cjs.js +957 -218
  13. package/dist/react/index.cjs.js.map +1 -1
  14. package/dist/react/index.esm.js +954 -219
  15. package/dist/react/index.esm.js.map +1 -1
  16. package/dist/react/react/index.d.ts +148 -6
  17. package/dist/react/sdk/OzElement.d.ts +34 -3
  18. package/dist/react/sdk/OzVault.d.ts +68 -4
  19. package/dist/react/sdk/errors.d.ts +9 -0
  20. package/dist/react/sdk/index.d.ts +29 -0
  21. package/dist/react/server/index.d.ts +168 -10
  22. package/dist/react/types/index.d.ts +65 -16
  23. package/dist/react/utils/appearance.d.ts +9 -0
  24. package/dist/react/utils/cardUtils.d.ts +14 -0
  25. package/dist/react/utils/uuid.d.ts +12 -0
  26. package/dist/server/frame/tokenizerFrame.d.ts +32 -0
  27. package/dist/server/index.cjs.js +598 -67
  28. package/dist/server/index.cjs.js.map +1 -1
  29. package/dist/server/index.esm.js +596 -68
  30. package/dist/server/index.esm.js.map +1 -1
  31. package/dist/server/sdk/OzElement.d.ts +34 -3
  32. package/dist/server/sdk/OzVault.d.ts +68 -4
  33. package/dist/server/sdk/errors.d.ts +9 -0
  34. package/dist/server/sdk/index.d.ts +29 -0
  35. package/dist/server/server/index.d.ts +168 -10
  36. package/dist/server/types/index.d.ts +65 -16
  37. package/dist/server/utils/appearance.d.ts +9 -0
  38. package/dist/server/utils/cardUtils.d.ts +14 -0
  39. package/dist/server/utils/uuid.d.ts +12 -0
  40. package/dist/types/frame/tokenizerFrame.d.ts +32 -0
  41. package/dist/types/sdk/OzElement.d.ts +34 -3
  42. package/dist/types/sdk/OzVault.d.ts +68 -4
  43. package/dist/types/sdk/errors.d.ts +9 -0
  44. package/dist/types/sdk/index.d.ts +29 -0
  45. package/dist/types/server/index.d.ts +168 -10
  46. package/dist/types/types/index.d.ts +65 -16
  47. package/dist/types/utils/appearance.d.ts +9 -0
  48. package/dist/types/utils/cardUtils.d.ts +14 -0
  49. package/dist/types/utils/uuid.d.ts +12 -0
  50. package/package.json +7 -4
package/README.md CHANGED
@@ -1,887 +1,1121 @@
1
- # OzElements
2
-
3
- PCI-compliant card tokenization SDK for the Ozura Vault.
4
-
5
- OzElements renders secure, iframe-isolated card input fields on your page and tokenizes card data directly to the Ozura Vault. Your JavaScript never touches raw card numbers, CVVs, or expiry dates — the iframes handle everything. You get back a token. Use it with your own payment processor, your own backend systems, or with the Ozura Pay API.
6
-
7
- You need a **vault public key** (`pk_…`) for the browser SDK and your **vault API key** on the server only — it mints short-lived **wax keys** so the secret never reaches the browser. **Integration expectations and examples are in this README** (section [Wax keys (merchant integration)](#wax-keys-merchant-integration)). [WAX_KEY_IMPLEMENTATION.md](./WAX_KEY_IMPLEMENTATION.md) is an optional deep dive (design history, dev server, security notes).
8
-
9
- ---
10
-
11
- ## Why this exists
12
-
13
- If you need to collect card data, you have a PCI compliance problem. OzElements solves it.
14
-
15
- The Ozura Vault stores and tokenizes sensitive card data. OzElements is the frontend SDK for the vault — it gives you drop-in card fields that are fully isolated inside Ozura-controlled iframes. Your page never sees raw card data, which keeps your PCI scope minimal (SAQ-A).
16
-
17
- **You don't need to be an OzuraPay merchant to use this.** You need vault credentials: a **public key** for the browser and the **vault API key** on your server (for minting wax only). Once you have a token, you can use it however you want — with your own processor, your own systems, or optionally with the Ozura Pay API.
18
-
19
- ---
20
-
21
- ## How it works
22
-
23
- ```
24
- Your page (yoursite.com)
25
-
26
- │ import { OzVault } from '@ozura/elements'
27
-
28
- const vault = await OzVault.create({ pubKey, fetchWaxKey });
29
-
30
- const cardNumber = vault.createElement('cardNumber', { style: { ... } });
31
- cardNumber.mount('#card-number');
32
- │ cardNumber.on('change', ({ valid, cardBrand }) => { ... });
33
-
34
- const { token, cvcSession } = await vault.createToken();
35
- // token is yours — use it with any system
36
-
37
- ├── <iframe src="https://elements.ozura.com/frame/element-frame.html">
38
- │ <input type="tel" /> ← your JS cannot access this
39
- │ </iframe>
40
-
41
- └── <iframe style="display:none" src="https://elements.ozura.com/frame/tokenizer-frame.html">
42
- Receives raw values from element iframes (same-origin).
43
- POSTs directly to vault /tokenize.
44
- Returns token + cvcSession to the SDK.
45
- </iframe>
46
- ```
47
-
48
- Raw card data travels one path: element iframe → tokenizer iframe (direct same-origin `postMessage`). Your SDK only ever sees metadata (valid/complete/cardBrand) and the final token.
49
-
50
- The tokenizer POSTs to the vault with **`X-Wax-Key`** (minted on your backend). The vault secret must **never** appear in the browser or in tokenize requests.
51
-
52
- ---
53
-
54
- ## Installation
55
-
56
- ```bash
57
- npm install @ozura/elements
58
- ```
59
-
60
- Or via script tag (UMD) from jsDelivr (served from npm after you publish):
61
-
62
- ```html
63
- <script src="https://cdn.jsdelivr.net/npm/@ozura/elements/dist/oz-elements.umd.js"></script>
64
- ```
65
-
66
- For production, pin a version: `.../npm/@ozura/elements@1.0.0/dist/oz-elements.umd.js`
67
-
68
- ---
69
-
70
- ## Quick start
71
-
72
- ```js
73
- import { OzVault } from '@ozura/elements';
74
-
75
- const vault = await OzVault.create({
76
- pubKey: 'your_pub_key',
77
- fetchWaxKey: async (sessionId) => {
78
- // Your backend mints a session-bound wax key using the vault secret
79
- const { waxKey } = await fetch('/api/mint-wax', {
80
- method: 'POST',
81
- headers: { 'Content-Type': 'application/json' },
82
- body: JSON.stringify({ sessionId }),
83
- }).then(r => r.json());
84
- return waxKey;
85
- },
86
- });
87
-
88
- const cardNumber = vault.createElement('cardNumber', {
89
- style: {
90
- base: { color: '#1a1a2e', fontSize: '16px', fontFamily: 'Inter, sans-serif' },
91
- focus: { color: '#1a1a2e' },
92
- invalid: { color: '#dc2626' },
93
- },
94
- });
95
-
96
- const expiry = vault.createElement('expirationDate');
97
- const cvv = vault.createElement('cvv');
98
-
99
- cardNumber.mount('#card-number');
100
- expiry.mount('#expiry');
101
- cvv.mount('#cvv');
102
-
103
- cardNumber.on('change', ({ valid, complete, cardBrand }) => {
104
- console.log(cardBrand); // 'visa' | 'mastercard' | 'amex' | ...
105
- });
106
-
107
- document.getElementById('tokenize').addEventListener('click', async () => {
108
- const { token, cvcSession } = await vault.createToken();
109
-
110
- console.log('Token:', token);
111
- console.log('CVC Session:', cvcSession);
112
-
113
- // The token is yours — send it to your backend and use it however you want:
114
- // With your own payment processor
115
- // With the Ozura Pay API (see "OzuraPay integration" below)
116
- // • With any system that accepts tokenized card data
117
- await fetch('/api/tokenized', {
118
- method: 'POST',
119
- headers: { 'Content-Type': 'application/json' },
120
- body: JSON.stringify({ token, cvcSession }),
121
- });
122
- });
123
- ```
124
-
125
- ---
126
-
127
- ## Wax keys (merchant integration)
128
-
129
- The vault `/tokenize` call must not use your vault secret from the browser. Instead:
130
-
131
- 1. During `OzVault.create()`, the SDK generates a `sessionId` (UUID) and calls **`fetchWaxKey(sessionId)`** once.
132
- 2. Your **`fetchWaxKey`** implementation should `POST` to **your** backend (e.g. `/api/mint-wax`) with that `sessionId`.
133
- 3. Your backend uses the **vault API key only on the server** to mint a short-lived **wax key** from the vault, then returns `{ waxKey }` to the browser.
134
- 4. The hidden tokenizer iframe sends **`X-Wax-Key`** (and related headers) to the vault — **never `X-API-Key`**.
135
-
136
- ### Vault mint contract (your backend → vault)
137
-
138
- Your server calls the vault (the SDK’s `Ozura.mintWaxKey` does this for you):
139
-
140
- | | |
141
- |---|---|
142
- | **Method / URL** | `POST {vaultBaseUrl}/internal/wax-session` |
143
- | **Auth** | `X-API-Key: <vault API key>` (server only) |
144
- | **Body** | `{ "checkout_session_id": "<sessionId>" }` — optional; correlates with the SDK session. `{}` is valid. |
145
- | **Success** | HTTP **201** · `{ "data": { "wax_key": "<uuid>", "expires_in_seconds": 1800 } }` (TTL is typically 30 minutes) |
146
- | **Errors** | JSON such as `{ "message", "error_code" }` — treat like any API error |
147
-
148
- If your vault hostname differs from the default used by published iframe builds, pass **`vaultUrl`** on `Ozura` so mint/revoke hit the correct host.
149
-
150
- ### One-line handlers (`@ozura/elements/server`)
151
-
152
- Both helpers accept **`sessionId`** or **`tokenizationSessionId`** in the JSON body. They respond with **`{ waxKey }`** on success.
153
-
154
- **Next.js App Router** (or any runtime with `Request` / `Response`):
155
-
156
- ```ts
157
- import { Ozura, createMintWaxHandler } from '@ozura/elements/server';
158
-
159
- const ozura = new Ozura({
160
- merchantId: process.env.MERCHANT_ID!,
161
- apiKey: process.env.MERCHANT_API_KEY!,
162
- vaultKey: process.env.VAULT_API_KEY!,
163
- // vaultUrl: process.env.VAULT_URL, // optional override
164
- });
165
-
166
- export const POST = createMintWaxHandler(ozura);
167
- ```
168
-
169
- **Express** (register JSON body parsing **before** the middleware):
170
-
171
- ```ts
172
- import express from 'express';
173
- import { Ozura, createMintWaxMiddleware } from '@ozura/elements/server';
174
-
175
- const app = express();
176
- const ozura = new Ozura({
177
- merchantId: process.env.MERCHANT_ID!,
178
- apiKey: process.env.MERCHANT_API_KEY!,
179
- vaultKey: process.env.VAULT_API_KEY!,
180
- });
181
-
182
- app.use(express.json());
183
- app.post('/api/mint-wax', createMintWaxMiddleware(ozura));
184
- ```
185
-
186
- ### Manual route
187
-
188
- Use this if you need to persist `waxKey` server-side for later revocation:
189
-
190
- ```ts
191
- app.post('/api/mint-wax', async (req, res) => {
192
- const sessionId = req.body?.sessionId ?? req.body?.tokenizationSessionId;
193
- const { waxKey, expiresInSeconds } = await ozura.mintWaxKey(
194
- typeof sessionId === 'string' && sessionId
195
- ? { tokenizationSessionId: sessionId }
196
- : {},
197
- );
198
- // await saveWaxForSession(sessionId, waxKey);
199
- res.json({ waxKey });
200
- });
201
- ```
202
-
203
- `mintWaxKey` throws **`OzuraError`** on failure (vault 4xx/5xx). The one-line handlers catch that and respond with **`{ error: string }`**: HTTP status is the vault status when ≥ 400, otherwise **502** (e.g. network failure).
204
-
205
- ### Browser: handle mint failures
206
-
207
- Check the response from your mint endpoint before calling `createToken`:
208
-
209
- ```js
210
- fetchWaxKey: async (sessionId) => {
211
- const res = await fetch('/api/mint-wax', {
212
- method: 'POST',
213
- headers: { 'Content-Type': 'application/json' },
214
- body: JSON.stringify({ sessionId }),
215
- });
216
- const data = await res.json().catch(() => ({}));
217
- if (!res.ok) {
218
- throw new Error(typeof data.error === 'string' ? data.error : 'Could not mint wax key');
219
- }
220
- if (!data.waxKey) throw new Error('Mint response missing waxKey');
221
- return data.waxKey;
222
- },
223
- ```
224
-
225
- ### Optional: revoke when the checkout session ends
226
-
227
- To shorten exposure before TTL, call **`await ozura.revokeWaxKey(waxKey)`** from your server when the session completes successfully, the user cancels, or your session times out. It is best-effort (does not throw). **Do not** revoke on card decline — the user may fix the card and call `createToken()` again without a full new `OzVault.create()`.
228
-
229
- ### Expired or consumed wax
230
-
231
- If tokenization fails with an auth-style error after the wax key expired or was consumed, create a **new** vault instance: `await OzVault.create({ pubKey, fetchWaxKey })` so `fetchWaxKey` runs again.
232
-
233
- ### More detail
234
-
235
- [WAX_KEY_IMPLEMENTATION.md](./WAX_KEY_IMPLEMENTATION.md) covers revocation scenarios, local dev-server behaviour, and security notes in depth.
236
-
237
- ---
238
-
239
- ## API
240
-
241
- ### `OzVault.create(options)`
242
-
243
- Returns `Promise<OzVault>`. The static factory is the only way to create a vault — the constructor is private.
244
-
245
- | Option | Type | Default | Description |
246
- |---|---|---|---|
247
- | `options.pubKey` | `string` | required | System pub key for the tokenize endpoint. Obtain from Ozura admin. |
248
- | `options.fetchWaxKey` | `(sessionId: string) => Promise<string>` | required | Called once during `create()`. Receives an SDK-generated session UUID; your implementation passes it to your backend, which mints a session-bound wax key from the vault using your vault secret. Returns the wax key string. |
249
- | `options.frameBaseUrl` | `string` | `https://elements.ozura.com` | Where iframe assets are served from |
250
- | `options.fonts` | `FontSource[]` | `[]` | Custom fonts to inject into element iframes |
251
- | `options.appearance` | `ElementStyleConfig` | — | Default style applied to all created elements |
252
- | `options.onLoadError` | `() => void` | — | Called if the tokenizer iframe fails to load within `loadTimeoutMs` |
253
- | `options.loadTimeoutMs` | `number` | `10000` | Timeout in ms before `onLoadError` fires |
254
-
255
- Implement **`/api/mint-wax`** on your server using `@ozura/elements/server` — see [Wax keys (merchant integration)](#wax-keys-merchant-integration) for Next.js, Express, manual mint, errors, and optional revocation.
256
-
257
- ### `vault.createElement(type, options?)`
258
-
259
- `type`: `'cardNumber'` | `'cvv'` | `'expirationDate'`
260
-
261
- | Option | Type | Description |
262
- |---|---|---|
263
- | `style.base` | `object` | CSS properties applied by default |
264
- | `style.focus` | `object` | CSS properties applied on focus |
265
- | `style.invalid` | `object` | CSS properties applied when input is invalid |
266
- | `placeholder` | `string` | Override default placeholder text |
267
- | `disabled` | `boolean` | Disable the input |
268
-
269
- ### `vault.createToken(options?)`
270
-
271
- Triggers tokenization across all mounted elements. Returns a Promise with the vault token.
272
-
273
- ```ts
274
- // Minimal — just tokenize the card data
275
- const { token, cvcSession } = await vault.createToken();
276
-
277
- // With billing validates and normalizes billing details alongside tokenization.
278
- // Useful if you plan to pass billing to a payment API (e.g. Ozura Pay API).
279
- const { token, cvcSession, billing } = await vault.createToken({
280
- billing: {
281
- firstName: string; // required, 1–50 chars
282
- lastName: string; // required, 1–50 chars
283
- email?: string; // valid address, max 50 chars
284
- phone?: string; // E.164 format e.g. "+15551234567"
285
- address?: {
286
- line1: string; // required if address provided
287
- line2?: string; // omitted from output if blank
288
- city: string;
289
- state: string; // "California" auto-normalised → "CA" for US/CA
290
- zip: string;
291
- country: string; // ISO 3166-1 alpha-2, e.g. "US"
292
- };
293
- };
294
-
295
- // Deprecated use billing.firstName/lastName instead:
296
- firstName?: string;
297
- lastName?: string;
298
- });
299
- ```
300
-
301
- `TokenResponse` fields:
302
-
303
- | Field | Description |
304
- |---|---|
305
- | `token` | Vault token referencing the stored card data |
306
- | `cvcSession` | CVC session ID for use with payment APIs |
307
- | `billing` | Validated, normalized billing details (only present when `billing` was passed) |
308
-
309
- **Validation** thrown as `OzError` before any network call: empty firstName/lastName, fields > 50 chars, invalid email format, non-E.164 phone, address fields 0 or > 50 chars.
310
-
311
- ### `vault.isReady`
312
-
313
- `boolean` `true` once the hidden tokenizer iframe has loaded. For React, use the `ready` value from `useOzElements()` instead (which also tracks field iframe readiness).
314
-
315
- ### `vault.destroy()`
316
-
317
- Tears down the vault completely: removes the global message listener, rejects any in-flight `createToken()` promises, unmounts all element iframes, and removes the tokenizer iframe. Call this when your checkout component unmounts.
318
-
319
- ```js
320
- // React useEffect cleanup / SPA route change
321
- return () => vault.destroy();
322
- ```
323
-
324
- ### `element.mount(target)`
325
-
326
- Injects the element iframe into a container. Accepts either a CSS selector string or a direct `HTMLElement` reference (required when using React refs).
327
-
328
- ```js
329
- element.mount('#card-number'); // CSS selector
330
- element.mount(containerRef.current); // HTMLElement (React)
331
- ```
332
-
333
- ### `element.unmount()`
334
-
335
- Removes the iframe from the DOM and resets internal state. Called automatically by `vault.destroy()`. Safe to call manually when swapping payment methods in a SPA.
336
-
337
- ### `element.on(event, callback)`
338
-
339
- | Event | Payload | Description |
340
- |---|---|---|
341
- | `ready` | | Iframe loaded and initialised |
342
- | `change` | `ElementChangeEvent` | Input value changed |
343
- | `focus` | — | Input focused |
344
- | `blur` | — | Input blurred |
345
-
346
- **`ElementChangeEvent` shape:**
347
-
348
- | Field | Type | Present on |
349
- |---|---|---|
350
- | `empty` | `boolean` | all elements |
351
- | `complete` | `boolean` | all elements |
352
- | `valid` | `boolean` | all elements |
353
- | `error` | `string \| undefined` | all set when complete but invalid |
354
- | `cardBrand` | `string \| undefined` | `cardNumber` only |
355
- | `month` | `string \| undefined` | `expirationDate` only (`"01"`–`"12"`) |
356
- | `year` | `string \| undefined` | `expirationDate` only (2-digit, e.g. `"27"`) |
357
-
358
- ### `element.clear()`
359
-
360
- Clears the current value in the iframe input and resets validation state.
361
-
362
- ### `element.update(options)`
363
-
364
- Updates `style`, `placeholder`, or `disabled` on a mounted element without remounting the iframe.
365
-
366
- ---
367
-
368
- ## Server SDK (`@ozura/elements/server`)
369
-
370
- Import `Ozura` and optional wax helpers from `@ozura/elements/server`:
371
-
372
- | Export | Role |
373
- |--------|------|
374
- | `new Ozura({ merchantId, apiKey, vaultKey, apiUrl?, vaultUrl?, … })` | Pay API (`cardSale`, `listTransactions`) and vault wax calls |
375
- | `ozura.mintWaxKey({ tokenizationSessionId? })` | `POST /internal/wax-session` — implement or use helpers below |
376
- | `ozura.revokeWaxKey(waxKey)` | Best-effort revoke when the checkout session ends |
377
- | `createMintWaxHandler(ozura)` | Fetch API handler (Next.js App Router, Workers, Edge) → `{ waxKey }` |
378
- | `createMintWaxMiddleware(ozura)` | Express / Connect middleware → `{ waxKey }` |
379
- | `getClientIp(req)` | Helper for `cardSale` client IP |
380
-
381
- See [Wax keys (merchant integration)](#wax-keys-merchant-integration) in this README for vault contract, examples, and client error handling. Extra depth (dev server, threat notes): [WAX_KEY_IMPLEMENTATION.md](./WAX_KEY_IMPLEMENTATION.md).
382
-
383
- ---
384
-
385
- ## React
386
-
387
- ```bash
388
- npm install @ozura/elements
389
- ```
390
-
391
- Import from the `/react` subpath:
392
-
393
- ```tsx
394
- import { OzElements, OzCardNumber, OzExpiry, OzCvv, useOzElements } from '@ozura/elements/react';
395
- ```
396
-
397
- ### Example
398
-
399
- ```tsx
400
- import { useState } from 'react';
401
- import { OzElements, OzCardNumber, OzExpiry, OzCvv, useOzElements } from '@ozura/elements/react';
402
- import { normalizeCardSaleError } from '@ozura/elements';
403
-
404
- const fieldStyle = {
405
- base: { color: '#111827', fontSize: '16px', fontFamily: 'Inter, sans-serif' },
406
- focus: { color: '#111827' },
407
- invalid: { color: '#dc2626' },
408
- };
409
-
410
- function CheckoutForm() {
411
- const { createToken, ready } = useOzElements();
412
-
413
- const [form, setForm] = useState({
414
- firstName: '', lastName: '', email: '', phone: '',
415
- address1: '', city: '', state: '', zip: '', country: 'US',
416
- });
417
- const [error, setError] = useState('');
418
-
419
- const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
420
- setForm(f => ({ ...f, [field]: e.target.value }));
421
-
422
- const handlePay = async () => {
423
- setError('');
424
- try {
425
- const { token, cvcSession, billing } = await createToken({
426
- billing: {
427
- firstName: form.firstName,
428
- lastName: form.lastName,
429
- email: form.email || undefined,
430
- phone: form.phone || undefined,
431
- ...(form.address1 ? {
432
- address: {
433
- line1: form.address1,
434
- city: form.city,
435
- state: form.state,
436
- zip: form.zip,
437
- country: form.country.toUpperCase(),
438
- },
439
- } : {}),
440
- },
441
- });
442
-
443
- // Send token + cvcSession + billing to your server → Ozura Pay API cardSale
444
- const res = await fetch('/api/charge', {
445
- method: 'POST',
446
- headers: { 'Content-Type': 'application/json' },
447
- body: JSON.stringify({ token, cvcSession, billing, amount: '49.00', currency: 'USD' }),
448
- });
449
-
450
- if (!res.ok) {
451
- const data = await res.json();
452
- setError(normalizeCardSaleError(data.error ?? 'Payment failed'));
453
- }
454
- } catch (err: unknown) {
455
- setError(err instanceof Error ? err.message : 'An error occurred');
456
- }
457
- };
458
-
459
- return (
460
- <div>
461
- {/* Billing fields — plain inputs, not PCI-sensitive */}
462
- <input placeholder="First name" value={form.firstName} onChange={set('firstName')} />
463
- <input placeholder="Last name" value={form.lastName} onChange={set('lastName')} />
464
- <input placeholder="Email" value={form.email} onChange={set('email')} />
465
- <input placeholder="Phone (+15551234567)" value={form.phone} onChange={set('phone')} />
466
- <input placeholder="Street address" value={form.address1} onChange={set('address1')} />
467
- <input placeholder="City" value={form.city} onChange={set('city')} />
468
- <input placeholder="State" value={form.state} onChange={set('state')} />
469
- <input placeholder="ZIP" value={form.zip} onChange={set('zip')} />
470
- <input placeholder="Country (US)" value={form.country} onChange={set('country')} maxLength={2} />
471
-
472
- {/* Card fields — iframe-isolated, raw card data never touches this component */}
473
- <OzCardNumber style={fieldStyle} />
474
- <OzExpiry style={fieldStyle} />
475
- <OzCvv style={fieldStyle} />
476
-
477
- {error && <p style={{ color: 'red' }}>{error}</p>}
478
- <button onClick={handlePay} disabled={!ready}>Pay $49.00</button>
479
- </div>
480
- );
481
- }
482
-
483
- export default function App() {
484
- return (
485
- <OzElements
486
- pubKey="your_pub_key"
487
- fetchWaxKey={async (sessionId) => {
488
- const { waxKey } = await fetch('/api/mint-wax', {
489
- method: 'POST',
490
- headers: { 'Content-Type': 'application/json' },
491
- body: JSON.stringify({ sessionId }),
492
- }).then(r => r.json());
493
- return waxKey;
494
- }}
495
- frameBaseUrl="https://elements.ozura.com"
496
- >
497
- <CheckoutForm />
498
- </OzElements>
499
- );
500
- }
501
- ```
502
-
503
- ### `<OzElements>` props
504
-
505
- | Prop | Type | Description |
506
- |---|---|---|
507
- | `fetchWaxKey` | `(sessionId: string) => Promise<string>` | Called once on mount. Pass the received `sessionId` to your backend; your backend mints a wax key from the vault and returns it. |
508
- | `pubKey` | `string` | System pub key for the tokenize endpoint |
509
- | `frameBaseUrl` | `string?` | Where iframe assets are hosted |
510
- | `fonts` | `FontSource[]?` | Custom fonts to inject into element iframes |
511
- | `onLoadError` | `() => void?` | Called if vault init fails (fetchWaxKey error or tokenizer iframe timeout) |
512
- | `loadTimeoutMs` | `number?` | Timeout before `onLoadError` fires (default: 10 000) |
513
- | `appearance` | `Appearance?` | Global theme / variable overrides |
514
-
515
- ### `useOzElements()` return
516
-
517
- | Value | Type | Description |
518
- |---|---|---|
519
- | `createToken` | `(opts?) => Promise<{ token, cvcSession }>` | Tokenizes all mounted fields |
520
- | `ready` | `boolean` | `true` when all field iframes have loaded |
521
-
522
- ### Field component props (`OzCardNumber`, `OzExpiry`, `OzCvv`)
523
-
524
- | Prop | Type | Description |
525
- |---|---|---|
526
- | `style` | `ElementStyleConfig?` | `{ base, focus, invalid }` CSS property objects |
527
- | `placeholder` | `string?` | Override default placeholder |
528
- | `disabled` | `boolean?` | Disable the input |
529
- | `onChange` | `(e: ElementChangeEvent) => void` | Keystroke callback |
530
- | `onFocus` | `() => void` | Focus callback |
531
- | `onBlur` | `() => void` | Blur callback |
532
- | `onReady` | `() => void` | Iframe ready callback |
533
- | `className` | `string?` | CSS class on the wrapper `<div>` |
534
-
535
- ---
536
-
537
- ## OzuraPay Integration (Optional)
538
-
539
- If you're an OzuraPay merchant and want to charge cards through Ozura's processors, you can use your vault tokens with the Ozura Pay API. **You must be onboarded as an OzuraPay merchant first** — this gives you a merchant ID and a Pay API key, which are required alongside your vault API key.
540
-
541
- | Credential | Where it goes |
542
- |-----------|---------------|
543
- | Vault API key | **Server only.** Browser calls `fetchWaxKey` → your backend mints a wax key with this secret; never send the secret in the browser. Pay API `cardSale` also sends it as `vault-api-key` from the server. |
544
- | Pay API key | Backend only: `x-api-key` header |
545
- | Merchant ID | Backend only: `merchantId` in request body |
546
-
547
- For the full credential setup, billing form field requirements, validation rules, and constraints, see [DOCS-REFRAME.md](./DOCS-REFRAME.md#using-tokens-with-ozurapay--prerequisites-and-credentials).
548
-
549
- ### 1 — Collect card data + billing in one call
550
-
551
- ```js
552
- import { OzVault, normalizeCardSaleError } from '@ozura/elements';
553
-
554
- const vault = await OzVault.create({
555
- pubKey: 'your_pub_key',
556
- fetchWaxKey: async (sessionId) => {
557
- const { waxKey } = await fetch('/api/mint-wax', {
558
- method: 'POST',
559
- headers: { 'Content-Type': 'application/json' },
560
- body: JSON.stringify({ sessionId }),
561
- }).then(r => r.json());
562
- return waxKey;
563
- },
564
- });
565
-
566
- // mount card elements
567
-
568
- async function handlePay(formValues) {
569
- let tokenResult;
570
- try {
571
- tokenResult = await vault.createToken({
572
- billing: {
573
- firstName: formValues.firstName, // validated 1–50 chars
574
- lastName: formValues.lastName,
575
- email: formValues.email, // validated email format
576
- phone: formValues.phone, // must be E.164: "+15551234567"
577
- address: {
578
- line1: formValues.address1,
579
- line2: formValues.address2, // omitted from output if blank
580
- city: formValues.city,
581
- state: formValues.state, // "California" → "CA" auto-normalized
582
- zip: formValues.zip,
583
- country: formValues.country, // "US", "CA", "GB" …
584
- },
585
- },
586
- });
587
- } catch (err) {
588
- // Validation errors thrown here show directly to user
589
- showError(err.message);
590
- return;
591
- }
592
-
593
- const { token, cvcSession, billing } = tokenResult;
594
-
595
- // 2 Fetch client IP from your own server (never api.ipify.org ad-blockers block it)
596
- const ipRes = await fetch('/api/client-ip');
597
- const { ip } = await ipRes.json();
598
-
599
- // 3 POST to your server, which calls cardSale
600
- const res = await fetch('/api/charge', {
601
- method: 'POST',
602
- headers: { 'Content-Type': 'application/json' },
603
- body: JSON.stringify({
604
- ozuraCardToken: token,
605
- ozuraCvcSession: cvcSession,
606
- billingFirstName: billing.firstName,
607
- billingLastName: billing.lastName,
608
- billingEmail: billing.email,
609
- billingPhone: billing.phone, // already E.164
610
- billingAddress1: billing.address.line1,
611
- billingAddress2: billing.address.line2, // omit key if undefined
612
- billingCity: billing.address.city,
613
- billingState: billing.address.state, // already 2-letter
614
- billingZipcode: billing.address.zip,
615
- billingCountry: billing.address.country,
616
- clientIpAddress: ip,
617
- amount: '49.00',
618
- currency: 'USD',
619
- salesTaxExempt: false,
620
- surchargePercent: '0.00',
621
- tipAmount: '0.00',
622
- }),
623
- });
624
-
625
- const data = await res.json();
626
-
627
- if (!res.ok) {
628
- showError(normalizeCardSaleError(data.error ?? res.statusText));
629
- } else {
630
- showSuccess(data.transactionId);
631
- }
632
- }
633
- ```
634
-
635
- ### 2 — Server-side cardSale call
636
-
637
- Your server proxies to the Ozura Pay API with **two API keys** in the headers:
638
-
639
- ```js
640
- // Node.js / Express example
641
- import type { CardSaleRequest, CardSaleApiResponse } from '@ozura/elements';
642
-
643
- app.post('/api/charge', async (req, res) => {
644
- const body = req.body;
645
-
646
- const payload: CardSaleRequest = {
647
- processor: 'elavon',
648
- merchantId: process.env.MERCHANT_ID,
649
- amount: body.amount,
650
- currency: body.currency,
651
- ozuraCardToken: body.ozuraCardToken,
652
- ozuraCvcSession: body.ozuraCvcSession,
653
- billingFirstName: body.billingFirstName,
654
- billingLastName: body.billingLastName,
655
- billingEmail: body.billingEmail,
656
- billingPhone: body.billingPhone,
657
- billingAddress1: body.billingAddress1,
658
- ...(body.billingAddress2 ? { billingAddress2: body.billingAddress2 } : {}),
659
- billingCity: body.billingCity,
660
- billingState: body.billingState,
661
- billingZipcode: body.billingZipcode,
662
- billingCountry: body.billingCountry,
663
- clientIpAddress: body.clientIpAddress,
664
- salesTaxExempt: body.salesTaxExempt,
665
- surchargePercent: '0.00',
666
- tipAmount: '0.00',
667
- };
668
-
669
- const response = await fetch(`${process.env.OZURA_API_URL}/api/v1/cardSale`, {
670
- method: 'POST',
671
- headers: {
672
- 'Content-Type': 'application/json',
673
- 'x-api-key': process.env.MERCHANT_API_KEY, // merchant Pay API key
674
- 'vault-api-key': process.env.VAULT_API_KEY, // same key used in OzVault
675
- },
676
- body: JSON.stringify(payload),
677
- signal: AbortSignal.timeout(30_000),
678
- });
679
-
680
- if (!response.ok) {
681
- const err = await response.json().catch(() => ({}));
682
- // Pass err.error through normalizeCardSaleError on the client side
683
- return res.status(response.status).json({ error: err.error ?? 'cardSale failed' });
684
- }
685
-
686
- const result: CardSaleApiResponse = await response.json();
687
-
688
- if (!result.success) {
689
- return res.status(402).json({ error: 'Payment was not successful' });
690
- }
691
-
692
- const txn = result.data;
693
-
694
- // ⚠️ Store txn.transactionId in your database NOW — before responding to the client.
695
- // You need it for refunds and to prevent duplicate charges on network retry.
696
- await db.transactions.insertOne({
697
- transactionId: txn.transactionId,
698
- amount: txn.amount,
699
- currency: txn.currency,
700
- cardLastFour: txn.cardLastFour,
701
- cardBrand: txn.cardBrand,
702
- cardExpMonth: txn.cardExpMonth,
703
- cardExpYear: txn.cardExpYear,
704
- billingName: `${txn.billingFirstName} ${txn.billingLastName}`,
705
- transDate: txn.transDate,
706
- createdAt: new Date(),
707
- });
708
-
709
- res.json({ transactionId: txn.transactionId, cardLastFour: txn.cardLastFour });
710
- });
711
- ```
712
-
713
- ### 3 — cardSale response shape
714
-
715
- ```ts
716
- import type { CardSaleApiResponse, CardSaleResponseData } from '@ozura/elements';
717
-
718
- // Successful response shape from POST /api/v1/cardSale:
719
- // {
720
- // success: true,
721
- // data: {
722
- // transactionId: "txn_...", ← store this immediately
723
- // amount: "49.00",
724
- // currency: "USD",
725
- // surchargeAmount:"0.00",
726
- // tipAmount: "0.00",
727
- // cardLastFour: "4242",
728
- // cardExpMonth: "09",
729
- // cardExpYear: "2027", 4-digit year
730
- // cardBrand: "visa",
731
- // transDate: "2026-02-20T...",
732
- // ozuraMerchantId:"...",
733
- // billingFirstName, billingLastName, billingEmail, billingPhone,
734
- // billingAddress1, billingAddress2?, billingCity, billingState,
735
- // billingZipcode, billingCountry
736
- // }
737
- // }
738
- //
739
- // Error response (4xx/5xx): { error: string }
740
- // → pass error to normalizeCardSaleError() for a user-facing message
741
- ```
742
-
743
- ### 3 — Client IP endpoint
744
-
745
- The Pay API requires a real IP address. Fetch it server-side — do **not** use `api.ipify.org` from the client (blocked by most ad-blockers):
746
-
747
- ```js
748
- app.get('/api/client-ip', (req, res) => {
749
- const ip =
750
- req.headers['x-forwarded-for']?.split(',')[0].trim() ??
751
- req.socket.remoteAddress ??
752
- '0.0.0.0';
753
- res.json({ ip });
754
- });
755
- ```
756
-
757
- ### `normalizeCardSaleError(raw)`
758
-
759
- Maps raw Pay API error strings to user-friendly copy. Uses the same key table as the Ozura checkout frontend — identical errors produce identical messages on both surfaces:
760
-
761
- ```js
762
- import { normalizeCardSaleError } from '@ozura/elements';
763
-
764
- normalizeCardSaleError('Insufficient Funds')
765
- // → "Your card has insufficient funds. Please use a different payment method."
766
-
767
- normalizeCardSaleError('CVV Verification Failed')
768
- // "The CVV code you entered is incorrect. Please check and try again."
769
-
770
- normalizeCardSaleError('Card expired')
771
- // → "Your card has expired. Please use a different card."
772
- ```
773
-
774
- ### TypeScript: `CardSaleRequest`
775
-
776
- Import the interface to type your cardSale payload and catch missing fields at compile time:
777
-
778
- ```ts
779
- import type { CardSaleRequest } from '@ozura/elements';
780
-
781
- const payload: CardSaleRequest = {
782
- processor: 'elavon',
783
- merchantId: '…',
784
- amount: '49.00',
785
- currency: 'USD',
786
- ozuraCardToken: token,
787
- ozuraCvcSession: cvcSession,
788
- billingFirstName: billing.firstName,
789
- billingLastName: billing.lastName,
790
- billingEmail: billing.email!,
791
- billingPhone: billing.phone!,
792
- billingAddress1: billing.address!.line1,
793
- billingCity: billing.address!.city,
794
- billingState: billing.address!.state,
795
- billingZipcode: billing.address!.zip,
796
- billingCountry: billing.address!.country,
797
- clientIpAddress: ip,
798
- salesTaxExempt: false,
799
- surchargePercent: '0.00',
800
- tipAmount: '0.00',
801
- };
802
- ```
803
-
804
- ---
805
-
806
- ## Testing
807
-
808
- ```bash
809
- npm test # run once
810
- npm run test:watch # watch mode
811
- npm run test:coverage # with V8 coverage report
812
- ```
813
-
814
- The test suite covers: Luhn algorithm, card brand detection, card number formatting, expiry validation, and error normalisation.
815
-
816
- ---
817
-
818
- ## Development
819
-
820
- ```bash
821
- npm install
822
- npm run dev # build + serve demo at http://localhost:4242/demo/index.html
823
- npm run watch # rebuild on save
824
- npm run build # production build
825
- ```
826
-
827
- Build outputs in `dist/`:
828
-
829
- | File | Format | Use |
830
- |---|---|---|
831
- | `oz-elements.esm.js` | ESM | `import { OzVault } from '@ozura/elements'` |
832
- | `oz-elements.umd.js` | UMD | `<script>` tag / CDN |
833
- | `frame/element-frame.js` | IIFE | loaded by element iframes |
834
- | `frame/tokenizer-frame.js` | IIFE | loaded by tokenizer iframe |
835
- | `react/index.esm.js` | ESM | `import ... from '@ozura/elements/react'` |
836
- | `react/index.cjs.js` | CJS | `require('@ozura/elements/react')` |
837
- | `server/index.*.js` | ESM + CJS | `import { Ozura, createMintWaxHandler } from '@ozura/elements/server'` |
838
-
839
- Maintainer notes: [DEVELOPMENT.md](./DEVELOPMENT.md). Merchants: [Wax keys (merchant integration)](#wax-keys-merchant-integration) in this file; supplementary detail in [WAX_KEY_IMPLEMENTATION.md](./WAX_KEY_IMPLEMENTATION.md).
840
-
841
- ---
842
-
843
- ## Roadmap
844
-
845
- ### v0.1 — Core SDK ✅
846
- - [x] Iframe-isolated card number, CVV, and expiration date elements
847
- - [x] Luhn validation, real-time formatting, card brand detection
848
- - [x] postMessage protocol between host SDK and iframes
849
- - [x] Tokenizer iframe — assembles card data and POSTs directly to vault
850
- - [x] Style customization (base / focus / invalid states)
851
- - [x] Rollup build — ESM, UMD, and IIFE frame bundles
852
- - [x] Working demo page
853
- - [x] CORS resolved for dev environment (local proxy server)
854
-
855
- ### v0.2 Developer experience
856
- - [x] Card brand SVG icon inside the card number field (updates in real time)
857
- - [x] Auto-advance focus: card number → expiry → CVV on completion
858
- - [x] `element.clear()`, `element.update()`, `element.unmount()` fully wired
859
- - [x] Field-level error strings in change events
860
- - [x] `OzVault.destroy()` for full lifecycle cleanup
861
- - [x] `OzVault.isReady` getter
862
- - [x] `element.mount()` accepts `HTMLElement` as well as a CSS selector
863
- - [x] Unit test suite (Vitest + jsdom) — 168 tests, 0 failures
864
- - [ ] Published to npm as `@ozura/elements`
865
- - [ ] CDN delivery for UMD build (optional for v1)
866
-
867
- ### v0.3 — React ✅
868
- - [x] `<OzElements>` provider — creates and owns an `OzVault` instance
869
- - [x] `<OzCardNumber />`, `<OzExpiry />`, `<OzCvv />` field components
870
- - [x] `useOzElements()` hook — `createToken` + `ready` flag
871
- - [x] ESM + CJS React bundle, exported at `@ozura/elements/react`
872
- - [ ] Published as a standalone `@oz/react-elements` package (optional; subpath export is sufficient for v1)
873
-
874
- ### v1.0 — Production ready
875
- - [x] Production iframe hosting at `elements.ozura.com`
876
- - [x] postMessage security covered (unit + E2E tests; see `docs/AUDIT.md`)
877
- - [x] Security audit (consolidated in `docs/AUDIT.md`; all findings resolved or closed)
878
- - [ ] Publish `@ozura/elements@1.0.0` to npm
879
- - [ ] Public documentation site (or link to ozura-documentation)
880
- - [ ] ACH / bank account element (pending Pay API support) — post-v1
881
- - [ ] Saved payment method support (returning customers) — post-v1
882
-
883
- ---
884
-
885
- ## License
886
-
887
- MIT Copyright 2025 Ozura
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 so the onReady callback isn't in a TDZ
153
+ let readyCount = 0;
154
+ let tokenizerIsReady = false;
155
+
156
+ function checkReady() {
157
+ if (readyCount === 3 && tokenizerIsReady) enablePayButton();
158
+ }
159
+
160
+ const vault = await OzVault.create({
161
+ pubKey: 'pk_live_...',
162
+ fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
163
+ onReady: () => { tokenizerIsReady = true; checkReady(); }, // tokenizer iframe loaded
164
+ });
165
+
166
+ const cardNumberEl = vault.createElement('cardNumber');
167
+ const expiryEl = vault.createElement('expirationDate');
168
+ const cvvEl = vault.createElement('cvv');
169
+
170
+ cardNumberEl.mount('#card-number');
171
+ expiryEl.mount('#expiry');
172
+ cvvEl.mount('#cvv');
173
+
174
+ [cardNumberEl, expiryEl, cvvEl].forEach(el => {
175
+ el.on('ready', () => { readyCount++; checkReady(); });
176
+ });
177
+
178
+ async function pay() {
179
+ const { token, cvcSession, card } = await vault.createToken({
180
+ billing: { firstName: 'Jane', lastName: 'Smith' },
181
+ });
182
+ // POST { token, cvcSession } to your server
183
+ }
184
+
185
+ // Clean up when done
186
+ vault.destroy();
187
+ ```
188
+
189
+ > **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.
190
+
191
+ ---
192
+
193
+ ## Server setup
194
+
195
+ ### Wax key endpoint
196
+
197
+ The SDK calls your `fetchWaxKey` function with a `tokenizationSessionId` UUID. Your backend must exchange it for a wax key from the vault.
198
+
199
+ **Next.js App Router (recommended)**
200
+
201
+ ```ts
202
+ // app/api/mint-wax/route.ts
203
+ import { Ozura, createMintWaxHandler } from '@ozura/elements/server';
204
+
205
+ const ozura = new Ozura({
206
+ merchantId: process.env.MERCHANT_ID!,
207
+ apiKey: process.env.MERCHANT_API_KEY!,
208
+ vaultKey: process.env.VAULT_API_KEY!,
209
+ });
210
+
211
+ export const POST = createMintWaxHandler(ozura);
212
+ ```
213
+
214
+ **Express**
215
+
216
+ ```ts
217
+ import express from 'express';
218
+ import { Ozura, createMintWaxMiddleware } from '@ozura/elements/server';
219
+
220
+ const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
221
+ const app = express();
222
+
223
+ app.use(express.json());
224
+ app.post('/api/mint-wax', createMintWaxMiddleware(ozura));
225
+ ```
226
+
227
+ **Manual implementation**
228
+
229
+ ```ts
230
+ // POST /api/mint-wax
231
+ const { sessionId } = await req.json();
232
+ const { waxKey } = await ozura.mintWaxKey({
233
+ tokenizationSessionId: sessionId,
234
+ maxTokenizeCalls: 3, // vault enforces this limit; must match VaultOptions.maxTokenizeCalls on the client
235
+ });
236
+ return Response.json({ waxKey });
237
+ ```
238
+
239
+ ### Card sale endpoint
240
+
241
+ After `createToken()` resolves on the frontend, POST `{ token, cvcSession, billing }` to your server to charge the card.
242
+
243
+ **Next.js App Router**
244
+
245
+ ```ts
246
+ // app/api/charge/route.ts
247
+ import { Ozura, createCardSaleHandler } from '@ozura/elements/server';
248
+
249
+ const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
250
+
251
+ export const POST = createCardSaleHandler(ozura, {
252
+ getAmount: async (body) => {
253
+ const order = await db.orders.findById(body.orderId as string);
254
+ return order.total; // decimal string, e.g. "49.00"
255
+ },
256
+ // getCurrency: async (body) => 'USD', // optional, defaults to "USD"
257
+ });
258
+ ```
259
+
260
+ **Express**
261
+
262
+ ```ts
263
+ app.post('/api/charge', createCardSaleMiddleware(ozura, {
264
+ getAmount: async (body) => {
265
+ const order = await db.orders.findById(body.orderId as string);
266
+ return order.total; // decimal string, e.g. "49.00"
267
+ },
268
+ // getCurrency: async (body) => 'USD', // optional, defaults to "USD"
269
+ }));
270
+ ```
271
+
272
+ > `createCardSaleMiddleware` always terminates the request — it does not call `next()` and cannot be composed in a middleware chain.
273
+
274
+ **Manual implementation**
275
+
276
+ ```ts
277
+ const { token, cvcSession, billing } = await req.json();
278
+ const result = await ozura.cardSale({
279
+ token,
280
+ cvcSession,
281
+ amount: '49.00',
282
+ currency: 'USD',
283
+ billing,
284
+ // Take the first IP from the forwarded-for chain; fall back to socket address.
285
+ // req is a Fetch API Request (Next.js App Router / Vercel Edge).
286
+ clientIpAddress: req.headers.get('x-forwarded-for')?.split(',')[0].trim()
287
+ ?? req.headers.get('x-real-ip')
288
+ ?? '',
289
+ });
290
+ // result.transactionId, result.amount, result.cardLastFour, result.cardBrand
291
+ ```
292
+
293
+ ---
294
+
295
+ ## Vanilla JS API
296
+
297
+ ### OzVault.create(options)
298
+
299
+ ```ts
300
+ const vault = await OzVault.create(options: VaultOptions): Promise<OzVault>
301
+ ```
302
+
303
+ 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.
304
+
305
+ | Option | Type | Required | Description |
306
+ |---|---|---|---|
307
+ | `pubKey` | `string` | | Your public key from the Ozura admin. |
308
+ | `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. |
309
+ | `frameBaseUrl` | `string` | | Base URL for iframe assets. Defaults to production CDN. Override for local dev (see [Local development](#local-development)). |
310
+ | `fonts` | `FontSource[]` | — | Custom fonts to inject into all element iframes. |
311
+ | `appearance` | `Appearance` | — | Global theme and variable overrides. |
312
+ | `loadTimeoutMs` | `number` | — | Tokenizer iframe load timeout in ms. Default: `10000`. Only takes effect when `onLoadError` is also provided. |
313
+ | `onLoadError` | `() => void` | | Called if the tokenizer iframe fails to load within `loadTimeoutMs`. |
314
+ | `onWaxRefresh` | `() => void` | — | Called when the SDK silently re-mints an expired wax key mid-tokenization. |
315
+ | `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. |
316
+ | `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. |
317
+
318
+ Throws `OzError` if `fetchWaxKey` rejects, returns an empty string, or returns a non-string value.
319
+
320
+ > **`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`.
321
+
322
+ ---
323
+
324
+ ### vault.createElement(type, options?)
325
+
326
+ ```ts
327
+ vault.createElement(type: ElementType, options?: ElementOptions): OzElement
328
+ ```
329
+
330
+ Creates and returns an element iframe. Call `.mount(target)` to attach it to the DOM.
331
+
332
+ `ElementType`: `'cardNumber'` | `'cvv'` | `'expirationDate'`
333
+
334
+ ```ts
335
+ const cardEl = vault.createElement('cardNumber', {
336
+ placeholder: '1234 5678 9012 3456',
337
+ style: {
338
+ base: { color: '#1a1a1a', fontSize: '16px' },
339
+ focus: { borderColor: '#6366f1' },
340
+ invalid: { color: '#dc2626' },
341
+ complete: { color: '#16a34a' },
342
+ },
343
+ });
344
+
345
+ cardEl.mount('#card-number-container');
346
+ ```
347
+
348
+ `ElementOptions`:
349
+
350
+ | Option | Type | Description |
351
+ |---|---|---|
352
+ | `style` | `ElementStyleConfig` | Per-state style overrides. See [Styling](#styling). |
353
+ | `placeholder` | `string` | Placeholder text (max 100 characters). |
354
+ | `disabled` | `boolean` | Disables the input. |
355
+ | `loadTimeoutMs` | `number` | Iframe load timeout in ms. Default: `10000`. |
356
+
357
+ **OzElement methods:**
358
+
359
+ | Method | Description |
360
+ |---|---|
361
+ | `.mount(target)` | Mount the iframe. Accepts a CSS selector string or `HTMLElement`. |
362
+ | `.unmount()` | Remove the iframe from the DOM. The element can be re-mounted. |
363
+ | `.destroy()` | Permanently destroy the element. Cannot be re-mounted. |
364
+ | `.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. |
365
+ | `.clear()` | Clear the field value. |
366
+ | `.focus()` | Programmatically focus the input. |
367
+ | `.blur()` | Programmatically blur the input. |
368
+ | `.on(event, fn)` | Subscribe to an event. Returns `this` for chaining. |
369
+ | `.off(event, fn)` | Remove an event handler. |
370
+ | `.once(event, fn)` | Subscribe for a single invocation. |
371
+ | `.isReady` | `true` once the iframe has loaded and signalled ready. |
372
+
373
+ ---
374
+
375
+ ### vault.createToken(options?)
376
+
377
+ ```ts
378
+ vault.createToken(options?: TokenizeOptions): Promise<TokenResponse>
379
+ ```
380
+
381
+ 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.
382
+
383
+ Returns a `TokenResponse`:
384
+
385
+ ```ts
386
+ interface TokenResponse {
387
+ token: string; // Vault token — pass to cardSale
388
+ cvcSession?: string; // CVC session — pass to cardSale
389
+ card?: {
390
+ last4: string; // e.g. "4242"
391
+ brand: string; // e.g. "visa"
392
+ expMonth: string; // e.g. "09"
393
+ expYear: string; // e.g. "2027"
394
+ };
395
+ billing?: BillingDetails; // Normalized billing — only present if billing was passed in
396
+ }
397
+ ```
398
+
399
+ > **`cvcSession` is always present on a successful tokenization.** Although the TypeScript type marks it optional, `createToken()` rejects before returning if `cvcSession` is absent — this would indicate a vault misconfiguration. Always forward both `token` and `cvcSession` to your charge endpoint.
400
+
401
+ `TokenizeOptions`:
402
+
403
+ | Option | Type | Description |
404
+ |---|---|---|
405
+ | `billing` | `BillingDetails` | Validated and normalized billing details. Returned in `TokenResponse.billing`. |
406
+ | `firstName` | `string` | **Deprecated.** Pass inside `billing` instead. |
407
+ | `lastName` | `string` | **Deprecated.** Pass inside `billing` instead. |
408
+
409
+ Throws `OzError` if:
410
+ - The vault is not ready (`errorCode: 'unknown'`)
411
+ - A tokenization is already in progress
412
+ - Billing validation fails (`errorCode: 'validation'`)
413
+ - No elements are mounted
414
+ - The vault returns an error (`errorCode` reflects the HTTP status)
415
+ - The request times out after 30 seconds (`errorCode: 'timeout'`) this timeout is separate from `loadTimeoutMs` and is not configurable
416
+
417
+ **`vault.tokenizeCount`**
418
+
419
+ ```ts
420
+ vault.tokenizeCount: number // read-only getter
421
+ ```
422
+
423
+ 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:
424
+
425
+ ```ts
426
+ const MAX = 3; // matches maxTokenizeCalls
427
+ const remaining = MAX - vault.tokenizeCount;
428
+ payButton.textContent = `Pay (${remaining} attempt${remaining === 1 ? '' : 's'} remaining)`;
429
+ ```
430
+
431
+ In React, use `tokenizeCount` from `useOzElements()` instead — it is a reactive state value and will trigger re-renders automatically.
432
+
433
+ ---
434
+
435
+ ### vault.createBankElement()
436
+
437
+ ```ts
438
+ vault.createBankElement(type: BankElementType, options?: ElementOptions): OzElement
439
+ ```
440
+
441
+ Creates a bank account element. `BankElementType`: `'accountNumber'` | `'routingNumber'`.
442
+
443
+ ```ts
444
+ const accountEl = vault.createBankElement('accountNumber');
445
+ const routingEl = vault.createBankElement('routingNumber');
446
+ accountEl.mount('#account-number');
447
+ routingEl.mount('#routing-number');
448
+ ```
449
+
450
+ ---
451
+
452
+ ### vault.createBankToken(options)
453
+
454
+ ```ts
455
+ vault.createBankToken(options: BankTokenizeOptions): Promise<BankTokenResponse>
456
+ ```
457
+
458
+ Tokenizes the mounted `accountNumber` and `routingNumber` elements. Both must be mounted and ready.
459
+
460
+ ```ts
461
+ interface BankTokenizeOptions {
462
+ firstName: string; // Account holder first name (required, max 50 chars)
463
+ lastName: string; // Account holder last name (required, max 50 chars)
464
+ }
465
+
466
+ interface BankTokenResponse {
467
+ token: string;
468
+ bank?: {
469
+ last4: string; // Last 4 digits of account number
470
+ routingNumberLast4: string;
471
+ };
472
+ }
473
+ ```
474
+
475
+ > **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.
476
+
477
+ > **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.
478
+
479
+ ---
480
+
481
+ ### vault.destroy()
482
+
483
+ ```ts
484
+ vault.destroy(): void
485
+ ```
486
+
487
+ 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.
488
+
489
+ ```ts
490
+ // React useEffect cleanup — the cancel flag prevents a vault from leaking
491
+ // if the component unmounts before OzVault.create() resolves.
492
+ useEffect(() => {
493
+ let cancelled = false;
494
+ let vault: OzVault | null = null;
495
+ OzVault.create(options).then(v => {
496
+ if (cancelled) { v.destroy(); return; }
497
+ vault = v;
498
+ });
499
+ return () => {
500
+ cancelled = true;
501
+ vault?.destroy();
502
+ };
503
+ }, []);
504
+ ```
505
+
506
+ ---
507
+
508
+ ### OzElement events
509
+
510
+ ```ts
511
+ element.on('change', (event: ElementChangeEvent) => { ... });
512
+ element.on('focus', () => { ... });
513
+ element.on('blur', () => { ... });
514
+ element.on('ready', () => { ... });
515
+ element.on('loaderror', (payload: { elementType: string; error: string }) => { ... });
516
+ ```
517
+
518
+ `ElementChangeEvent`:
519
+
520
+ | Field | Type | Description |
521
+ |---|---|---|
522
+ | `empty` | `boolean` | `true` when the field is empty. |
523
+ | `complete` | `boolean` | `true` when the field has enough digits to be complete. |
524
+ | `valid` | `boolean` | `true` when the value passes all validation (Luhn, expiry date, etc.). |
525
+ | `error` | `string \| undefined` | User-facing error message when `valid` is `false` and the field has been touched. |
526
+ | `cardBrand` | `string \| undefined` | Detected brand — only on `cardNumber` fields (e.g. `"visa"`, `"amex"`). |
527
+ | `month` | `string \| undefined` | Parsed 2-digit month — only on `expirationDate` fields. |
528
+ | `year` | `string \| undefined` | Parsed 2-digit year — only on `expirationDate` fields. |
529
+
530
+ > **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.
531
+
532
+ Auto-advance is built in: the vault automatically moves focus from card number → expiry → CVV when each field completes. No additional code required.
533
+
534
+ ---
535
+
536
+ ## React API
537
+
538
+ ### OzElements provider
539
+
540
+ ```tsx
541
+ import { OzElements, createFetchWaxKey } from '@ozura/elements/react';
542
+
543
+ <OzElements
544
+ pubKey="pk_live_..."
545
+ fetchWaxKey={createFetchWaxKey('/api/mint-wax')}
546
+ appearance={{ theme: 'flat', variables: { colorPrimary: '#6366f1' } }}
547
+ onLoadError={() => setPaymentUnavailable(true)}
548
+ >
549
+ {children}
550
+ </OzElements>
551
+ ```
552
+
553
+ All `VaultOptions` are accepted as props. The provider creates a single `OzVault` instance and destroys it on unmount.
554
+
555
+ > **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.
556
+
557
+ > **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.
558
+
559
+ ---
560
+
561
+ ### OzCard
562
+
563
+ Drop-in combined card component. Renders card number, expiry, and CVV with a configurable layout.
564
+
565
+ ```tsx
566
+ import { OzCard } from '@ozura/elements/react';
567
+
568
+ <OzCard
569
+ layout="default" // "default" (number on top, expiry+CVV below) | "rows" (stacked)
570
+ onChange={(state) => {
571
+ // state.complete — all three fields complete + valid
572
+ // state.cardBrand — detected brand
573
+ // state.error — first error across all fields
574
+ // state.fields — per-field ElementChangeEvent objects
575
+ }}
576
+ onReady={() => console.log('all card fields loaded')}
577
+ disabled={isSubmitting}
578
+ labels={{ cardNumber: 'Card Number', expiry: 'Expiry', cvv: 'CVV' }}
579
+ placeholders={{ cardNumber: '1234 5678 9012 3456', expiry: 'MM/YY', cvv: '···' }}
580
+ />
581
+ ```
582
+
583
+ `OzCardProps` (full):
584
+
585
+ | Prop | Type | Description |
586
+ |---|---|---|
587
+ | `layout` | `'default' \| 'rows'` | `'default'`: number full-width, expiry+CVV side by side. `'rows'`: all stacked. |
588
+ | `gap` | `number \| string` | Gap between fields. Default: `8` (px). |
589
+ | `style` | `ElementStyleConfig` | Shared style applied to all three inputs. |
590
+ | `styles` | `{ cardNumber?, expiry?, cvv? }` | Per-field overrides merged on top of `style`. |
591
+ | `classNames` | `{ cardNumber?, expiry?, cvv?, row? }` | CSS class names for field wrappers and the expiry+CVV row. |
592
+ | `labels` | `{ cardNumber?, expiry?, cvv? }` | Optional label text above each field. |
593
+ | `labelStyle` | `React.CSSProperties` | Style applied to all `<label>` elements. |
594
+ | `labelClassName` | `string` | Class applied to all `<label>` elements. |
595
+ | `placeholders` | `{ cardNumber?, expiry?, cvv? }` | Custom placeholder text per field. |
596
+ | `hideErrors` | `boolean` | Suppress the built-in error display. Handle via `onChange`. |
597
+ | `errorStyle` | `React.CSSProperties` | Style for the built-in error container. |
598
+ | `errorClassName` | `string` | Class for the built-in error container. |
599
+ | `renderError` | `(error: string) => ReactNode` | Custom error renderer. |
600
+ | `onChange` | `(state: OzCardState) => void` | Fires on any field change. |
601
+ | `onReady` | `() => void` | Fires once all three iframes have loaded. |
602
+ | `onFocus` | `(field: 'cardNumber' \| 'expiry' \| 'cvv') => void` | |
603
+ | `onBlur` | `(field: 'cardNumber' \| 'expiry' \| 'cvv') => void` | |
604
+ | `disabled` | `boolean` | Disable all inputs. |
605
+ | `className` | `string` | Class for the outer wrapper. |
606
+
607
+ ---
608
+
609
+ ### Individual field components
610
+
611
+ For custom layouts where `OzCard` is too opinionated:
612
+
613
+ ```tsx
614
+ import { OzCardNumber, OzExpiry, OzCvv } from '@ozura/elements/react';
615
+
616
+ <OzCardNumber onChange={handleChange} placeholder="Card number" />
617
+ <OzExpiry onChange={handleChange} />
618
+ <OzCvv onChange={handleChange} />
619
+ ```
620
+
621
+ All accept `OzFieldProps`:
622
+
623
+ | Prop | Type | Description |
624
+ |---|---|---|
625
+ | `style` | `ElementStyleConfig` | Input styles. |
626
+ | `placeholder` | `string` | Placeholder text. |
627
+ | `disabled` | `boolean` | Disables the input. |
628
+ | `loadTimeoutMs` | `number` | Iframe load timeout in ms. |
629
+ | `onChange` | `(event: ElementChangeEvent) => void` | |
630
+ | `onFocus` | `() => void` | |
631
+ | `onBlur` | `() => void` | |
632
+ | `onReady` | `() => void` | |
633
+ | `onLoadError` | `(error: string) => void` | |
634
+ | `className` | `string` | Class for the outer wrapper div. |
635
+
636
+ ---
637
+
638
+ ### OzBankCard
639
+
640
+ ```tsx
641
+ import { OzBankCard } from '@ozura/elements/react';
642
+
643
+ <OzBankCard
644
+ onChange={(state) => {
645
+ // state.complete, state.error, state.fields.accountNumber, state.fields.routingNumber
646
+ }}
647
+ labels={{ accountNumber: 'Account Number', routingNumber: 'Routing Number' }}
648
+ />
649
+ ```
650
+
651
+ Or use individual bank components:
652
+
653
+ ```tsx
654
+ import { OzBankAccountNumber, OzBankRoutingNumber } from '@ozura/elements/react';
655
+
656
+ <OzBankAccountNumber onChange={handleChange} />
657
+ <OzBankRoutingNumber onChange={handleChange} />
658
+ ```
659
+
660
+ ---
661
+
662
+ ### useOzElements()
663
+
664
+ ```ts
665
+ const { createToken, createBankToken, ready, initError, tokenizeCount } = useOzElements();
666
+ ```
667
+
668
+ Must be called from inside an `<OzElements>` provider tree.
669
+
670
+ | Return | Type | Description |
671
+ |---|---|---|
672
+ | `createToken` | `(options?: TokenizeOptions) => Promise<TokenResponse>` | Tokenize mounted card elements. |
673
+ | `createBankToken` | `(options: BankTokenizeOptions) => Promise<BankTokenResponse>` | Tokenize mounted bank elements. |
674
+ | `ready` | `boolean` | `true` when the tokenizer **and** all mounted element iframes are ready. Gate your submit button on this. See note below. |
675
+ | `initError` | `Error \| null` | Non-null if `OzVault.create()` failed (e.g. `fetchWaxKey` threw). Render a fallback UI. |
676
+ | `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`. |
677
+
678
+ > **`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.
679
+
680
+ ---
681
+
682
+ ## Styling
683
+
684
+ ### Per-element styles
685
+
686
+ 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).
687
+
688
+ ```ts
689
+ const style: ElementStyleConfig = {
690
+ base: {
691
+ color: '#1a1a1a',
692
+ fontSize: '16px',
693
+ fontFamily: '"Inter", sans-serif',
694
+ padding: '10px 12px',
695
+ backgroundColor: '#ffffff',
696
+ borderRadius: '6px',
697
+ border: '1px solid #d1d5db',
698
+ },
699
+ focus: {
700
+ borderColor: '#6366f1',
701
+ boxShadow: '0 0 0 3px rgba(99,102,241,0.15)',
702
+ outline: 'none',
703
+ },
704
+ invalid: {
705
+ borderColor: '#ef4444',
706
+ color: '#dc2626',
707
+ },
708
+ complete: {
709
+ borderColor: '#22c55e',
710
+ },
711
+ placeholder: {
712
+ color: '#9ca3af',
713
+ },
714
+ };
715
+ ```
716
+
717
+ State precedence: `placeholder` applies to the `::placeholder` pseudo-element. `focus`, `invalid`, and `complete` merge on top of `base`.
718
+
719
+ ### Global appearance
720
+
721
+ Apply a preset theme and/or variable overrides to all elements at once:
722
+
723
+ ```ts
724
+ // OzVault.create
725
+ const vault = await OzVault.create({
726
+ pubKey: '...',
727
+ fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
728
+ appearance: {
729
+ theme: 'flat', // 'default' | 'night' | 'flat'
730
+ variables: {
731
+ colorText: '#1a1a1a',
732
+ colorBackground: '#ffffff',
733
+ colorPrimary: '#6366f1', // focus caret + color
734
+ colorDanger: '#dc2626', // invalid state
735
+ colorSuccess: '#16a34a', // complete state
736
+ colorPlaceholder: '#9ca3af',
737
+ fontFamily: '"Inter", sans-serif',
738
+ fontSize: '15px',
739
+ fontWeight: '400',
740
+ padding: '10px 14px',
741
+ letterSpacing: '0.01em',
742
+ },
743
+ },
744
+ });
745
+
746
+ // React provider
747
+ <OzElements pubKey="..." fetchWaxKey={...} appearance={{ theme: 'night' }}>
748
+ ```
749
+
750
+ Per-element `style` takes precedence over `appearance` variables.
751
+
752
+ > **`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.
753
+ >
754
+ > | `appearance` value | Result |
755
+ > |---|---|
756
+ > | *(omitted entirely)* | No preset — element uses minimal built-in defaults |
757
+ > | `{}` | Equivalent to `{ theme: 'default' }` — full default theme applied |
758
+ > | `{ theme: 'night' }` | Night theme |
759
+ > | `{ variables: { colorText: '#333' } }` | Default theme + variable overrides |
760
+
761
+ ### Custom fonts
762
+
763
+ Fonts are injected into each iframe so they render inside the input fields:
764
+
765
+ ```ts
766
+ fonts: [
767
+ // Google Fonts or any HTTPS CSS URL
768
+ { cssSrc: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap' },
769
+
770
+ // Custom @font-face
771
+ {
772
+ family: 'BrandFont',
773
+ src: 'url(https://cdn.example.com/brand-font.woff2)',
774
+ weight: '400',
775
+ style: 'normal',
776
+ display: 'swap',
777
+ },
778
+ ]
779
+ ```
780
+
781
+ Font `src` values must start with `url(https://...)`. HTTP and data URIs are rejected.
782
+
783
+ ---
784
+
785
+ ## Billing details
786
+
787
+ ```ts
788
+ interface BillingDetails {
789
+ firstName: string; // 1–50 characters
790
+ lastName: string; // 1–50 characters
791
+ email?: string; // Valid email, max 50 characters
792
+ phone?: string; // E.164 format, e.g. "+15551234567", max 50 characters
793
+ address?: {
794
+ line1: string; // 1–50 characters
795
+ line2?: string; // Optional, omitted from cardSale if blank
796
+ city: string; // 1–50 characters
797
+ state: string; // For US/CA: normalized to 2-letter abbreviation
798
+ zip: string; // 1–50 characters
799
+ country: string; // ISO 3166-1 alpha-2, e.g. "US"
800
+ };
801
+ }
802
+ ```
803
+
804
+ 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.
805
+
806
+ ---
807
+
808
+ ## Error handling
809
+
810
+ All SDK errors are instances of `OzError`:
811
+
812
+ ```ts
813
+ import { OzError } from '@ozura/elements';
814
+
815
+ try {
816
+ const { token } = await vault.createToken({ billing });
817
+ } catch (err) {
818
+ if (err instanceof OzError) {
819
+ switch (err.errorCode) {
820
+ case 'network': // Connection failure — show retry UI
821
+ case 'timeout': // 30s deadline exceeded — safe to retry
822
+ case 'server': // 5xx from vault transient, safe to retry
823
+ if (err.retryable) showRetryPrompt();
824
+ break;
825
+
826
+ case 'auth': // Bad pub key / API key — configuration issue
827
+ case 'validation': // Bad card data — show field-level error
828
+ case 'config': // frameBaseUrl not in permitted allowlist
829
+ case 'unknown':
830
+ showError(err.message);
831
+ break;
832
+ }
833
+ }
834
+ }
835
+ ```
836
+
837
+ `OzError` fields:
838
+
839
+ | Field | Type | Description |
840
+ |---|---|---|
841
+ | `message` | `string` | Human-readable, consumer-facing error message. |
842
+ | `errorCode` | `OzErrorCode` | `'network' \| 'timeout' \| 'auth' \| 'validation' \| 'server' \| 'config' \| 'unknown'` |
843
+ | `raw` | `string` | Raw error string from the vault API, if available. |
844
+ | `retryable` | `boolean` | `true` for `network`, `timeout`, `server`. `false` for `auth`, `validation`, `config`, `unknown`. |
845
+
846
+ > **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.
847
+
848
+ **Error normalisation helpers** (for displaying errors from `cardSale` to users):
849
+
850
+ ```ts
851
+ import { normalizeVaultError, normalizeBankVaultError, normalizeCardSaleError } from '@ozura/elements';
852
+
853
+ // Maps vault tokenize error strings to user-facing copy
854
+ const display = normalizeVaultError(err.raw); // card flows
855
+ const display = normalizeBankVaultError(err.raw); // bank/ACH flows
856
+ const display = normalizeCardSaleError(err.message); // cardSale API errors
857
+ ```
858
+
859
+ ---
860
+
861
+ ## Server utilities
862
+
863
+ ### Ozura class
864
+
865
+ ```ts
866
+ import { Ozura, OzuraError } from '@ozura/elements/server';
867
+
868
+ const ozura = new Ozura({
869
+ merchantId: process.env.MERCHANT_ID!,
870
+ apiKey: process.env.MERCHANT_API_KEY!,
871
+ vaultKey: process.env.VAULT_API_KEY!,
872
+ // apiUrl: 'https://api.ozura.com', // override Pay API URL
873
+ // vaultUrl: 'https://vault.ozura.com', // override vault URL
874
+ timeoutMs: 30000, // default
875
+ retries: 2, // max retry attempts for 5xx/network errors (3 total attempts)
876
+ });
877
+ ```
878
+
879
+ **Methods:**
880
+
881
+ ```ts
882
+ // Charge a tokenized card
883
+ const result = await ozura.cardSale({
884
+ token: tokenResponse.token,
885
+ cvcSession: tokenResponse.cvcSession,
886
+ amount: '49.00',
887
+ currency: 'USD', // default: 'USD'
888
+ billing: tokenResponse.billing,
889
+ clientIpAddress: '1.2.3.4', // fetch server-side, never from the browser
890
+ // surchargePercent, tipAmount, salesTaxExempt, processor
891
+ });
892
+ // result.transactionId, result.amount, result.cardLastFour, result.cardBrand
893
+ // result.surchargeAmount and result.tipAmount are optional — only present when non-zero
894
+ const surcharge = result.surchargeAmount ?? '0.00';
895
+ const tip = result.tipAmount ?? '0.00';
896
+
897
+ // Mint a wax key (for custom fetchWaxKey implementations)
898
+ const { waxKey, expiresInSeconds } = await ozura.mintWaxKey({
899
+ tokenizationSessionId: sessionId,
900
+ maxTokenizeCalls: 3, // must match VaultOptions.maxTokenizeCalls on the client (default: 3)
901
+ });
902
+
903
+ // Revoke a wax key — call on all three session-end paths
904
+ // Best-effort — never throws. Shortens the exposure window before the vault's ~30 min TTL.
905
+ await ozura.revokeWaxKey(waxKey);
906
+
907
+ // Suggested pattern — wire all three exit paths:
908
+ // 1. Payment success
909
+ const result = await ozura.cardSale({ ... });
910
+ await ozura.revokeWaxKey(waxKey); // key is spent; close the window immediately
911
+
912
+ // 2. User cancels checkout
913
+ router.post('/api/cancel', async (req) => {
914
+ const { waxKey } = await db.session.get(req.sessionId);
915
+ await ozura.revokeWaxKey(waxKey);
916
+ return Response.json({ ok: true });
917
+ });
918
+
919
+ // 3. Page/tab close (best-effort — browser may not deliver this)
920
+ // Use sendBeacon so the request survives navigation / tab close.
921
+ window.addEventListener('visibilitychange', () => {
922
+ if (document.visibilityState === 'hidden') {
923
+ navigator.sendBeacon('/api/cancel', JSON.stringify({ sessionId }));
924
+ }
925
+ });
926
+
927
+ // List transactions
928
+ const { transactions, pagination } = await ozura.listTransactions({
929
+ dateFrom: '2025-01-01',
930
+ dateTo: '2025-12-31',
931
+ transactionType: 'CreditCardSale',
932
+ page: 1,
933
+ limit: 50,
934
+ });
935
+ ```
936
+
937
+ **`OzuraError`** (thrown by all `Ozura` methods):
938
+
939
+ ```ts
940
+ try {
941
+ await ozura.cardSale(input);
942
+ } catch (err) {
943
+ if (err instanceof OzuraError) {
944
+ err.statusCode; // HTTP status code
945
+ err.message; // Normalized message
946
+ err.raw; // Raw API response string
947
+ err.retryAfter; // Seconds (only present on 429)
948
+ }
949
+ }
950
+ ```
951
+
952
+ Rate limits: `cardSale` — 100 req/min per merchant. `listTransactions` — 200 req/min per merchant.
953
+
954
+ > **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.
955
+
956
+ ---
957
+
958
+ ### Route handler factories
959
+
960
+ The server package exports four factory functions covering two runtimes × two endpoints:
961
+
962
+ | Function | Runtime | Endpoint |
963
+ |---|---|---|
964
+ | `createMintWaxHandler` | Fetch API (Next.js App Router, Cloudflare, Vercel Edge) | `POST /api/mint-wax` |
965
+ | `createMintWaxMiddleware` | Express / Connect | `POST /api/mint-wax` |
966
+ | `createCardSaleHandler` | Fetch API | `POST /api/charge` |
967
+ | `createCardSaleMiddleware` | Express / Connect | `POST /api/charge` |
968
+
969
+ `createCardSaleHandler` / `createCardSaleMiddleware` accept a `CardSaleHandlerOptions` object:
970
+
971
+ ```ts
972
+ interface CardSaleHandlerOptions {
973
+ /**
974
+ * Required. Return the charge amount as a decimal string.
975
+ * Never trust the amount from the request body — resolve it from your database.
976
+ */
977
+ getAmount: (body: Record<string, unknown>) => Promise<string>;
978
+
979
+ /**
980
+ * Optional. Return the ISO 4217 currency code. Default: "USD".
981
+ */
982
+ getCurrency?: (body: Record<string, unknown>) => Promise<string>;
983
+ }
984
+ ```
985
+
986
+ 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.
987
+
988
+ ---
989
+
990
+ ## Local development
991
+
992
+ The repository includes a development server at `dev-server.mjs` that serves the built frame assets and proxies vault API requests:
993
+
994
+ ```bash
995
+ npm run dev # build + start dev server on http://localhost:4242
996
+ ```
997
+
998
+ Set `frameBaseUrl` to point your vault at the local server:
999
+
1000
+ ```ts
1001
+ const vault = await OzVault.create({
1002
+ pubKey: 'pk_test_...',
1003
+ fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
1004
+ frameBaseUrl: 'http://localhost:4242', // local dev only
1005
+ });
1006
+ ```
1007
+
1008
+ Or in React:
1009
+
1010
+ ```tsx
1011
+ <OzElements
1012
+ pubKey="pk_test_..."
1013
+ fetchWaxKey={createFetchWaxKey('/api/mint-wax')}
1014
+ frameBaseUrl="http://localhost:4242"
1015
+ >
1016
+ ```
1017
+
1018
+ Configure environment variables for the dev server:
1019
+
1020
+ ```bash
1021
+ VAULT_URL=https://vault-staging.example.com
1022
+ VAULT_API_KEY=vk_test_...
1023
+ ```
1024
+
1025
+ ---
1026
+
1027
+ ## Content Security Policy
1028
+
1029
+ The SDK loads iframes from the Ozura frame origin. Add the following directives to your CSP:
1030
+
1031
+ ```
1032
+ frame-src https://elements.ozura.com;
1033
+ ```
1034
+
1035
+ If loading custom fonts via `fonts[].cssSrc`, also allow the font stylesheet origin:
1036
+
1037
+ ```
1038
+ style-src https://fonts.googleapis.com;
1039
+ font-src https://fonts.gstatic.com;
1040
+ ```
1041
+
1042
+ To verify your CSP after a build:
1043
+
1044
+ ```bash
1045
+ npm run check:csp
1046
+ ```
1047
+
1048
+ ---
1049
+
1050
+ ## TypeScript reference
1051
+
1052
+ All public types are exported from `@ozura/elements`:
1053
+
1054
+ ```ts
1055
+ import type {
1056
+ // Element types
1057
+ ElementType, // 'cardNumber' | 'cvv' | 'expirationDate'
1058
+ BankElementType, // 'accountNumber' | 'routingNumber'
1059
+ ElementOptions,
1060
+ ElementStyleConfig,
1061
+ ElementStyle,
1062
+ ElementChangeEvent,
1063
+
1064
+ // Vault config
1065
+ VaultOptions,
1066
+ FontSource,
1067
+ CssFontSource,
1068
+ CustomFontSource,
1069
+ Appearance,
1070
+ AppearanceVariables,
1071
+ OzTheme, // 'default' | 'night' | 'flat'
1072
+
1073
+ // Tokenization
1074
+ TokenizeOptions,
1075
+ BankTokenizeOptions,
1076
+ TokenResponse,
1077
+ BankTokenResponse,
1078
+ CardMetadata,
1079
+ BankAccountMetadata,
1080
+
1081
+ // Billing
1082
+ BillingDetails,
1083
+ BillingAddress,
1084
+
1085
+ // Card sale
1086
+ CardSaleRequest,
1087
+ CardSaleResponseData,
1088
+ CardSaleApiResponse,
1089
+
1090
+ // Transactions
1091
+ TransactionQueryParams,
1092
+ TransactionQueryPagination,
1093
+ TransactionQueryResponse,
1094
+ TransactionType,
1095
+ TransactionData,
1096
+ CardTransactionData,
1097
+ AchTransactionData,
1098
+ CryptoTransactionData,
1099
+
1100
+ // Errors
1101
+ OzErrorCode,
1102
+ } from '@ozura/elements';
1103
+ ```
1104
+
1105
+ Server-specific types are exported from `@ozura/elements/server`:
1106
+
1107
+ ```ts
1108
+ import type {
1109
+ OzuraConfig,
1110
+ CardSaleInput,
1111
+ MintWaxKeyOptions,
1112
+ MintWaxKeyResult,
1113
+ ListTransactionsInput,
1114
+ } from '@ozura/elements/server';
1115
+ ```
1116
+
1117
+ React-specific types are exported from `@ozura/elements/react`:
1118
+
1119
+ ```ts
1120
+ import type { OzFieldProps, OzCardProps, OzCardState, OzBankCardProps, OzBankCardState } from '@ozura/elements/react';
1121
+ ```