@ozura/elements 0.1.0-beta.7 → 1.0.1

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 +906 -663
  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 +211 -94
  6. package/dist/frame/tokenizer-frame.js.map +1 -1
  7. package/dist/oz-elements.esm.js +817 -230
  8. package/dist/oz-elements.esm.js.map +1 -1
  9. package/dist/oz-elements.umd.js +817 -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 +968 -218
  13. package/dist/react/index.cjs.js.map +1 -1
  14. package/dist/react/index.esm.js +965 -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 +181 -17
  22. package/dist/react/types/index.d.ts +69 -19
  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 +608 -71
  28. package/dist/server/index.cjs.js.map +1 -1
  29. package/dist/server/index.esm.js +606 -72
  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 +181 -17
  36. package/dist/server/types/index.d.ts +69 -19
  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 +181 -17
  46. package/dist/types/types/index.d.ts +69 -19
  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,53 +1,79 @@
1
- # OzElements
1
+ # @ozura/elements
2
2
 
3
- PCI-compliant card tokenization SDK for the Ozura Vault.
3
+ PCI-isolated card and bank account tokenization SDK for the Ozura Vault.
4
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.
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
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).
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)
8
44
 
9
45
  ---
10
46
 
11
- ## Why this exists
47
+ ## How it works
12
48
 
13
- If you need to collect card data, you have a PCI compliance problem. OzElements solves it.
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
+ ```
14
57
 
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).
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.
16
62
 
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.
63
+ Your server only ever sees a token, never card data.
18
64
 
19
65
  ---
20
66
 
21
- ## How it works
67
+ ## Credentials
22
68
 
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.
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).
51
77
 
52
78
  ---
53
79
 
@@ -57,831 +83,1048 @@ The tokenizer POSTs to the vault with **`X-Wax-Key`** (minted on your backend).
57
83
  npm install @ozura/elements
58
84
  ```
59
85
 
60
- Or via script tag (UMD) from jsDelivr (served from npm after you publish):
86
+ React and React DOM are peer dependencies (optional only needed for `@ozura/elements/react`):
61
87
 
62
- ```html
63
- <script src="https://cdn.jsdelivr.net/npm/@ozura/elements/dist/oz-elements.umd.js"></script>
88
+ ```bash
89
+ npm install react react-dom # if not already installed
64
90
  ```
65
91
 
66
- For production, pin a version: `.../npm/@ozura/elements@1.0.0/dist/oz-elements.umd.js`
92
+ **Requirements:** Node ≥ 18, React 17 (React peer).
67
93
 
68
94
  ---
69
95
 
70
- ## Quick start
96
+ ## Quick start — React
71
97
 
72
- ```js
73
- import { OzVault } from '@ozura/elements';
98
+ ```tsx
99
+ // 1. Wrap your checkout in <OzElements>
100
+ import { OzElements, OzCard, useOzElements, createFetchWaxKey } from '@ozura/elements/react';
74
101
 
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', {
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', {
80
130
  method: 'POST',
81
131
  headers: { 'Content-Type': 'application/json' },
82
- body: JSON.stringify({ sessionId }),
83
- }).then(r => r.json());
84
- return waxKey;
85
- },
86
- });
132
+ body: JSON.stringify({ token, cvcSession, billing }),
133
+ });
134
+ };
87
135
 
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
- });
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
+ ```
95
144
 
96
- const expiry = vault.createElement('expirationDate');
97
- const cvv = vault.createElement('cvv');
145
+ ---
98
146
 
99
- cardNumber.mount('#card-number');
100
- expiry.mount('#expiry');
101
- cvv.mount('#cvv');
147
+ ## Quick start — Vanilla JS
102
148
 
103
- cardNumber.on('change', ({ valid, complete, cardBrand }) => {
104
- console.log(cardBrand); // 'visa' | 'mastercard' | 'amex' | ...
105
- });
149
+ ```ts
150
+ import { OzVault, createFetchWaxKey } from '@ozura/elements';
106
151
 
107
- document.getElementById('tokenize').addEventListener('click', async () => {
108
- const { token, cvcSession } = await vault.createToken();
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;
109
158
 
110
- console.log('Token:', token);
111
- console.log('CVC Session:', cvcSession);
159
+ function checkReady() {
160
+ if (readyCount === 3 && tokenizerIsReady) enablePayButton();
161
+ }
112
162
 
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
- });
163
+ const vault = await OzVault.create({
164
+ pubKey: 'pk_live_...',
165
+ fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
166
+ onReady: () => { tokenizerIsReady = true; checkReady(); }, // tokenizer iframe loaded
122
167
  });
123
- ```
124
168
 
