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

# Server SDK

> Process payments, query transactions, create payment sessions, and handle errors on your backend with @ozura/elements/server.

`@ozura/elements/server` is the server-side counterpart to the Elements browser SDK. It wraps the OzuraPay API and Vault API with typed methods, automatic field mapping, retry logic, and error handling.

```ts theme={null}
import { Ozura, OzuraError, getClientIp, createSessionMiddleware } from '@ozura/elements/server';
```

> **Next.js / Fetch API users:** use `createSessionHandler` instead of `createSessionMiddleware`. Both are exported from the same package. See [createSessionHandler](#createsessionhandler-nextjs--fetch-api) below.

<Note>
  **ESM vs CJS:** The package ships both ESM (`import`) and CommonJS (`require`) builds. Code examples throughout use ESM `import` syntax. If your project uses CommonJS (no `"type": "module"` in `package.json`, or a `.cjs` file), swap to `require`:

  ```js theme={null}
  const { Ozura, OzuraError, getClientIp, createSessionMiddleware } =
    require('@ozura/elements/server');
  ```

  Both builds are identical in behaviour — only the module format differs.
</Note>

<Warning>
  This module runs on your **server** (Node.js, Deno, Bun, etc.). Never import it in browser code — it requires your vault key and merchant API key, which must stay secret.
</Warning>

<Note>
  **Node.js requirement:** `@ozura/elements/server` requires Node.js **≥ 18**. Versions below 18 lack the native `fetch` API the package depends on.
</Note>

***

## Quick Start

```ts theme={null}
import { Ozura } from '@ozura/elements/server';

// Full integration (mint + tokenize + charge)
const ozura = new Ozura({
  merchantId: process.env.MERCHANT_ID!,
  apiKey:     process.env.MERCHANT_API_KEY!,
  vaultKey:   process.env.VAULT_API_KEY!,
});

// Charge a tokenized card (token comes from the frontend Elements SDK)
const result = await ozura.cardSale({
  token:           tokenResponse.token,
  cvcSession:      tokenResponse.cvcSession,
  amount:          '49.00',
  currency:        'USD',
  billing:         tokenResponse.billing,
  clientIpAddress: getClientIp(req),
});

console.log(result.transactionId);
```

**Tokenize-only setup** — vault-only merchants (using their own processor or just storing tokens) don't need `merchantId` or `apiKey`:

```ts theme={null}
const ozura = new Ozura({
  vaultKey: process.env.VAULT_API_KEY!,
  // No merchantId/apiKey — those are only required for cardSale().
  // For sandbox, use a test vault key from a Test project at ozuravault.com.
});
// Use ozura.createSession() — cardSale() will throw if called without merchantId/apiKey
```

***

## Configuration

```ts theme={null}
new Ozura(config: OzuraConfig)
```

| Option       | Type     | Required          | Default                                            | Description                                                                                                                           |
| ------------ | -------- | ----------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `merchantId` | `string` | Charge flows only | —                                                  | Your Ozura merchant ID. Required for `cardSale()`. Omit for tokenize-only integrations.                                               |
| `apiKey`     | `string` | Charge flows only | —                                                  | Merchant OzuraPay API key (sent as `x-api-key` on cardSale requests). Required for `cardSale()`. Omit for tokenize-only integrations. |
| `vaultKey`   | `string` | ✅                 | —                                                  | Vault API secret key — used to create sessions. **Never expose in browser code.**                                                     |
| `apiUrl`     | `string` | —                 | Ozura's hosted API URL for the installed package   | OzuraPay API base URL override                                                                                                        |
| `vaultUrl`   | `string` | —                 | Ozura's hosted Vault URL for the installed package | Vault API base URL override (for `createSession` and `revokeSession`)                                                                 |
| `timeoutMs`  | `number` | —                 | `30000`                                            | Request timeout in milliseconds                                                                                                       |
| `retries`    | `number` | —                 | `2`                                                | Max retry attempts for 5xx / network errors on `listTransactions` and `createSession`. `cardSale` never retries.                      |

<Info>
  **Tokenize-only integrations** (create sessions + tokenize cards, no charging) only need `vaultKey`. The `merchantId` and `apiKey` fields are validated lazily — they are only required when you call `cardSale()`.
</Info>

<Warning>
  Store all credentials in environment variables. Never hardcode them in source code or commit them to version control.
</Warning>

***

## Methods

### cardSale

Charge a tokenized card. Maps the `CardSaleInput` shape to the OzuraPay API's flat field format.

```ts theme={null}
ozura.cardSale(input: CardSaleInput): Promise<CardSaleResponseData>
```

<Note>
  `cardSale` **never retries**, regardless of the `retries` config. Financial POSTs are not idempotent — retrying a failed `cardSale` could cause a double charge. If you receive a 5xx response, check transaction records before retrying manually.
</Note>

<Note>
  **5xx de-duplication pattern.** If `cardSale` throws with a 5xx status, the charge may or may not have been processed — the request could have timed out after the processor accepted it. Before retrying or failing the order, query `listTransactions` to check for a matching charge:

  ```ts theme={null}
  try {
    result = await ozura.cardSale({ token, cvcSession, amount, billing, clientIpAddress });
  } catch (err) {
    if (err instanceof OzuraError && err.statusCode >= 500) {
      // Check whether a charge already landed
      const twoMinAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
      const { transactions } = await ozura.listTransactions({
        dateFrom: twoMinAgo,
        transactionType: 'CreditCardSale',
      });
      // Match by amount and the last-4 returned in billing metadata
      const duplicate = transactions.find(tx =>
        tx.transactionType === 'CreditCardSale' &&
        tx.amount === amount
      );
      if (duplicate) {
        // Charge succeeded — use duplicate.transactionId
        result = duplicate;
      } else {
        throw err; // safe to surface to the user
      }
    } else {
      throw err;
    }
  }
  ```

  For stricter de-duplication, pass your own `orderId` in `createSession` when creating the session. You can filter `listTransactions` on a wider window and match by correlating your `orderId` to the transaction metadata.
</Note>

**Rate limit:** 100 requests/minute per merchant.

```ts theme={null}
const result = await ozura.cardSale({
  token:      tokenResponse.token,
  cvcSession: tokenResponse.cvcSession,
  amount:     '49.00',
  currency:   'USD',
  billing: {
    firstName: 'Jane',
    lastName:  'Smith',
    email:     'jane@example.com',
    phone:     '+15551234567',
    address: {
      line1:   '123 Main St',
      city:    'San Francisco',
      state:   'CA',
      zip:     '94102',
      country: 'US',
    },
  },
  clientIpAddress: getClientIp(req),
});

// Always store result.transactionId — required for refunds
console.log(result.transactionId, result.cardLastFour);
```

<Info>
  You can pass `tokenResponse.billing` directly from the frontend — the browser SDK validates and normalizes it for you.
</Info>

***

### listTransactions

List transactions by date range with pagination. Returns all transaction types.

```ts theme={null}
ozura.listTransactions(input?: ListTransactionsInput): Promise<ListTransactionsResult>
```

**Rate limit:** 200 requests/minute per merchant.

```ts theme={null}
// Look up a single transaction by ID
const { transactions } = await ozura.listTransactions({
  transactionId: 'txn_abc123',
});

// Or list by date range with pagination
const { transactions: txns, pagination } = await ozura.listTransactions({
  dateFrom:        '2025-01-01 00:00:00',
  dateTo:          '2025-12-31 23:59:59',
  transactionType: 'CreditCardSale',
  page:            1,
  limit:           25,
  sortBy:          'createdAt',
  sortOrder:       'desc',
});

if (pagination.hasNextPage) {
  // fetch next page with page + 1
}
```

***

<h3 id="createsession">
  createSession
</h3>

Creates a short-lived payment session key from the vault. This is the server-side companion to `sessionUrl` / `getSessionKey` on the frontend SDK.

```ts theme={null}
ozura.createSession(options?: CreateSessionOptions): Promise<CreateSessionResult>
```

| Option          | Type                     | Required | Description                                                                                                                                                                                      |
| --------------- | ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sessionId`     | `string`                 | —        | The UUID the SDK generates and forwards through your session endpoint. Pass it here for vault-side correlation and audit.                                                                        |
| `sessionLimit`  | `number \| null`         | —        | Maximum card submissions this session accepts before the vault marks it consumed. Default: `3`. Pass `null` to remove the cap (unlimited). Must match `VaultOptions.sessionLimit` on the client. |
| `maxProxyCalls` | `number \| null`         | —        | Maximum vault proxy calls. Omit for most integrations. Pass `null` to remove the cap.                                                                                                            |
| `orderId`       | `string`                 | —        | Your order ID — stored on the session for support lookups and transaction correlation.                                                                                                           |
| `customerId`    | `string`                 | —        | Your customer ID — stored on the session for support lookups.                                                                                                                                    |
| `cartId`        | `string`                 | —        | Your cart/basket ID — stored on the session for support lookups.                                                                                                                                 |
| `metadata`      | `Record<string, string>` | —        | Arbitrary key/value pairs stored on the session. Max 16 keys, 64-char keys, 256-char values.                                                                                                     |
| `ttlSeconds`    | `number`                 | —        | Session lifetime in seconds (60–1800). Default: `1800` (30 min). Shorten for flows where a tighter TTL is desirable.                                                                             |

<Warning>
  **Keep `sessionLimit` in sync with the client.** Set the same value in `VaultOptions.sessionLimit` when calling `OzVault.create()` — see [Installation → VaultOptions](/sdks/elements/installation#ozvaultcreate-options). A mismatch means the vault may reject a tokenize call before the client expects a refresh, causing a user-visible delay mid-checkout.
</Warning>

```ts theme={null}
// Next.js App Router — manual implementation (or use createSessionHandler)
export async function POST(req: Request) {
  const { sessionId } = await req.json();
  const { sessionKey } = await ozura.createSession({
    sessionId,
    sessionLimit: 3,     // must match VaultOptions.sessionLimit on the client
    orderId:     order.id,   // optional — correlate Ozura records with your own DB
    customerId:  user.id,
    metadata:    { source: 'web' },
  });
  return Response.json({ sessionKey });
}
```

**CreateSessionResult:**

```ts theme={null}
{
  sessionKey:       string;  // short-lived session credential
  expiresInSeconds: number;  // TTL, typically 1800 (30 minutes)
}
```

***

### revokeSession

Revoke a payment session key. Best-effort — never throws. Call this on all three session-end paths to close the exposure window before the 30-minute vault TTL elapses. The SDK's proactive/reactive refresh and `sessionLimit` are defence-in-depth layers — explicit revocation is the primary closure mechanism.

```ts theme={null}
ozura.revokeSession(sessionKey: string): Promise<void>
```

**Wire all three exit paths:**

```ts theme={null}
// 1. Payment success — revoke immediately after cardSale resolves
const result = await ozura.cardSale({ ... });
await ozura.revokeSession(sessionKey);  // session is spent; close the window immediately

// 2. User cancels checkout — revoke in your cancel route
// (store sessionKey server-side when the session is created — see pattern below)
app.post('/api/cancel', async (req, res) => {
  const { sessionKey } = await db.sessions.get(req.body.sessionId);
  await ozura.revokeSession(sessionKey);
  res.json({ ok: true });
});

// 3. Page/tab close — best-effort via sendBeacon
// (browser may not deliver fetch on unload; sendBeacon survives navigation)
window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    navigator.sendBeacon('/api/cancel', JSON.stringify({ sessionId }));
  }
});
```

### Persisting session state for cancel/revoke

`revokeSession(sessionKey)` requires the `sessionKey` — but the browser SDK never exposes the session key to the page (it is kept inside the vault internals for security). To call `revokeSession` on cancel, **your session route must store the mapping** when the session is first created.

The `sessionId` sent by the browser SDK to your session route is a UUID you can use as the lookup key. Store it server-side (e.g. Redis with the session TTL, a DB row, or an in-memory map for simple cases) when you create the session, then look it up when the cancel route fires.

```ts theme={null}
// Session route — store sessionKey for later revocation
app.post('/api/oz-session', async (req, res) => {
  const { sessionId } = req.body;

  const { sessionKey, expiresInSeconds } = await ozura.createSession({ sessionId });

  // Store sessionId → sessionKey with a TTL matching the session
  await redis.setex(`session:${sessionId}`, expiresInSeconds, sessionKey);

  res.json({ sessionKey });
});

