@ozura/elements 0.1.0-beta.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.
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/dist/frame/element-frame.html +22 -0
- package/dist/frame/element-frame.js +731 -0
- package/dist/frame/element-frame.js.map +1 -0
- package/dist/frame/tokenizer-frame.html +11 -0
- package/dist/frame/tokenizer-frame.js +328 -0
- package/dist/frame/tokenizer-frame.js.map +1 -0
- package/dist/oz-elements.esm.js +1190 -0
- package/dist/oz-elements.esm.js.map +1 -0
- package/dist/oz-elements.umd.js +1202 -0
- package/dist/oz-elements.umd.js.map +1 -0
- package/dist/react/frame/elementFrame.d.ts +8 -0
- package/dist/react/frame/tokenizerFrame.d.ts +13 -0
- package/dist/react/index.cjs.js +1407 -0
- package/dist/react/index.cjs.js.map +1 -0
- package/dist/react/index.esm.js +1400 -0
- package/dist/react/index.esm.js.map +1 -0
- package/dist/react/react/index.d.ts +214 -0
- package/dist/react/sdk/OzElement.d.ts +65 -0
- package/dist/react/sdk/OzVault.d.ts +106 -0
- package/dist/react/sdk/errors.d.ts +55 -0
- package/dist/react/sdk/index.d.ts +5 -0
- package/dist/react/server/index.d.ts +140 -0
- package/dist/react/types/index.d.ts +432 -0
- package/dist/react/utils/appearance.d.ts +13 -0
- package/dist/react/utils/billingUtils.d.ts +60 -0
- package/dist/react/utils/cardUtils.d.ts +37 -0
- package/dist/server/frame/elementFrame.d.ts +8 -0
- package/dist/server/frame/tokenizerFrame.d.ts +13 -0
- package/dist/server/index.cjs.js +294 -0
- package/dist/server/index.cjs.js.map +1 -0
- package/dist/server/index.esm.js +290 -0
- package/dist/server/index.esm.js.map +1 -0
- package/dist/server/sdk/OzElement.d.ts +65 -0
- package/dist/server/sdk/OzVault.d.ts +106 -0
- package/dist/server/sdk/errors.d.ts +55 -0
- package/dist/server/sdk/index.d.ts +5 -0
- package/dist/server/server/index.d.ts +140 -0
- package/dist/server/types/index.d.ts +432 -0
- package/dist/server/utils/appearance.d.ts +13 -0
- package/dist/server/utils/billingUtils.d.ts +60 -0
- package/dist/server/utils/cardUtils.d.ts +37 -0
- package/dist/types/frame/elementFrame.d.ts +8 -0
- package/dist/types/frame/tokenizerFrame.d.ts +13 -0
- package/dist/types/sdk/OzElement.d.ts +65 -0
- package/dist/types/sdk/OzVault.d.ts +106 -0
- package/dist/types/sdk/errors.d.ts +55 -0
- package/dist/types/sdk/index.d.ts +5 -0
- package/dist/types/server/index.d.ts +140 -0
- package/dist/types/types/index.d.ts +432 -0
- package/dist/types/utils/appearance.d.ts +13 -0
- package/dist/types/utils/billingUtils.d.ts +60 -0
- package/dist/types/utils/cardUtils.d.ts +37 -0
- package/package.json +97 -0
package/README.md
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
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
|
+
All you need is a vault API key.
|
|
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 just need a vault API key. 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 'oz-elements'
|
|
27
|
+
│
|
|
28
|
+
│ const vault = new OzVault('vault_api_key');
|
|
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
|
+
---
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm install oz-elements
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or via script tag (UMD):
|
|
59
|
+
|
|
60
|
+
```html
|
|
61
|
+
<script src="https://cdn.ozura.com/oz-elements/oz-elements.umd.js"></script>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { OzVault } from 'oz-elements';
|
|
70
|
+
|
|
71
|
+
const vault = new OzVault('your_vault_api_key', {
|
|
72
|
+
pubKey: 'your_pub_key',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const cardNumber = vault.createElement('cardNumber', {
|
|
76
|
+
style: {
|
|
77
|
+
base: { color: '#1a1a2e', fontSize: '16px', fontFamily: 'Inter, sans-serif' },
|
|
78
|
+
focus: { color: '#1a1a2e' },
|
|
79
|
+
invalid: { color: '#dc2626' },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const expiry = vault.createElement('expirationDate');
|
|
84
|
+
const cvv = vault.createElement('cvv');
|
|
85
|
+
|
|
86
|
+
cardNumber.mount('#card-number');
|
|
87
|
+
expiry.mount('#expiry');
|
|
88
|
+
cvv.mount('#cvv');
|
|
89
|
+
|
|
90
|
+
cardNumber.on('change', ({ valid, complete, cardBrand }) => {
|
|
91
|
+
console.log(cardBrand); // 'visa' | 'mastercard' | 'amex' | ...
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
document.getElementById('tokenize').addEventListener('click', async () => {
|
|
95
|
+
const { token, cvcSession } = await vault.createToken();
|
|
96
|
+
|
|
97
|
+
console.log('Token:', token);
|
|
98
|
+
console.log('CVC Session:', cvcSession);
|
|
99
|
+
|
|
100
|
+
// The token is yours — send it to your backend and use it however you want:
|
|
101
|
+
// • With your own payment processor
|
|
102
|
+
// • With the Ozura Pay API (see "OzuraPay integration" below)
|
|
103
|
+
// • With any system that accepts tokenized card data
|
|
104
|
+
await fetch('/api/tokenized', {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ token, cvcSession }),
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## API
|
|
115
|
+
|
|
116
|
+
### `new OzVault(apiKey, options)`
|
|
117
|
+
|
|
118
|
+
| Option | Type | Default | Description |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| `apiKey` | `string` | required | Your Ozura vault API key |
|
|
121
|
+
| `options.pubKey` | `string` | required | System pub key for the tokenize endpoint. Obtain from Ozura admin. |
|
|
122
|
+
| `options.apiUrl` | `string` | Ozura prod vault | Vault base URL |
|
|
123
|
+
| `options.frameBaseUrl` | `string` | `https://elements.ozura.com` | Where iframe assets are served from |
|
|
124
|
+
|
|
125
|
+
### `vault.createElement(type, options?)`
|
|
126
|
+
|
|
127
|
+
`type`: `'cardNumber'` | `'cvv'` | `'expirationDate'`
|
|
128
|
+
|
|
129
|
+
| Option | Type | Description |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `style.base` | `object` | CSS properties applied by default |
|
|
132
|
+
| `style.focus` | `object` | CSS properties applied on focus |
|
|
133
|
+
| `style.invalid` | `object` | CSS properties applied when input is invalid |
|
|
134
|
+
| `placeholder` | `string` | Override default placeholder text |
|
|
135
|
+
| `disabled` | `boolean` | Disable the input |
|
|
136
|
+
|
|
137
|
+
### `vault.createToken(options?)`
|
|
138
|
+
|
|
139
|
+
Triggers tokenization across all mounted elements. Returns a Promise with the vault token.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// Minimal — just tokenize the card data
|
|
143
|
+
const { token, cvcSession } = await vault.createToken();
|
|
144
|
+
|
|
145
|
+
// With billing — validates and normalizes billing details alongside tokenization.
|
|
146
|
+
// Useful if you plan to pass billing to a payment API (e.g. Ozura Pay API).
|
|
147
|
+
const { token, cvcSession, billing } = await vault.createToken({
|
|
148
|
+
billing: {
|
|
149
|
+
firstName: string; // required, 1–50 chars
|
|
150
|
+
lastName: string; // required, 1–50 chars
|
|
151
|
+
email?: string; // valid address, max 50 chars
|
|
152
|
+
phone?: string; // E.164 format e.g. "+15551234567"
|
|
153
|
+
address?: {
|
|
154
|
+
line1: string; // required if address provided
|
|
155
|
+
line2?: string; // omitted from output if blank
|
|
156
|
+
city: string;
|
|
157
|
+
state: string; // "California" auto-normalised → "CA" for US/CA
|
|
158
|
+
zip: string;
|
|
159
|
+
country: string; // ISO 3166-1 alpha-2, e.g. "US"
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Deprecated — use billing.firstName/lastName instead:
|
|
164
|
+
firstName?: string;
|
|
165
|
+
lastName?: string;
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`TokenResponse` fields:
|
|
170
|
+
|
|
171
|
+
| Field | Description |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `token` | Vault token referencing the stored card data |
|
|
174
|
+
| `cvcSession` | CVC session ID for use with payment APIs |
|
|
175
|
+
| `billing` | Validated, normalized billing details (only present when `billing` was passed) |
|
|
176
|
+
|
|
177
|
+
**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.
|
|
178
|
+
|
|
179
|
+
### `vault.isReady`
|
|
180
|
+
|
|
181
|
+
`boolean` — `true` once the hidden tokenizer iframe has loaded. For React, use the `ready` value from `useOzElements()` instead (which also tracks field iframe readiness).
|
|
182
|
+
|
|
183
|
+
### `vault.destroy()`
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
// React useEffect cleanup / SPA route change
|
|
189
|
+
return () => vault.destroy();
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### `element.mount(target)`
|
|
193
|
+
|
|
194
|
+
Injects the element iframe into a container. Accepts either a CSS selector string or a direct `HTMLElement` reference (required when using React refs).
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
element.mount('#card-number'); // CSS selector
|
|
198
|
+
element.mount(containerRef.current); // HTMLElement (React)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### `element.unmount()`
|
|
202
|
+
|
|
203
|
+
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.
|
|
204
|
+
|
|
205
|
+
### `element.on(event, callback)`
|
|
206
|
+
|
|
207
|
+
| Event | Payload | Description |
|
|
208
|
+
|---|---|---|
|
|
209
|
+
| `ready` | — | Iframe loaded and initialised |
|
|
210
|
+
| `change` | `ElementChangeEvent` | Input value changed |
|
|
211
|
+
| `focus` | — | Input focused |
|
|
212
|
+
| `blur` | — | Input blurred |
|
|
213
|
+
|
|
214
|
+
**`ElementChangeEvent` shape:**
|
|
215
|
+
|
|
216
|
+
| Field | Type | Present on |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `empty` | `boolean` | all elements |
|
|
219
|
+
| `complete` | `boolean` | all elements |
|
|
220
|
+
| `valid` | `boolean` | all elements |
|
|
221
|
+
| `error` | `string \| undefined` | all — set when complete but invalid |
|
|
222
|
+
| `cardBrand` | `string \| undefined` | `cardNumber` only |
|
|
223
|
+
| `month` | `string \| undefined` | `expirationDate` only (`"01"`–`"12"`) |
|
|
224
|
+
| `year` | `string \| undefined` | `expirationDate` only (2-digit, e.g. `"27"`) |
|
|
225
|
+
|
|
226
|
+
### `element.clear()`
|
|
227
|
+
|
|
228
|
+
Clears the current value in the iframe input and resets validation state.
|
|
229
|
+
|
|
230
|
+
### `element.update(options)`
|
|
231
|
+
|
|
232
|
+
Updates `style`, `placeholder`, or `disabled` on a mounted element without remounting the iframe.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## React
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
npm install oz-elements
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Import from the `/react` subpath:
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
import { OzElements, OzCardNumber, OzExpiry, OzCvv, useOzElements } from 'oz-elements/react';
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Example
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
import { useState } from 'react';
|
|
252
|
+
import { OzElements, OzCardNumber, OzExpiry, OzCvv, useOzElements } from 'oz-elements/react';
|
|
253
|
+
import { normalizeCardSaleError } from 'oz-elements';
|
|
254
|
+
|
|
255
|
+
const fieldStyle = {
|
|
256
|
+
base: { color: '#111827', fontSize: '16px', fontFamily: 'Inter, sans-serif' },
|
|
257
|
+
focus: { color: '#111827' },
|
|
258
|
+
invalid: { color: '#dc2626' },
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
function CheckoutForm() {
|
|
262
|
+
const { createToken, ready } = useOzElements();
|
|
263
|
+
|
|
264
|
+
const [form, setForm] = useState({
|
|
265
|
+
firstName: '', lastName: '', email: '', phone: '',
|
|
266
|
+
address1: '', city: '', state: '', zip: '', country: 'US',
|
|
267
|
+
});
|
|
268
|
+
const [error, setError] = useState('');
|
|
269
|
+
|
|
270
|
+
const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
271
|
+
setForm(f => ({ ...f, [field]: e.target.value }));
|
|
272
|
+
|
|
273
|
+
const handlePay = async () => {
|
|
274
|
+
setError('');
|
|
275
|
+
try {
|
|
276
|
+
const { token, cvcSession, billing } = await createToken({
|
|
277
|
+
billing: {
|
|
278
|
+
firstName: form.firstName,
|
|
279
|
+
lastName: form.lastName,
|
|
280
|
+
email: form.email || undefined,
|
|
281
|
+
phone: form.phone || undefined,
|
|
282
|
+
...(form.address1 ? {
|
|
283
|
+
address: {
|
|
284
|
+
line1: form.address1,
|
|
285
|
+
city: form.city,
|
|
286
|
+
state: form.state,
|
|
287
|
+
zip: form.zip,
|
|
288
|
+
country: form.country.toUpperCase(),
|
|
289
|
+
},
|
|
290
|
+
} : {}),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Send token + cvcSession + billing to your server → Ozura Pay API cardSale
|
|
295
|
+
const res = await fetch('/api/charge', {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: { 'Content-Type': 'application/json' },
|
|
298
|
+
body: JSON.stringify({ token, cvcSession, billing, amount: '49.00', currency: 'USD' }),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!res.ok) {
|
|
302
|
+
const data = await res.json();
|
|
303
|
+
setError(normalizeCardSaleError(data.error ?? 'Payment failed'));
|
|
304
|
+
}
|
|
305
|
+
} catch (err: unknown) {
|
|
306
|
+
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<div>
|
|
312
|
+
{/* Billing fields — plain inputs, not PCI-sensitive */}
|
|
313
|
+
<input placeholder="First name" value={form.firstName} onChange={set('firstName')} />
|
|
314
|
+
<input placeholder="Last name" value={form.lastName} onChange={set('lastName')} />
|
|
315
|
+
<input placeholder="Email" value={form.email} onChange={set('email')} />
|
|
316
|
+
<input placeholder="Phone (+15551234567)" value={form.phone} onChange={set('phone')} />
|
|
317
|
+
<input placeholder="Street address" value={form.address1} onChange={set('address1')} />
|
|
318
|
+
<input placeholder="City" value={form.city} onChange={set('city')} />
|
|
319
|
+
<input placeholder="State" value={form.state} onChange={set('state')} />
|
|
320
|
+
<input placeholder="ZIP" value={form.zip} onChange={set('zip')} />
|
|
321
|
+
<input placeholder="Country (US)" value={form.country} onChange={set('country')} maxLength={2} />
|
|
322
|
+
|
|
323
|
+
{/* Card fields — iframe-isolated, raw card data never touches this component */}
|
|
324
|
+
<OzCardNumber style={fieldStyle} />
|
|
325
|
+
<OzExpiry style={fieldStyle} />
|
|
326
|
+
<OzCvv style={fieldStyle} />
|
|
327
|
+
|
|
328
|
+
{error && <p style={{ color: 'red' }}>{error}</p>}
|
|
329
|
+
<button onClick={handlePay} disabled={!ready}>Pay $49.00</button>
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export default function App() {
|
|
335
|
+
return (
|
|
336
|
+
<OzElements
|
|
337
|
+
apiKey="your_vault_api_key"
|
|
338
|
+
pubKey="your_pub_key"
|
|
339
|
+
frameBaseUrl="https://elements.ozura.com"
|
|
340
|
+
>
|
|
341
|
+
<CheckoutForm />
|
|
342
|
+
</OzElements>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### `<OzElements>` props
|
|
348
|
+
|
|
349
|
+
| Prop | Type | Description |
|
|
350
|
+
|---|---|---|
|
|
351
|
+
| `apiKey` | `string` | Your Ozura vault API key |
|
|
352
|
+
| `apiUrl` | `string?` | Vault API base URL |
|
|
353
|
+
| `frameBaseUrl` | `string?` | Where iframe assets are hosted |
|
|
354
|
+
|
|
355
|
+
### `useOzElements()` return
|
|
356
|
+
|
|
357
|
+
| Value | Type | Description |
|
|
358
|
+
|---|---|---|
|
|
359
|
+
| `createToken` | `(opts?) => Promise<{ token, cvcSession }>` | Tokenizes all mounted fields |
|
|
360
|
+
| `ready` | `boolean` | `true` when all field iframes have loaded |
|
|
361
|
+
|
|
362
|
+
### Field component props (`OzCardNumber`, `OzExpiry`, `OzCvv`)
|
|
363
|
+
|
|
364
|
+
| Prop | Type | Description |
|
|
365
|
+
|---|---|---|
|
|
366
|
+
| `style` | `ElementStyleConfig?` | `{ base, focus, invalid }` CSS property objects |
|
|
367
|
+
| `placeholder` | `string?` | Override default placeholder |
|
|
368
|
+
| `disabled` | `boolean?` | Disable the input |
|
|
369
|
+
| `onChange` | `(e: ElementChangeEvent) => void` | Keystroke callback |
|
|
370
|
+
| `onFocus` | `() => void` | Focus callback |
|
|
371
|
+
| `onBlur` | `() => void` | Blur callback |
|
|
372
|
+
| `onReady` | `() => void` | Iframe ready callback |
|
|
373
|
+
| `className` | `string?` | CSS class on the wrapper `<div>` |
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## OzuraPay Integration (Optional)
|
|
378
|
+
|
|
379
|
+
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.
|
|
380
|
+
|
|
381
|
+
| Credential | Where it goes |
|
|
382
|
+
|-----------|---------------|
|
|
383
|
+
| Vault API key | Frontend: `new OzVault(vaultApiKey)`. Backend: `vault-api-key` header |
|
|
384
|
+
| Pay API key | Backend only: `x-api-key` header |
|
|
385
|
+
| Merchant ID | Backend only: `merchantId` in request body |
|
|
386
|
+
|
|
387
|
+
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).
|
|
388
|
+
|
|
389
|
+
### 1 — Collect card data + billing in one call
|
|
390
|
+
|
|
391
|
+
```js
|
|
392
|
+
import { OzVault, normalizeCardSaleError } from 'oz-elements';
|
|
393
|
+
|
|
394
|
+
const vault = new OzVault('vault_api_key_here', {
|
|
395
|
+
pubKey: 'your_pub_key',
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// … mount card elements …
|
|
399
|
+
|
|
400
|
+
async function handlePay(formValues) {
|
|
401
|
+
let tokenResult;
|
|
402
|
+
try {
|
|
403
|
+
tokenResult = await vault.createToken({
|
|
404
|
+
billing: {
|
|
405
|
+
firstName: formValues.firstName, // validated 1–50 chars
|
|
406
|
+
lastName: formValues.lastName,
|
|
407
|
+
email: formValues.email, // validated email format
|
|
408
|
+
phone: formValues.phone, // must be E.164: "+15551234567"
|
|
409
|
+
address: {
|
|
410
|
+
line1: formValues.address1,
|
|
411
|
+
line2: formValues.address2, // omitted from output if blank
|
|
412
|
+
city: formValues.city,
|
|
413
|
+
state: formValues.state, // "California" → "CA" auto-normalized
|
|
414
|
+
zip: formValues.zip,
|
|
415
|
+
country: formValues.country, // "US", "CA", "GB" …
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
} catch (err) {
|
|
420
|
+
// Validation errors thrown here — show directly to user
|
|
421
|
+
showError(err.message);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const { token, cvcSession, billing } = tokenResult;
|
|
426
|
+
|
|
427
|
+
// 2 — Fetch client IP from your own server (never api.ipify.org — ad-blockers block it)
|
|
428
|
+
const ipRes = await fetch('/api/client-ip');
|
|
429
|
+
const { ip } = await ipRes.json();
|
|
430
|
+
|
|
431
|
+
// 3 — POST to your server, which calls cardSale
|
|
432
|
+
const res = await fetch('/api/charge', {
|
|
433
|
+
method: 'POST',
|
|
434
|
+
headers: { 'Content-Type': 'application/json' },
|
|
435
|
+
body: JSON.stringify({
|
|
436
|
+
ozuraCardToken: token,
|
|
437
|
+
ozuraCvcSession: cvcSession,
|
|
438
|
+
billingFirstName: billing.firstName,
|
|
439
|
+
billingLastName: billing.lastName,
|
|
440
|
+
billingEmail: billing.email,
|
|
441
|
+
billingPhone: billing.phone, // already E.164
|
|
442
|
+
billingAddress1: billing.address.line1,
|
|
443
|
+
billingAddress2: billing.address.line2, // omit key if undefined
|
|
444
|
+
billingCity: billing.address.city,
|
|
445
|
+
billingState: billing.address.state, // already 2-letter
|
|
446
|
+
billingZipcode: billing.address.zip,
|
|
447
|
+
billingCountry: billing.address.country,
|
|
448
|
+
clientIpAddress: ip,
|
|
449
|
+
amount: '49.00',
|
|
450
|
+
currency: 'USD',
|
|
451
|
+
salesTaxExempt: false,
|
|
452
|
+
surchargePercent: '0.00',
|
|
453
|
+
tipAmount: '0.00',
|
|
454
|
+
}),
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const data = await res.json();
|
|
458
|
+
|
|
459
|
+
if (!res.ok) {
|
|
460
|
+
showError(normalizeCardSaleError(data.error ?? res.statusText));
|
|
461
|
+
} else {
|
|
462
|
+
showSuccess(data.transactionId);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### 2 — Server-side cardSale call
|
|
468
|
+
|
|
469
|
+
Your server proxies to the Ozura Pay API with **two API keys** in the headers:
|
|
470
|
+
|
|
471
|
+
```js
|
|
472
|
+
// Node.js / Express example
|
|
473
|
+
import type { CardSaleRequest, CardSaleApiResponse } from 'oz-elements';
|
|
474
|
+
|
|
475
|
+
app.post('/api/charge', async (req, res) => {
|
|
476
|
+
const body = req.body;
|
|
477
|
+
|
|
478
|
+
const payload: CardSaleRequest = {
|
|
479
|
+
processor: 'elavon',
|
|
480
|
+
merchantId: process.env.MERCHANT_ID,
|
|
481
|
+
amount: body.amount,
|
|
482
|
+
currency: body.currency,
|
|
483
|
+
ozuraCardToken: body.ozuraCardToken,
|
|
484
|
+
ozuraCvcSession: body.ozuraCvcSession,
|
|
485
|
+
billingFirstName: body.billingFirstName,
|
|
486
|
+
billingLastName: body.billingLastName,
|
|
487
|
+
billingEmail: body.billingEmail,
|
|
488
|
+
billingPhone: body.billingPhone,
|
|
489
|
+
billingAddress1: body.billingAddress1,
|
|
490
|
+
...(body.billingAddress2 ? { billingAddress2: body.billingAddress2 } : {}),
|
|
491
|
+
billingCity: body.billingCity,
|
|
492
|
+
billingState: body.billingState,
|
|
493
|
+
billingZipcode: body.billingZipcode,
|
|
494
|
+
billingCountry: body.billingCountry,
|
|
495
|
+
clientIpAddress: body.clientIpAddress,
|
|
496
|
+
salesTaxExempt: body.salesTaxExempt,
|
|
497
|
+
surchargePercent: '0.00',
|
|
498
|
+
tipAmount: '0.00',
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const response = await fetch(`${process.env.OZURA_API_URL}/api/v1/cardSale`, {
|
|
502
|
+
method: 'POST',
|
|
503
|
+
headers: {
|
|
504
|
+
'Content-Type': 'application/json',
|
|
505
|
+
'x-api-key': process.env.MERCHANT_API_KEY, // merchant Pay API key
|
|
506
|
+
'vault-api-key': process.env.VAULT_API_KEY, // same key used in OzVault
|
|
507
|
+
},
|
|
508
|
+
body: JSON.stringify(payload),
|
|
509
|
+
signal: AbortSignal.timeout(30_000),
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
if (!response.ok) {
|
|
513
|
+
const err = await response.json().catch(() => ({}));
|
|
514
|
+
// Pass err.error through normalizeCardSaleError on the client side
|
|
515
|
+
return res.status(response.status).json({ error: err.error ?? 'cardSale failed' });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const result: CardSaleApiResponse = await response.json();
|
|
519
|
+
|
|
520
|
+
if (!result.success) {
|
|
521
|
+
return res.status(402).json({ error: 'Payment was not successful' });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const txn = result.data;
|
|
525
|
+
|
|
526
|
+
// ⚠️ Store txn.transactionId in your database NOW — before responding to the client.
|
|
527
|
+
// You need it for refunds and to prevent duplicate charges on network retry.
|
|
528
|
+
await db.transactions.insertOne({
|
|
529
|
+
transactionId: txn.transactionId,
|
|
530
|
+
amount: txn.amount,
|
|
531
|
+
currency: txn.currency,
|
|
532
|
+
cardLastFour: txn.cardLastFour,
|
|
533
|
+
cardBrand: txn.cardBrand,
|
|
534
|
+
cardExpMonth: txn.cardExpMonth,
|
|
535
|
+
cardExpYear: txn.cardExpYear,
|
|
536
|
+
billingName: `${txn.billingFirstName} ${txn.billingLastName}`,
|
|
537
|
+
transDate: txn.transDate,
|
|
538
|
+
createdAt: new Date(),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
res.json({ transactionId: txn.transactionId, cardLastFour: txn.cardLastFour });
|
|
542
|
+
});
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### 3 — cardSale response shape
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
import type { CardSaleApiResponse, CardSaleResponseData } from 'oz-elements';
|
|
549
|
+
|
|
550
|
+
// Successful response shape from POST /api/v1/cardSale:
|
|
551
|
+
// {
|
|
552
|
+
// success: true,
|
|
553
|
+
// data: {
|
|
554
|
+
// transactionId: "txn_...", ← store this immediately
|
|
555
|
+
// amount: "49.00",
|
|
556
|
+
// currency: "USD",
|
|
557
|
+
// surchargeAmount:"0.00",
|
|
558
|
+
// tipAmount: "0.00",
|
|
559
|
+
// cardLastFour: "4242",
|
|
560
|
+
// cardExpMonth: "09",
|
|
561
|
+
// cardExpYear: "2027", ← 4-digit year
|
|
562
|
+
// cardBrand: "visa",
|
|
563
|
+
// transDate: "2026-02-20T...",
|
|
564
|
+
// ozuraMerchantId:"...",
|
|
565
|
+
// billingFirstName, billingLastName, billingEmail, billingPhone,
|
|
566
|
+
// billingAddress1, billingAddress2?, billingCity, billingState,
|
|
567
|
+
// billingZipcode, billingCountry
|
|
568
|
+
// }
|
|
569
|
+
// }
|
|
570
|
+
//
|
|
571
|
+
// Error response (4xx/5xx): { error: string }
|
|
572
|
+
// → pass error to normalizeCardSaleError() for a user-facing message
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### 3 — Client IP endpoint
|
|
576
|
+
|
|
577
|
+
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):
|
|
578
|
+
|
|
579
|
+
```js
|
|
580
|
+
app.get('/api/client-ip', (req, res) => {
|
|
581
|
+
const ip =
|
|
582
|
+
req.headers['x-forwarded-for']?.split(',')[0].trim() ??
|
|
583
|
+
req.socket.remoteAddress ??
|
|
584
|
+
'0.0.0.0';
|
|
585
|
+
res.json({ ip });
|
|
586
|
+
});
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### `normalizeCardSaleError(raw)`
|
|
590
|
+
|
|
591
|
+
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:
|
|
592
|
+
|
|
593
|
+
```js
|
|
594
|
+
import { normalizeCardSaleError } from 'oz-elements';
|
|
595
|
+
|
|
596
|
+
normalizeCardSaleError('Insufficient Funds')
|
|
597
|
+
// → "Your card has insufficient funds. Please use a different payment method."
|
|
598
|
+
|
|
599
|
+
normalizeCardSaleError('CVV Verification Failed')
|
|
600
|
+
// → "The CVV code you entered is incorrect. Please check and try again."
|
|
601
|
+
|
|
602
|
+
normalizeCardSaleError('Card expired')
|
|
603
|
+
// → "Your card has expired. Please use a different card."
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### TypeScript: `CardSaleRequest`
|
|
607
|
+
|
|
608
|
+
Import the interface to type your cardSale payload and catch missing fields at compile time:
|
|
609
|
+
|
|
610
|
+
```ts
|
|
611
|
+
import type { CardSaleRequest } from 'oz-elements';
|
|
612
|
+
|
|
613
|
+
const payload: CardSaleRequest = {
|
|
614
|
+
processor: 'elavon',
|
|
615
|
+
merchantId: '…',
|
|
616
|
+
amount: '49.00',
|
|
617
|
+
currency: 'USD',
|
|
618
|
+
ozuraCardToken: token,
|
|
619
|
+
ozuraCvcSession: cvcSession,
|
|
620
|
+
billingFirstName: billing.firstName,
|
|
621
|
+
billingLastName: billing.lastName,
|
|
622
|
+
billingEmail: billing.email!,
|
|
623
|
+
billingPhone: billing.phone!,
|
|
624
|
+
billingAddress1: billing.address!.line1,
|
|
625
|
+
billingCity: billing.address!.city,
|
|
626
|
+
billingState: billing.address!.state,
|
|
627
|
+
billingZipcode: billing.address!.zip,
|
|
628
|
+
billingCountry: billing.address!.country,
|
|
629
|
+
clientIpAddress: ip,
|
|
630
|
+
salesTaxExempt: false,
|
|
631
|
+
surchargePercent: '0.00',
|
|
632
|
+
tipAmount: '0.00',
|
|
633
|
+
};
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
## Testing
|
|
639
|
+
|
|
640
|
+
```bash
|
|
641
|
+
npm test # run once
|
|
642
|
+
npm run test:watch # watch mode
|
|
643
|
+
npm run test:coverage # with V8 coverage report
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
The test suite covers: Luhn algorithm, card brand detection, card number formatting, expiry validation, and error normalisation.
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## Development
|
|
651
|
+
|
|
652
|
+
```bash
|
|
653
|
+
npm install
|
|
654
|
+
npm run dev # build + serve demo at http://localhost:4242/demo/index.html
|
|
655
|
+
npm run build # production build
|
|
656
|
+
npm run watch # rebuild on save
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
Build outputs in `dist/`:
|
|
660
|
+
|
|
661
|
+
| File | Format | Use |
|
|
662
|
+
|---|---|---|
|
|
663
|
+
| `oz-elements.esm.js` | ESM | `import { OzVault } from 'oz-elements'` |
|
|
664
|
+
| `oz-elements.umd.js` | UMD | `<script>` tag / CDN |
|
|
665
|
+
| `frame/element-frame.js` | IIFE | loaded by element iframes |
|
|
666
|
+
| `frame/tokenizer-frame.js` | IIFE | loaded by tokenizer iframe |
|
|
667
|
+
| `react/index.esm.js` | ESM | `import ... from 'oz-elements/react'` |
|
|
668
|
+
| `react/index.cjs.js` | CJS | `require('oz-elements/react')` |
|
|
669
|
+
|
|
670
|
+
See [DEVELOPMENT.md](./DEVELOPMENT.md) for architecture detail, security model, and internal progress log.
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Roadmap
|
|
675
|
+
|
|
676
|
+
### v0.1 — Core SDK ✅
|
|
677
|
+
- [x] Iframe-isolated card number, CVV, and expiration date elements
|
|
678
|
+
- [x] Luhn validation, real-time formatting, card brand detection
|
|
679
|
+
- [x] postMessage protocol between host SDK and iframes
|
|
680
|
+
- [x] Tokenizer iframe — assembles card data and POSTs directly to vault
|
|
681
|
+
- [x] Style customization (base / focus / invalid states)
|
|
682
|
+
- [x] Rollup build — ESM, UMD, and IIFE frame bundles
|
|
683
|
+
- [x] Working demo page
|
|
684
|
+
- [x] CORS resolved for dev environment (local proxy server)
|
|
685
|
+
|
|
686
|
+
### v0.2 — Developer experience ✅
|
|
687
|
+
- [x] Card brand SVG icon inside the card number field (updates in real time)
|
|
688
|
+
- [x] Auto-advance focus: card number → expiry → CVV on completion
|
|
689
|
+
- [x] `element.clear()`, `element.update()`, `element.unmount()` fully wired
|
|
690
|
+
- [x] Field-level error strings in change events
|
|
691
|
+
- [x] `OzVault.destroy()` for full lifecycle cleanup
|
|
692
|
+
- [x] `OzVault.isReady` getter
|
|
693
|
+
- [x] `element.mount()` accepts `HTMLElement` as well as a CSS selector
|
|
694
|
+
- [x] Unit test suite (Vitest + jsdom) — 168 tests, 0 failures
|
|
695
|
+
- [ ] Published to npm as `oz-elements`
|
|
696
|
+
- [ ] CDN delivery for UMD build
|
|
697
|
+
|
|
698
|
+
### v0.3 — React ✅
|
|
699
|
+
- [x] `<OzElements>` provider — creates and owns an `OzVault` instance
|
|
700
|
+
- [x] `<OzCardNumber />`, `<OzExpiry />`, `<OzCvv />` field components
|
|
701
|
+
- [x] `useOzElements()` hook — `createToken` + `ready` flag
|
|
702
|
+
- [x] ESM + CJS React bundle, exported at `oz-elements/react`
|
|
703
|
+
- [ ] Published as a standalone `@oz/react-elements` package
|
|
704
|
+
|
|
705
|
+
### v1.0 — Production ready
|
|
706
|
+
- [x] Production iframe hosting at `elements.ozura.com`
|
|
707
|
+
- [ ] postMessage integration tests
|
|
708
|
+
- [ ] Security audit
|
|
709
|
+
- [ ] Public documentation site
|
|
710
|
+
- [ ] ACH / bank account element (pending Pay API support)
|
|
711
|
+
- [ ] Saved payment method support (returning customers)
|
|
712
|
+
|
|
713
|
+
---
|
|
714
|
+
|
|
715
|
+
## License
|
|
716
|
+
|
|
717
|
+
MIT — Copyright 2025 Ozura
|