125
- ---
169
+ const cardNumberEl = vault.createElement('cardNumber');
170
+ const expiryEl = vault.createElement('expirationDate');
171
+ const cvvEl = vault.createElement('cvv');
126
172
 
127
- ## Wax keys (merchant integration)
173
+ cardNumberEl.mount('#card-number');
174
+ expiryEl.mount('#expiry');
175
+ cvvEl.mount('#cvv');
128
176
 
129
- The vault `/tokenize` call must not use your vault secret from the browser. Instead:
177
+ [cardNumberEl, expiryEl, cvvEl].forEach(el => {
178
+ el.on('ready', () => { readyCount++; checkReady(); });
179
+ });
130
180
 
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`**.
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
+ }
135
187
 
136
- ### Vault mint contract (your backend → vault)
188
+ // Clean up when done
189
+ vault.destroy();
190
+ ```
137
191
 
138
- Your server calls the vault (the SDK’s `Ozura.mintWaxKey` does this for you):
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.
139
193
 
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 |
194
+ ---
147
195
 
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.
196
+ ## Server setup
149
197
 
150
- ### One-line handlers (`@ozura/elements/server`)
198
+ ### Wax key endpoint
151
199
 
152
- Both helpers accept **`sessionId`** or **`tokenizationSessionId`** in the JSON body. They respond with **`{ waxKey }`** on success.
200
+ The SDK calls your `fetchWaxKey` function with a `tokenizationSessionId` UUID. Your backend must exchange it for a wax key from the vault.
153
201
 
154
- **Next.js App Router** (or any runtime with `Request` / `Response`):
202
+ **Next.js App Router (recommended)**
155
203
 
156
204
  ```ts
205
+ // app/api/mint-wax/route.ts
157
206
  import { Ozura, createMintWaxHandler } from '@ozura/elements/server';
158
207
 
159
208
  const ozura = new Ozura({
160
209
  merchantId: process.env.MERCHANT_ID!,
161
210
  apiKey: process.env.MERCHANT_API_KEY!,
162
211
  vaultKey: process.env.VAULT_API_KEY!,
163
- // vaultUrl: process.env.VAULT_URL, // optional override
164
212
  });
165
213
 
166
214
  export const POST = createMintWaxHandler(ozura);
167
215
  ```
168
216
 
169
- **Express** (register JSON body parsing **before** the middleware):
217
+ **Express**
170
218
 
171
219
  ```ts
172
220
  import express from 'express';
173
221
  import { Ozura, createMintWaxMiddleware } from '@ozura/elements/server';
174
222
 
223
+ const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
175
224
  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
225
 
182
226
  app.use(express.json());
183
227
  app.post('/api/mint-wax', createMintWaxMiddleware(ozura));
184
228
  ```
185
229
 
186
- ### Manual route
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.
187
245
 
188
- Use this if you need to persist `waxKey` server-side for later revocation:
246
+ **Next.js App Router**
189
247
 
190
248
  ```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 });
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"
200
260
  });
201
261
  ```
202
262
 
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).
263
+ **Express**
204
264
 
205
- ### Browser: handle mint failures
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
+ ```
206
274
 
207
- Check the response from your mint endpoint before calling `createToken`:
275
+ > `createCardSaleMiddleware` always terminates the request it does not call `next()` and cannot be composed in a middleware chain.
208
276
 
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
- },
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
223
294
  ```
224
295
 
225
- ### Optional: revoke when the checkout session ends
296
+ ---
297
+
298
+ ## Vanilla JS API
226
299
 
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()`.
300
+ ### OzVault.create(options)
228
301
 
229
- ### Expired or consumed wax
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. |
230
320
 
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.
321
+ Throws `OzError` if `fetchWaxKey` rejects, returns an empty string, or returns a non-string value.
232
322
 
233
- ### More detail
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`.
234
324
 
235
- [WAX_KEY_IMPLEMENTATION.md](./WAX_KEY_IMPLEMENTATION.md) covers revocation scenarios, local dev-server behaviour, and security notes in depth.
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.
236
326
 
237
327
  ---
238
328
 
239
- ## API
329
+ ### vault.createElement(type, options?)
240
330
 
241
- ### `OzVault.create(options)`
331
+ ```ts
332
+ vault.createElement(type: ElementType, options?: ElementOptions): OzElement
333
+ ```
242
334
 
243
- Returns `Promise<OzVault>`. The static factory is the only way to create a vault the constructor is private.
335
+ Creates and returns an element iframe. Call `.mount(target)` to attach it to the DOM.
244
336
 
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 |
337
+ `ElementType`: `'cardNumber'` | `'cvv'` | `'expirationDate'`
254
338
 
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.
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
+ });
256
349
 
