> ## Documentation Index
> Fetch the complete documentation index at: https://docs.revolv3.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Revolv3 Web SDK

> A step-by-step guide for integrating hosted card fields and payment operations.

## 1. Overview

The Revolv3 Web SDK lets a merchant collect credit card details on their own checkout page without the raw card number, expiry, or CVV ever touching the merchant's JavaScript. Card inputs render inside a cross-origin iframe hosted by Revolv3; the merchant's page only ever sees tokenized results and field-level state (empty/complete/valid/error), never the underlying values.

There are two integration paths:

* **Collecting a fresh card** — `tokenize`, `sale`, `authorize`. These are handled inside the hosted iframe, because raw card data lives there.
* **Charging a previously saved card** — `saleWithPaymentMethod`, `authorizeWithPaymentMethod`. These call the Revolv3 API directly from the merchant's page, because the only input is an id — there's no raw card data to protect.
  <img src="https://mintcdn.com/revolv3/JIXRZs1KOvLEQPDt/images/docs/web-sdk-integration-paths.jpg?fit=max&auto=format&n=JIXRZs1KOvLEQPDt&q=85&s=09f76756ff21085fd36ec68e8ad12ea9" alt="Sequence diagram of the Revolv3 Web SDK flow between merchant backend, merchant page, hosted fields iframe, and Revolv3 API" width="1176" height="1069" data-path="images/docs/web-sdk-integration-paths.jpg" />

The SDK is loaded via a `<script src>` tag, as a self-contained bundle that doesn't require a build step on the merchant's side. It exposes a single global, `Revolv3WebSdk`, matching the library name baked into the build:

* **ESM** build

```html theme={null}
https://sdk.revolv3.com/latest/v0/revolv3.js
```

* **UMD** build

```html theme={null}
https://sdk.revolv3.com/latest/v0/revolv3.umd.js
```

## 2. Quick Start

### Step 1 — Mint a session and payment key (merchant backend)

Before any client-side code runs, the merchant's own backend — authenticated the same way it already authenticates to the rest of the Revolv3 API — must call two Revolv3 endpoints. The demo app's small local backend wraps these two calls behind its own `/session` and `/ott/{sessionId}` routes; a real merchant backend calls Revolv3 directly.

**1. Register a session for this checkout attempt:**

```
POST /api/web-sdk/auth/order-session
```

```js theme={null}
// request body
{
  sessionId: 'a string your backend generates, ≤500 chars', // Revolv3 does not mint this for you
  amount: 49.99,
  currency: 'USD', // a supported currency code
}
```

```js theme={null}
// response
{
  sessionId: '...',
  statusType: 'Pending', 
  expiresAt: '2026-07-27T18:00:00Z', // 30 minutes after creation, by default
}
```

The `amount`/`currency` you register here is what actually gets charged later — the browser-side `sale`/`authorize` call can never override it, so a tampered client can't change the price.

**2. Mint a one-time payment key, immediately before each submit attempt:**

```
POST /api/web-sdk/auth/ott
```

```js theme={null}
// request body
{ clientId: 'a client identifier, ≤500 chars', sessionId: '...' }
```

```js theme={null}
// response
{ token: '...', expiresIn: 60, expirationTime: '2026-07-27T17:31:00Z' }
```

The token expires \~60 seconds after issue by default and is **single-use** — it's consumed the moment a payment call using it is accepted, so mint a new one for every attempt (this is why the example in §2 Step 5 fetches it right before `sale`, not once up front). This endpoint is rate-limited (10 requests per 60 seconds per calling IP, by default).

**If a payment attempt fails**, the session moves to a `Failed` status and can't be used for another attempt as-is. Call `POST /api/web-sdk/auth/order-session/{sessionId}/reset` first (this only works while the session is `Failed` or `Expired`), then mint a new payment key and retry.

### Step 2 — Add a mount point

```html theme={null}
<div id="fields-container"></div>
```

`create()` replaces this element's children with a single iframe containing all four card inputs, so it should be an otherwise-empty container sized by your page's CSS.