// Cancel route — look up and revoke
app.post('/api/cancel', async (req, res) => {
  const { sessionId } = req.body;
  if (!sessionId) return res.status(400).json({ error: 'sessionId required' });

  const sessionKey = await redis.get(`session:${sessionId}`);
  if (sessionKey) {
    await ozura.revokeSession(sessionKey);
    await redis.del(`session:${sessionId}`);
  }

  res.json({ ok: true });
});
```

<Note>
  If you do not need explicit revocation (e.g. tokenize-only flows with no cancel path), you can skip persistence entirely. Sessions expire automatically after their TTL (default 30 minutes). The `sessionId` the SDK sends is opaque from the browser's perspective — it is safe to store as a key.
</Note>

***

<h2 id="session-route">
  Session Route
</h2>

The `sessionUrl` option in the browser SDK expects your backend to expose a `POST /api/oz-session` route. The SDK ships two factory functions to make this trivial.

<h3 id="createsessionhandler-nextjs--fetch-api">
  createSessionHandler (Next.js / Fetch API)
</h3>

Creates a handler for the Web Fetch API (`Request` → `Response`). Use with Next.js App Router, Remix, Cloudflare Workers, etc.

```ts theme={null}
import { Ozura, createSessionHandler } from '@ozura/elements/server';