257
- ### `vault.createElement(type, options?)`
350
+ cardEl.mount('#card-number-container');
351
+ ```
258
352
 
259
- `type`: `'cardNumber'` | `'cvv'` | `'expirationDate'`
353
+ `ElementOptions`:
260
354
 
261
355
  | Option | Type | Description |
262
356
  |---|---|---|
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
- };
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`. |
294
361
 
295
- // Deprecated — use billing.firstName/lastName instead:
296
- firstName?: string;
297
- lastName?: string;
298
- });
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>
299
384
  ```
300
385
 
301
- `TokenResponse` fields:
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.
302
387
 
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) |
388
+ Returns a `TokenResponse`:
308
389
 
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.
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
+ ```
310
403
 
311
- ### `vault.isReady`
404
+ `TokenizeOptions`:
312
405
 
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).
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. |
314
411
 
315
- ### `vault.destroy()`
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
316
419
 
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.
420
+ **`vault.tokenizeCount`**
318
421
 
319
- ```js
320
- // React useEffect cleanup / SPA route change
321
- return () => vault.destroy();
422
+ ```ts
423
+ vault.tokenizeCount: number // read-only getter
322
424
  ```
323
425
 
324
- ### `element.mount(target)`
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:
325
427
 
326
- Injects the element iframe into a container. Accepts either a CSS selector string or a direct `HTMLElement` reference (required when using React refs).
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
+ ---
327
437
 
328
- ```js
329
- element.mount('#card-number'); // CSS selector
330
- element.mount(containerRef.current); // HTMLElement (React)
438
+ ### vault.createBankElement()
439
+
440
+ ```ts
441
+ vault.createBankElement(type: BankElementType, options?: ElementOptions): OzElement
331
442
  ```
332
443
 
333
- ### `element.unmount()`
444
+ Creates a bank account element. `BankElementType`: `'accountNumber'` | `'routingNumber'`.
334
445
 
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.
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
+ ```
336
452
 
337
- ### `element.on(event, callback)`
453
+ ---
338
454
 
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 |
455
+ ### vault.createBankToken(options)
345
456
 
346
- **`ElementChangeEvent` shape:**
457
+ ```ts
458
+ vault.createBankToken(options: BankTokenizeOptions): Promise<BankTokenResponse>
459
+ ```
347
460
 
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"`) |
461
+ Tokenizes the mounted `accountNumber` and `routingNumber` elements. Both must be mounted and ready.
357
462
 
358
- ### `element.clear()`
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
+ }
359
468
 
360
- Clears the current value in the iframe input and resets validation state.
469
+ interface BankTokenResponse {
470
+ token: string;
471
+ bank?: {
472
+ last4: string; // Last 4 digits of account number
473
+ routingNumberLast4: string;
474
+ };
475
+ }
476
+ ```
361
477
 
362
- ### `element.update(options)`
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.
363
479
 
364
- Updates `style`, `placeholder`, or `disabled` on a mounted element without remounting the iframe.
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.
365
481
 
366
482
  ---
367
483
 
368
- ## Server SDK (`@ozura/elements/server`)
484
+ ### vault.destroy()
369
485
 
370
- Import `Ozura` and optional wax helpers from `@ozura/elements/server`:
486
+ ```ts
487
+ vault.destroy(): void
488
+ ```
371
489
 
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 |
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.
380
491
 
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).
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
+ ```
382
508
 
383
509
  ---
384
510
 
385
- ## React
511
+ ### OzElement events
386
512
 
387
- ```bash
388
- npm install @ozura/elements
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 }) => { ... });
389
519
  ```
390
520
 
391
- Import from the `/react` subpath:
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
392
542
 
393
543
  ```tsx
394
- import { OzElements, OzCardNumber, OzExpiry, OzCvv, useOzElements } from '@ozura/elements/react';
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>
395
554
  ```
396
555
 
397
- ### Example
556
+ All `VaultOptions` are accepted as props. The provider creates a single `OzVault` instance and destroys it on unmount.
398
557
 
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
- };
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.
409
559
 
410
- function CheckoutForm() {
411
- const { createToken, ready } = useOzElements();
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.
412
561
 
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
- };
562
+ ---
458
563
 
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
- }
564
+ ### OzCard
482
565
 
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
- }
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
+ />
501
584
  ```
502
585
 
503
- ### `<OzElements>` props
586
+ `OzCardProps` (full):
504
587
 
505
588
  | Prop | Type | Description |
506
589
  |---|---|---|
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 |
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
+ ---
514
611
 
515
- ### `useOzElements()` return
612
+ ### Individual field components
516
613
 
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 |
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
+ ```
521
623
 
522
- ### Field component props (`OzCardNumber`, `OzExpiry`, `OzCvv`)
624
+ All accept `OzFieldProps`:
523
625
 