### Step 3 — Initialize and mount

```js theme={null}
const hostedFields = Revolv3.init({
  sessionId: sessionId,       // from step 1
  environment: 'sandbox',     // or 'prod' — this alone selects both the iframe and API endpoints
});

await hostedFields.create({
  selector: '#fields-container',
  styles: {
    '--revolv3-border-focus-color': '#6366f1',
  },
});
```

`create()` rejects after 15 seconds if the iframe never finishes its setup handshake, so a genuinely broken network fails fast rather than hanging forever.

### Step 4 — Listen for state

```js theme={null}
hostedFields.on('change', function (event) {
  // event.field, event.state.isValid, event.state.error && event.state.error.code
});
```

### Step 5 — Submit

```js theme={null}
submitButton.addEventListener('click', async function () {
  const paymentKey = await getPaymentKey(sessionId); // fresh key per attempt
  try {
    const result = await hostedFields.sale(paymentKey, {
      merchantInvoiceRefId: 'your-own-invoice-reference', // required
      billingAddress: {
        addressLine1: '100 Main St',
        city: 'Santa Ana',
        state: 'CA',
        postalCode: '90000',
        country: 'US',
        email: 'buyer@example.com',
      },
    });
    // result.invoiceAttemptStatus tells you the outcome — see §6
  } catch (error) {
    // error is a plain error object — see §8
  }
});
```

### Step 6 — Verify server-side