const ozura = new Ozura({
  merchantId: process.env.MERCHANT_ID!,
  apiKey:     process.env.MERCHANT_API_KEY!,
  vaultKey:   process.env.VAULT_API_KEY!,
});

// Next.js App Router: app/api/oz-session/route.ts
export const POST = createSessionHandler(ozura);
```

The handler reads `sessionId` from the JSON body (sent automatically by the SDK), calls `ozura.createSession()`, and responds:

| Outcome                            | Status               | Body                       |
| ---------------------------------- | -------------------- | -------------------------- |
| Success                            | `200`                | `{ "sessionKey": "..." }`  |
| Vault API error (4xx)              | mirrors vault status | `{ "error": "<message>" }` |
| Vault API unavailable / unexpected | `502`                | `{ "error": "<message>" }` |

The Elements SDK reads `sessionKey` from the 200 response. Any non-200 response causes `OzVault.create()` to reject with the `error` field as the message.

### createSessionMiddleware (Express / Connect)

Creates an Express-style middleware (`(req, res) => void`). Requires `express.json()` (or equivalent body-parser) to be registered before it. The middleware expects `req.body.sessionId` as a string — any standard JSON body-parser that populates `req.body` works.

```ts theme={null}
import express from 'express';
import { Ozura, createSessionMiddleware } from '@ozura/elements/server';