524
626
  | Prop | Type | Description |
525
627
  |---|---|---|
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>` |
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. |
534
638
 
535
639
  ---
536
640
 
537
- ## OzuraPay Integration (Optional)
641
+ ### OzBankCard
538
642
 
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.
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
+ ```
540
653
 
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 |
654
+ Or use individual bank components:
546
655
 
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).
656
+ ```tsx
657
+ import { OzBankAccountNumber, OzBankRoutingNumber } from '@ozura/elements/react';
548
658
 
549
- ### 1 — Collect card data + billing in one call
659
+ <OzBankAccountNumber onChange={handleChange} />
660
+ <OzBankRoutingNumber onChange={handleChange} />
661
+ ```
550
662
 
551
- ```js
552
- import { OzVault, normalizeCardSaleError } from '@ozura/elements';
663
+ ---
664
+
665
+ ### useOzElements()
666
+
667
+ ```ts
668
+ const { createToken, createBankToken, ready, initError, tokenizeCount } = useOzElements();
669
+ ```
553
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
554
728
  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;
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
+ },
563
746
  },
564
747
  });
565
748
 
566
- // mount card elements …
749
+ // React provider
750
+ <OzElements pubKey="..." fetchWaxKey={...} appearance={{ theme: 'night' }}>
751
+ ```
567
752
 
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
- }
753
+ Per-element `style` takes precedence over `appearance` variables.
592
754
 
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
- });
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 |
624
763
 
625
- const data = await res.json();
764
+ ### Custom fonts
626
765
 
627
- if (!res.ok) {
628
- showError(normalizeCardSaleError(data.error ?? res.statusText));
629
- } else {
630
- showSuccess(data.transactionId);
631
- }
632
- }
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
+ ]
633
782
  ```
634
783
 
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',
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"
667
803
  };
804
+ }
805
+ ```
668
806
 
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
- });
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.
679
808
 
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
- }
809
+ ---
810
+
811
+ ## Error handling
685
812
 
686
- const result: CardSaleApiResponse = await response.json();
813
+ All SDK errors are instances of `OzError`:
687
814
 
688
- if (!result.success) {
689
- return res.status(402).json({ error: 'Payment was not successful' });
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
+ }
690
836
  }
837
+ }
838
+ ```
691
839
 
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
- });
840
+ `OzError` fields:
708
841
 
709
- res.json({ transactionId: txn.transactionId, cardLastFour: txn.cardLastFour });
710
- });
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
711
860
  ```
712
861
 
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 });
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)
754
879
  });
755
880
  ```
756
881
 
757
- ### `normalizeCardSaleError(raw)`
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
+ > ```
758
887
 
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:
888
+ **Methods:**
760
889
 
761
- ```js
762
- import { normalizeCardSaleError } from '@ozura/elements';
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
+ });
763
911
 
764
- normalizeCardSaleError('Insufficient Funds')
765
- // "Your card has insufficient funds. Please use a different payment method."
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);
766
915
 
767
- normalizeCardSaleError('CVV Verification Failed')
768
- // → "The CVV code you entered is incorrect. Please check and try again."
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
769
920
 
770
- normalizeCardSaleError('Card expired')
771
- // → "Your card has expired. Please use a different card."
772
- ```
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
+ });
773
935
 
774
- ### TypeScript: `CardSaleRequest`
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
+ ```
775
945
 
776
- Import the interface to type your cardSale payload and catch missing fields at compile time:
946
+ **`OzuraError`** (thrown by all `Ozura` methods):
777
947
 
778
948
  ```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
- };
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
+ }
802
959
  ```
803
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
+
804
965
  ---
805
966
 
806
- ## Testing
967
+ ### Route handler factories
807
968
 
808
- ```bash
809
- npm test # run once
810
- npm run test:watch # watch mode
811
- npm run test:coverage # with V8 coverage report
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
+ }
812
993
  ```
813
994
 
814
- The test suite covers: Luhn algorithm, card brand detection, card number formatting, expiry validation, and error normalisation.
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.
815
996
 
816
997
  ---
817
998
 
818
- ## Development
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:
819
1002
 
820
1003
  ```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
1004
+ npm run dev # build + start dev server on http://localhost:4242
825
1005
  ```
826
1006
 
827
- Build outputs in `dist/`:
1007
+ Set `frameBaseUrl` to point your vault at the local server:
828
1008
 
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'` |
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
+ ```
838
1016
 
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).
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
+ ```
840
1033
 
841
1034
  ---
842
1035
 
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
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
+ ```
882
1056
 
883
1057
  ---
884
1058
 
885
- ## License
1059
+ ## TypeScript reference
886
1060
 
887
- MIT Copyright 2025 Ozura
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
+ ```