Treat the resolved result as informational only for UI purposes. There is no dedicated status-check endpoint under the web-sdk API for your backend to poll by `sessionId` — instead, configure a webhook per [Revolv3's webhook docs](https://docs.revolv3.com/docs/webhook-events/webhook) and treat that as the authoritative source of truth for the outcome, rather than the client-side promise result.

The two events relevant to a payment made through this SDK:

* `InvoiceStatusChanged` — fires on transitions of the overall invoice status (the same `invoiceStatus` values from §6).
* `InvoiceAttemptStatusChanged` — fires when a specific payment attempt's status changes (the same `invoiceAttemptStatus` values from §6).

Each delivery is a JSON body (`InvoiceId`, `InvoiceStatus`, `Subtotal`, `Total`, `NetworkTransactionId`, `EventDateTime`, `EventType`, `RevolvMerchantId`, etc.) signed with HMAC-SHA256 in an `x-revolv3-signature` header — verify that signature before trusting the payload, respond `200` immediately and process asynchronously, and handle the delivery idempotently since webhooks can be redelivered.

## 3. Configuration Reference

### The object passed to `init()`

| Field | Type | Required | Description |
| - | - | - | - |
| `sessionId` | string | yes | Session identifier minted by the merchant backend (§2, step 1). Sent on every request and re-checked by both sides of the iframe boundary. |
| `environment` | string | yes | Exactly `'sandbox'` or `'prod'`. Determines which iframe **and** which API the SDK talks to — see below. |

That's the whole config object now — there is no merchant identifier and no origin URL to supply. `environment` alone resolves both the hosted-fields iframe URL and the API origin internally; an unrecognized value throws rather than silently defaulting to one or the other. Both are baked into the SDK build itself ("both environments ship in every build, so a single published bundle serves sandbox and prod" per the source comment), not configured per-integration.

### The object passed to `create()`

| Field | Type | Required | Description |
| - | - | - | - |
| `selector` | string | yes | CSS selector for the mount container. Must resolve to exactly one element already in the DOM when `create()` is called, or it throws `Container not found: <selector>`. |
| `styles` | object | no | CSS custom-property overrides, as plain `{ '--revolv3-...': 'value' }` pairs — see §4 Styling. |
| `fonts` | array | no | Custom web fonts to load into the hosted fields — see §4 Custom Fonts. |

## 4. Hosted Fields

### Field types

One iframe renders all four inputs together — there is no per-field mount API. `selector` targets a single container; the hosted-fields app fills it with a form containing:

| Field identifier (`event.field`) | `autocomplete` | Notes |
| - | - | - |
| `cardholderName` | `cc-name` | Free text, max 80 chars. |
| `paymentAccountNumber` | `cc-number` | Numeric keypad on mobile, 8–31 raw chars incl. spaces. |
| `expirationDate` | `cc-exp` | Displayed and typed as `MM / YY`. |
| `securityCode` | `cc-csc` | 3–4 digits. |

Layout is responsive: expiry and CVV sit side-by-side above 420px width and stack to one column below it, automatically.

### Styling

Every visual property is a CSS custom property scoped to the iframe document — merchants set values, never selectors, by passing plain string keys in the `styles` object. Unrecognized property names and values that fail a basic safety check (see §9) are dropped silently, not applied and not thrown back to the merchant page.

| CSS custom property | Default |
| - | - |
| `--revolv3-font-family` | system font stack |
| `--revolv3-color` | `#1a1a2e` |
| `--revolv3-font-size` | `15px` |
| `--revolv3-font-style` | `normal` |
| `--revolv3-font-variant` | `normal` |
| `--revolv3-font-weight` | `400` |
| `--revolv3-line-height` | `1.5` |
| `--revolv3-letter-spacing` | `normal` |
| `--revolv3-border-focus-color` | `#6366f1` |
| `--revolv3-border-error-color` | `#dc2626` |
| `--revolv3-placeholder-color` | `#9ca3af` |
| `--revolv3-selection-background` | `rgba(99,102,241,.12)` |
| `--revolv3-selection-color` | `inherit` |
| `--revolv3-autofill-background` | `#ffffff` |
| `--revolv3-autofill-color` | `inherit` |
| `--revolv3-background` | `#ffffff` |
| `--revolv3-border` | `1px solid #d1d5db` |
| `--revolv3-border-radius` | `4px` |
| `--revolv3-padding` | `12px 14px` |
| `--revolv3-icon-color` | `#9ca3af` |
| `--revolv3-hover-border` | `#d1d5db` |
| `--revolv3-disabled-opacity` | `0.6` |
| `--revolv3-opacity` | `1` |
| `--revolv3-shadow-focus` | `0 0 0 3px rgba(99,102,241,.12)` |
| `--revolv3-shadow-error` | `0 0 0 3px rgba(220,38,38,.12)` |
| `--revolv3-label-font-size` | `14px` |
| `--revolv3-error-font-size` | `13px` |
| `--revolv3-layout-gap` | `8px` |
| `--revolv3-field-gap` | `6px` |

```js theme={null}
await hostedFields.create({
  selector: '#fields-container',
  styles: {
    '--revolv3-font-family': "'Inter', sans-serif",
    '--revolv3-border-focus-color': '#0ea5e9',
    '--revolv3-border-error-color': '#dc2626',
  },
});
```

Values are re-applied every time `create()` completes its handshake; there's no separate "update styles" call — remount (`destroy()` then `create()` again) to change styling after the fact.

### Custom Fonts

If your brand font isn't a system font, load it into the hosted fields via `fonts` and then reference it from `--revolv3-font-family`:

```js theme={null}
await hostedFields.create({
  selector: '#fields-container',
  fonts: [
    { family: 'Inter', src: 'https://fonts.gstatic.com/s/inter/v.../file.woff2' },
  ],
  styles: {
    '--revolv3-font-family': "'Inter', sans-serif",
  },
});
```

Each entry is `{ family, src }`. Constraints, enforced silently (an entry that fails any of these is just dropped — no error is thrown or surfaced to your code, only a `console.warn` inside the iframe):

* Up to 8 fonts per `create()` call.
* `src` must be an absolute **HTTPS** URL ending in `.woff2`, `.woff`, `.ttf`, or `.otf`.
* The font's host must be **either** `fonts.gstatic.com` **or the same origin as your merchant page**. A self-hosted font file must therefore be served from your own domain, and that response needs an `Access-Control-Allow-Origin` header permitting the hosted-fields iframe's origin, or the browser will block the cross-origin font load even though the URL passed the SDK's own check.
* Requires the browser to support the `FontFace` API — on browsers that don't, `fonts` is silently ignored rather than breaking the rest of `create()`.

## 5. Events

Subscribe with `hostedFields.on(eventName, callback)`, unsubscribe with `hostedFields.off(eventName, callback)`.

| Event name | Fires when | Payload |
| - | - | - |
| `'ready'` | The iframe has finished setup — also resolves `create()`'s promise. | `{ state }` |
| `'change'` | Any field's value changes. | `{ field, state }` |
| `'focus'` | A field gains focus. | `{ field, state }` |
| `'blur'` | A field loses focus. | `{ field, state }` |
| `'submitStart'` | `tokenize`/`sale`/`authorize`/`saleWithPaymentMethod`/`authorizeWithPaymentMethod` was called. | `{ operation }` |
| `'submitComplete'` | The same operation finished — **always fires**, whether it succeeded or failed. | `{ operation }` |

A `state` object looks like this:

```js theme={null}
{
  isEmpty: false,
  isComplete: true,   // user finished typing — independent of correctness
  isValid: true,
  error: undefined,   // or { code: 'invalid_number', message: 'Enter a valid card number.' }
}
```

> `submitStart`/`submitComplete` report nothing about success or failure — they exist purely to drive UI like a global spinner around the call. Whether the operation succeeded must come from the resolved value or thrown error of the `tokenize`/`sale`/`authorize` call itself, not from these events. In particular, a **200-level response can still represent a declined payment** (see `invoiceAttemptStatus` in §6) — `submitComplete` fires the same way either way.

> Errors are intentionally suppressed while a field is still plausibly mid-entry: `state.error` stays absent until a field is either complete or has been blurred. A `change` listener wired up to show inline errors won't show anything for a half-typed card number, by design.

## 6. Payment Operations

These three operations collect a fresh card and are handled inside the hosted iframe. **Each now requires the merchant to supply its own reference id** in the request — these are not optional, and a missing one rejects the call before anything is sent over the wire (for `sale`/`authorize`; see the validation note under §8 for `tokenize`'s different handling).

| Call | Use when | Required field on `request` |
| - | - | - |
| `hostedFields.tokenize(paymentKey, request)` | You want a reusable payment method id without charging anything yet. | `merchantPaymentMethodRefId` (string, ≤100 chars) |
| `hostedFields.sale(paymentKey, request)` | Charge the card immediately. | `merchantInvoiceRefId` (string, ≤100 chars) |
| `hostedFields.authorize(paymentKey, request)` | Reserve funds now, capture later. | `merchantPaymentMethodRefId` (string, ≤100 chars) |

`request` also accepts the optional `billingAddress` object from §8 on all three. `sale` additionally accepts an optional `merchantPaymentMethodRefId`.

All three return a promise. `request` is optional and, if provided, is a plain object with one recognized property:

```js theme={null}
{
  billingAddress: { /* see §8 for fields */ }
}
```

**Result shapes** (what the promise resolves to):

```js theme={null}
// tokenize()
{
  paymentMethodId: 98765,
  billingAddressId: 111,
  billingAddress: { /* echoed back */ },
  billingFirstName: 'Jane',
  billingLastName: 'Doe',
  merchantPaymentMethodRefId: 'your-own-ref-id', // echoes what you sent
  paymentMethodAchDetails: null,
  paymentMethodCreditCardDetails: {
    binNumber: '424242',
    paymentLast4Digit: '4242',
    paymentExpirationDate: '12/29',
    accountUpdateMessage: null,
    accountUpdateDateTime: null,
    accountUpdateCode: null,
  },
}

// sale()
{
  invoiceId: 2,
  merchantInvoiceRefId: 'your-own-ref-id',
  merchantPaymentMethodRefId: null, // present only if you sent one
  networkTransactionId: '...',
  invoiceStatus: '...',
  invoiceAttemptStatus: '...',   // <- read this for approved/declined, not HTTP status
  message: '...',
  amount: { currency: 'USD', value: 100 },
  paymentMethodId: 98765,
  paymentMethodTypeId: 1,
  paymentProcessor: '...',
  processorMerchantId: '...',
  rawResponse: '...',
  paymentMethodCreditCardDetails: { /* same shape as tokenize's */ },
  responseMessage: '...',
  responseCode: '...',
  processorResponseDateTime: null,
  authCode: null,
  processorTransactionId: null,
  revolv3ResponseCode: null,
  revolv3ResponseMessage: null,
}

// authorize() — a differently-shaped result, not the same as sale()'s
{
  networkTransactionId: '...',
  paymentMethodAuthorizationId: 555, // the id an eventual capture would reference — see note below
  paymentMethod: {
    paymentMethodId: 98765,
    billingAddressId: 111,
    billingAddress: { /* ... */ },
    billingFirstName: 'Jane',
    billingLastName: 'Doe',
    merchantPaymentMethodRefId: 'your-own-ref-id',
    paymentMethodCreditCardDetails: { /* ... */ },
  },
  paymentProcessor: null,       
  processorMerchantId: null,    
  responseMessage: '...',
  responseCode: '...',
  rawResponse: null,            
  message: null,                  
  processorResponseDateTime: null, 
  authCode: null,                  
  processorTransactionId: null,   
  revolv3ResponseCode: null,   
  revolv3ResponseMessage: null,   
}

```

`invoiceStatus and invoiceAttemptStatus (on tokenize/sale, not currently on authorize) are two different things — invoiceStatus is the invoice's overall lifecycle state, invoiceAttemptStatus is the outcome of this specific charge attempt:`

| `invoiceAttemptStatus` | `Meaning` |
| - | - |
| `Success` | `Approved.` |
| `RetrySuccess` | `Approved on a retry.` |
| `Fail` | `Declined.` |
| `RetryFail` | `Declined on a retry.` |
| `Pending / RetryPending` | `Still resolving — treat as not-yet-final.` |
| `ConnectivityIssue / RetryConnectivityIssue` | `Couldn't reach the processor.` |

| `invoiceStatus` | `Meaning` |
| - | - |
| `Paid, MerchantPaid` | `Settled.` |
| `Pending, OneTimePaymentPending, BatchPending, CapturePending, RecurringPending, MultiCardsPending, RetryPending` | `Some stage of in-progress.` |
| `Failed` | `Did not settle.` |
| `Void, MerchantCancelled` | `Cancelled.` |
| `Refund, PartialRefund, RefundPending, RefundDeclined, RefundFailed, RefundVoid, PartialRefundVoid` | `Refund-related.` |
| `Recycle, Noncollectable` | `Collections-related.` |

### Concurrency & timeouts

Only **one payment operation can be in flight at a time** per SDK instance. Calling `tokenize`/`sale`/`authorize`/`saleWithPaymentMethod`/`authorizeWithPaymentMethod` while a previous call hasn't resolved yet rejects immediately with `"<operation> already in progress"` — it does not queue. Disable your submit button between `submitStart` and `submitComplete` to avoid this in normal use. This is also enforced server-side, independently — see §9.

Each call auto-rejects with `"<operation> timed out."` if no response arrives within **30 seconds**.

## 7. Saved Payment Methods

`saleWithPaymentMethod` and `authorizeWithPaymentMethod` charge or authorize an **already-tokenized** card by payment method id. Because there's no raw card data involved, these calls go **directly from the merchant's page to the Revolv3 API** — they don't involve the iframe at all, which is a different trust model from §6 worth calling out explicitly to readers coming from that section.

```js theme={null}
await hostedFields.saleWithPaymentMethod(paymentKey, {
  paymentMethodId: 12345,                       // required, integer
  merchantInvoiceRefId: 'your-own-invoice-ref',  // required, string, ≤100 chars
});

await hostedFields.authorizeWithPaymentMethod(paymentKey, {
  paymentMethodId: 12345, // required, integer — no reference id required here
});
```

**Result shapes** differ between the two, and not just by field count:

```js theme={null}
// saleWithPaymentMethod() — matches sale()'s expanded shape from §6
{
  invoiceId: 2,
  merchantInvoiceRefId: '...',
  merchantPaymentMethodRefId: null,
  networkTransactionId: '...',
  invoiceStatus: '...',
  invoiceAttemptStatus: '...',
  message: '...',
  amount: { currency: 'USD', value: 100 },
  paymentMethodId: 12345,
  paymentMethodTypeId: 1,
  paymentProcessor: '...',
  processorMerchantId: '...',
  rawResponse: '...',
  paymentMethodCreditCardDetails: null, // nullable here — no fresh card read to report on
  responseMessage: '...',
  responseCode: '...',
  processorResponseDateTime: null,
  authCode: null,
  processorTransactionId: null,
  revolv3ResponseCode: null,
  revolv3ResponseMessage: null,
}

// authorizeWithPaymentMethod() — the OLD flat shape, not the nested one authorize() now returns
{
  customerId: 1,   // the only response in the whole SDK that still has this field
  invoiceId: 2,
  networkTransactionId: '...',
  invoiceStatus: '...',
  invoiceAttemptStatus: '...',
  message: '...',
  amount: { currency: 'USD', value: 100 },
  paymentMethodId: 12345,
  paymentMethodTypeId: 1,
  paymentProcessor: '...',
  processorMerchantId: '...',
  processorResponseDateTime: null,
  processorTransactionId: null,
}
```

These two calls still participate in the same `submitStart`/`submitComplete`/single-in-flight/timeout behavior as §6.

## 8. Validation & Error Handling

### Field-level errors

An error's `code` is stable and safe to branch on; `message` is an overridable English default, not a contract.

| Code | Meaning |
| - | - |
| `'empty'` | Field has no value. |
| `'invalid_cardholder_name'` | Name too short. |
| `'invalid_number'` | Card number fails format/length/Luhn check. |
| `'invalid_security_code'` | CVV wrong length. |
| `'invalid_expiry_date'` | Expiry not in `MM / YY` format or an invalid month. |
| `'expired_card'` | Expiry is validly formatted but in the past. |

Validation rules applied live inside the iframe:

| Field | Rule |
| - | - |
| Card number | Digits only after stripping whitespace; 8–25 digits; passes a Luhn check. |
| Expiry | `MM / YY` format, month 1–12, not in the past relative to the current month/year. |
| Security code | 3–4 characters. |
| Cardholder name | Trimmed length ≥ 2. |

If a merchant calls `sale`/`authorize` (or their "with payment method" counterparts) with incomplete or invalid fields, the SDK short-circuits **before any network call** with a 422-style error (`"Please complete all required fields."`), and forces every field's error state to be revealed/broadcast — so `change` listeners will see errors for fields the user never touched.

### Request-level validation

Before `sale`, `authorize`, `saleWithPaymentMethod`, or `authorizeWithPaymentMethod` sends anything, the SDK checks the request object's own shape — including the required reference id fields from §6/§7 — and rejects with a validation error (not the usual `{message, statusCode, details}` shape from below) if something's missing or too long. `tokenize` **does not run this pre-flight check** — an invalid or missing `merchantPaymentMethodRefId` passed to `tokenize` is sent as-is and only rejected (if at all) by the server.

### `billingAddress` (optional, on all four card-collecting requests)

| Field | Constraint |
| - | - |
| `addressLine1` | required, ≤ 3000 chars |
| `addressLine2` | ≤ 3000 chars |
| `city` | ≤ 3000 chars |
| `state` | ≤ 2 chars |
| `postalCode` | 2–10 chars |
| `phoneNumber` | 7–20 chars |
| `email` | valid email |
| `country` | ISO 3166-1 alpha-2 |

### Error shape

Every rejected payment call throws a plain error-like object:

```js theme={null}
{
  message: 'Card was declined',
  statusCode: 402,   // optional
  details: '...',    // optional
}
```

```js theme={null}
try {
  await hostedFields.sale(paymentKey, request);
} catch (error) {
  console.error(error.message, error.statusCode, error.details);
}
```

## 9. Security Model

* Raw PAN, expiry, and CVV are read, formatted, and validated entirely inside the hosted-fields iframe. The merchant page never receives these values in any event payload or result — only tokenized/masked results (payment method id, last-4, BIN).
* The iframe is loaded with a locked-down sandbox (scripts, forms, and same-origin allowed; nothing else) and sends no referrer.
* Every cross-window message is checked on both sides: the merchant page only accepts messages from the iframe origin resolved for the chosen `environment`, **whose **`event.source`** is the exact iframe window the SDK itself created** (not just a matching origin string) and whose session id matches the current session; the iframe applies the equivalent origin + session checks against the calling page before acting on anything.
* On mount, the iframe calls a Revolv3 endpoint to verify that the merchant/session/origin triple is authorized before revealing the card form at all — a failed check hides the fields and rejects `create()`'s promise with the iframe's error message, instead of silently resolving.
* Style values are filtered twice: only recognized CSS custom property names are considered, and values are rejected if empty, over 256 characters, or contain `url(`, `@import`, `expression(`, `javascript:`, or a backtick — a basic CSS-injection guard. Rejected values are dropped silently rather than surfaced as an error your code can catch.
* Custom fonts (§4) go through their own allowlist — HTTPS only, a fixed set of font file extensions, and a host restricted to Google Fonts or the merchant's own page origin — independent of the style-value guard above.
* **The charge amount is locked in server-side, not client-supplied.** `amount`/`currency` are set once when the merchant backend registers the order session (§2, step 1) and are read from that stored session on every payment call — nothing the browser sends can change what's charged.
* **The "one payment operation at a time" behavior described in §6 is also enforced server-side**, independently of the SDK's own in-memory guard: each order session can only move from its `Pending` status to `Processing` once. A second concurrent attempt against the same session is rejected by the server even if the client-side guard were somehow bypassed. A failed attempt leaves the session in a `Failed` status that must be explicitly reset (§2, step 1) before another attempt is possible.
* **The payment key is single-use** — the server invalidates it the moment a payment call using it is accepted, independent of the client-side "already in progress" check.
* The domain-verification call the iframe makes on mount sends only `{ sessionId, origin }` — no merchant/client identifier at all. That's consistent with the backend: the owning merchant is resolved entirely from the order session record (established under the merchant backend's own authenticated identity in §2, step 1).

## 10. Full Example

```html theme={null}
<script src="https://sdk.revolv3.com/latest/v0/revolv3.umd.js"></script>
<div id="fields-container"></div>
```

```js theme={null}
const sessionId = await getSessionId();

const hostedFields = Revolv3.init({
  sessionId: sessionId,
  environment: 'sandbox',
});

hostedFields.on('submitStart', function () { setBusy(true); });
hostedFields.on('submitComplete', function () { setBusy(false); });
hostedFields.on('change', function (event) { renderFieldState(event); });
hostedFields.on('blur', function (event) { renderFieldState(event); });

await hostedFields.create({
  selector: '#fields-container',
  styles: {
    '--revolv3-font-family': "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
    '--revolv3-color': '#0f172a',
    '--revolv3-border-focus-color': '#627800',
    '--revolv3-border-error-color': '#dc2626',
  },
});

submitButton.addEventListener('click', async function () {
  submitButton.textContent = 'Processing...';
  const paymentKey = await getPaymentKey(sessionId); // fresh key per attempt
  try {
    const result = await hostedFields.sale(paymentKey, {
      billingAddress: {
        addressLine1: '100 Main Street',
        city: 'Santa Ana',
        state: 'CA',
        postalCode: '90000',
        phoneNumber: '555-123-4567',
        email: 's@gmail.com',
        country: 'US',
      },
    });
    showSuccess(result.paymentMethodId, result.invoiceAttemptStatus);
  } catch (error) {
    showError(error.message);
  } finally {
    submitButton.textContent = 'Submit';
  }
});
```