const ozura = new Ozura({ /* ... */ });

const app = express();
app.use(express.json());
app.post('/api/oz-session', createSessionMiddleware(ozura));
```

The response contract is the same as `createSessionHandler` above: `{ sessionKey }` on success, `{ error }` on failure.

***

## Card Sale Handler Factories

For backends where a dedicated route fully owns the card sale flow (amount from server-side DB, billing from token, IP from request), the SDK ships factory functions that build complete handlers.

### createCardSaleHandler (Next.js / Fetch API)

```ts theme={null}
import { Ozura, createCardSaleHandler } from '@ozura/elements/server';

const ozura = new Ozura({ /* ... */ });

export const POST = createCardSaleHandler(ozura, {
  getAmount: async (body) => {
    // Return the authoritative amount from YOUR database.
    // Never trust the client-submitted amount.
    const order = await db.orders.findById(body.orderId as string);
    return order.total; // e.g. "49.00"
  },
});
```

### createCardSaleMiddleware (Express / Connect)

```ts theme={null}
import { Ozura, createCardSaleMiddleware } from '@ozura/elements/server';

const ozura = new Ozura({ /* ... */ });

app.post('/api/charge', createCardSaleMiddleware(ozura, {
  getAmount: async (body) => {
    const order = await db.orders.findById(body.orderId as string);
    return order.total;
  },
}));
```

Both factories accept the same options:

| Option        | Type                                  | Required | Description                                                                                                                                                        |
| ------------- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `getAmount`   | `(body) => string \| Promise<string>` | ✅        | Return the authoritative transaction amount as a decimal string (e.g. `"49.00"`). **Source this from your own database** — never trust the value the client sends. |
| `getCurrency` | `(body) => string \| Promise<string>` | —        | Return the ISO 4217 currency code (e.g. `"USD"`, `"CAD"`). Defaults to `"USD"`.                                                                                    |

Both factories also:

* Read `token`, `cvcSession`, and `billing` from the request body
* Reject non-POST methods (`405`) and non-JSON content types (`415`)
* Call `getClientIp()` to resolve the client IP
* On success: return `{ transactionId, amount, cardLastFour, cardBrand }`
* On OzuraPay API error: return `{ error: string }` with the normalized error message and the appropriate HTTP status (4xx/5xx); 429 includes a `Retry-After` header

***

## Utilities

### getClientIp

Extract the client IP address from a server request object. Works across frameworks:

```ts theme={null}
import { getClientIp } from '@ozura/elements/server';
```

| Framework          | How it reads IP                                                               |
| ------------------ | ----------------------------------------------------------------------------- |
| Express / Fastify  | `req.ip` (requires `app.set('trust proxy', true)` behind a load balancer)     |
| Next.js App Router | `cf-connecting-ip` → `x-forwarded-for` → `x-real-ip`                          |
| Raw Node.js        | `cf-connecting-ip` → `x-forwarded-for` → `x-real-ip` → `socket.remoteAddress` |

```ts theme={null}
// Express
app.post('/api/charge', async (req, res) => {
  const result = await ozura.cardSale({
    // ...
    clientIpAddress: getClientIp(req),
  });
});
```

<Warning>
  Always fetch the client IP on the server. Never trust a value sent from the browser — ad blockers and browser extensions can interfere with client-side IP detection services.

  Headers like `x-forwarded-for` and `x-real-ip` are only trustworthy when your server sits behind a reverse proxy that strips and rewrites them. If your Node.js process is directly internet-accessible, an attacker can spoof these values.
</Warning>

<Note>
  **What happens if `clientIpAddress` is wrong or missing?** The OzuraPay API accepts the field without strict validation — it will not reject a `cardSale` request because the IP is an internal address, `127.0.0.1`, or appears blank. The IP is used for fraud scoring and audit logging; an incorrect value weakens fraud detection but does not block the transaction. If `getClientIp` cannot resolve a real IP (e.g. headers are absent or your proxy uses a non-standard header), the charge will still proceed — but your fraud risk posture degrades. If you are behind a proxy that uses a custom header, extract the IP manually and pass it as a string rather than relying on `getClientIp`.
</Note>

***

## Error Handling

All methods throw `OzuraError` on failure.

```ts theme={null}
import { Ozura, OzuraError } from '@ozura/elements/server';

try {
  const result = await ozura.cardSale({ /* ... */ });
} catch (err) {
  if (err instanceof OzuraError) {
    console.error(err.message);      // user-facing message
    console.error(err.statusCode);   // HTTP status (0 for network/timeout)
    console.error(err.raw);          // raw API error string
    console.error(err.retryAfter);   // seconds (429 only)
  }
}
```

### OzuraError

```ts theme={null}
class OzuraError extends Error {
  readonly statusCode:  number;   // HTTP status code (0 for network/timeout errors)
  readonly raw:         string;   // raw API error message
  readonly retryAfter?: number;   // retry-after seconds (429 responses only)
}
```

### Retry behavior

`listTransactions` and `createSession` automatically retry on 5xx and network errors with exponential backoff (1 s, 2 s, 4 s…). 4xx errors are never retried.

`cardSale` is **never retried** — see note above.

| Status        | Retried (listTransactions)? | createSession                                | cardSale        |
| ------------- | --------------------------- | -------------------------------------------- | --------------- |
| 5xx           | ✅ Up to `retries` times     | ✅ Up to `retries` times, exponential backoff | ❌ Never retried |
| Network error | ✅ Up to `retries` times     | ✅ Up to `retries` times, exponential backoff | ❌ Never retried |
| 4xx           | ❌                           | ❌                                            | ❌               |
| 429           | ❌ Throw with `retryAfter`   | ❌                                            | ❌               |

***

## Types

### CreateSessionOptions

```ts theme={null}
type CreateSessionOptions = {
  sessionId?:    string;           // SDK-generated session UUID forwarded by your endpoint
  sessionLimit?: number | null;    // default 3; null = no cap; must match VaultOptions.sessionLimit
  maxProxyCalls?: number | null;   // vault proxy call limit; omit for most integrations
  orderId?:      string;           // your order ID — stored for transaction correlation
  customerId?:   string;           // your customer ID — stored for transaction correlation
  cartId?:       string;           // your cart ID — stored for transaction correlation
  metadata?:     Record<string, string>; // max 16 keys, 64-char keys, 256-char values
  ttlSeconds?:   number;           // session TTL 60–1800s; default 1800 (30 min)
};
```

### CreateSessionResult

```ts theme={null}
type CreateSessionResult = {
  sessionKey:       string;  // short-lived session credential
  expiresInSeconds: number;  // TTL, typically 1800 (30 minutes)
};
```

<Note>
  The deprecated `MintWaxKeyOptions` and `MintWaxKeyResult` types are still exported as aliases to the above for backward compatibility.
</Note>

### CardSaleInput

```ts theme={null}
type CardSaleInput = {
  token:                      string;            // from TokenResponse.token
  cvcSession:                 string;            // from TokenResponse.cvcSession
  amount:                     string;            // decimal string, e.g. "49.00"
  currency?:                  string;            // ISO 4217, default "USD"
  billing:                    BillingDetails;
  clientIpAddress:            string;            // use getClientIp()
  salesTaxExempt?:            boolean;           // default false
  processor?:                 'elavon' | 'nuvei' | 'worldpay';
  surchargePercent?:          string;            // default "0.00", range 0–3
  tipAmount?:                 string;            // default "0.00"
  transactionInitiationType?: 'cit' | 'mit' | 'ucof' | 'refund';  // default "cit"
  transactionChannel?:        'cardPresent' | 'ecommerce' | 'moto' | 'recurring';  // default "ecommerce"
};
```

### CardSaleResponseData

```ts theme={null}
type CardSaleResponseData = {
  transactionId:    string;   // store this — required for refunds
  amount:           string;
  currency:         string;
  surchargeAmount?: string;   // present only when non-zero
  salesTaxAmount?:  string;
  tipAmount?:       string;   // present only when non-zero
  cardLastFour:     string;
  cardBin?:         string;
  cardExpMonth:     string;
  cardExpYear:      string;
  cardBrand:        string;
  isCreditCard?:    boolean;
  transDate:        string;   // ISO 8601
  ozuraMerchantId:  string;
  clientIpAddress?: string;
  billingFirstName: string;
  billingLastName:  string;
  billingEmail:     string;
  billingPhone:     string;
  billingAddress1:  string;
  billingAddress2?: string;
  billingCity:      string;
  billingState:     string;
  billingZipcode:   string;
  billingCountry:   string;
};
```

<Note>
  `surchargeAmount` and `tipAmount` are only present in the response when non-zero. Handle missing values defensively:

  ```ts theme={null}
  const surcharge = result.surchargeAmount ?? '0.00';
  const tip       = result.tipAmount       ?? '0.00';
  ```
</Note>

### ListTransactionsInput

```ts theme={null}
type ListTransactionsInput = {
  transactionId?:   string;             // look up a single transaction by ID
  dateFrom?:        string;             // "YYYY-MM-DD HH:MM:SS"
  dateTo?:          string;             // "YYYY-MM-DD HH:MM:SS"
  transactionType?: TransactionType;
  page?:            number;             // 1-based, default 1
  limit?:           number;             // default 50, max 100
  fields?:          string;             // comma-separated field names
  sortBy?:          string;
  sortOrder?:       'asc' | 'desc';
};
```

***

## Transaction Types

### TransactionType

```ts theme={null}
type TransactionType =
  | 'CreditCardSale' | 'CreditCardReversal' | 'CreditCardReturn' | 'CreditCardVoid'
  | 'AchSale'        | 'AchReversal'        | 'AchReturn'        | 'AchVoid'
  | 'CryptoSale'     | 'CryptoReturn'       | 'CryptoWithdrawal';
```

### Narrowing TransactionData

`listTransactions()` returns `TransactionData` — a discriminated union. Use `transactionType` to narrow:

```ts theme={null}
const { transactions } = await ozura.listTransactions({ transactionId: 'txn_abc123' });
const tx = transactions[0];

if (tx.transactionType === 'CreditCardSale') {
  console.log(tx.cardLastFour, tx.cardBrand);
} else if (tx.transactionType === 'AchSale') {
  console.log(tx.routingNumber);
}
```

<Note>
  **Field name difference:** `cardSale()` returns billing fields as `billingFirstName`, `billingLastName`, etc. Transaction queries return them as `firstName`, `lastName`, etc. The SDK types reflect what each endpoint actually returns.
</Note>

***

## Full Example

Express backend handling a card payment end-to-end:

```ts theme={null}
import express from 'express';
import { Ozura, OzuraError, getClientIp, createSessionMiddleware } from '@ozura/elements/server';

const app = express();
app.use(express.json());

const ozura = new Ozura({
  merchantId: process.env.MERCHANT_ID!,
  apiKey:     process.env.MERCHANT_API_KEY!,
  vaultKey:   process.env.VAULT_API_KEY!,
});

// Session route — called automatically by the frontend Elements SDK
app.post('/api/oz-session', createSessionMiddleware(ozura));

// Card charge route
app.post('/api/charge', async (req, res) => {
  try {
    const { token, cvcSession, billing } = req.body;

    // Fetch authoritative amount from your DB — never trust the client
    const order = await db.orders.findById(req.body.orderId);

    const result = await ozura.cardSale({
      token,
      cvcSession,
      amount:          order.total,
      currency:        'USD',
      billing,
      clientIpAddress: getClientIp(req),
    });

    res.json({
      success:       true,
      transactionId: result.transactionId,
      last4:         result.cardLastFour,
    });
  } catch (err) {
    if (err instanceof OzuraError) {
      res.status(err.statusCode || 500).json({ error: err.message });
    } else {
      res.status(500).json({ error: 'Internal server error' });
    }
  }
});

app.listen(3000);
```

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Card Elements" icon="credit-card" href="/sdks/elements/card-elements">
    Set up the frontend card fields that produce the tokens this SDK consumes.
  </Card>

  <Card title="Error Handling" icon="triangle-alert" href="/sdks/elements/error-handling">
    Normalize vault and payment errors for your users.
  </Card>

  <Card title="API Reference" icon="book" href="/sdks/elements/api-reference">
    Full type definitions for all SDK exports.
  </Card>

  <Card title="Installation" icon="download" href="/sdks/elements/installation">
    Credentials and OzVault.create() setup.
  </Card>
</CardGroup>
