# Mobile Device Integration

Guide for mobile app and POS terminal developers integrating with the AuxCORE device API.

**Current version:** v2.4.0 | **Last updated:** 2026-10-26 | See Changelog at the end of this page

> ⚠️ **v2.0.0 is a breaking change.** The registration flow requires a `POST /device/test-connection` step before the device becomes active. See Changelog for migration details.
>
> ℹ️ **v2.3.0:** `POST /device/test-connection` now validates the **merchant gateway configuration** the device will use (config only — no processor call, no money moved) and returns a specific failure `reason`. Its error codes changed — see Test Connection and the Changelog.
>
> 🆕 **v2.4.0 — multi-merchant support.** A user who belongs to several merchants can switch merchant without logging out, and without re-entering TPN + pairing code for a merchant they already registered on. New: `GET /device/session`, multi-merchant storage contract, and `POST /device/logout` clarified as **Unregister Device — not user sign-out**.

---

## Overview

Mobile apps communicate with the device backend via a dedicated set of endpoints under `/api/v1/device`. These use **device-scoped authentication** (a device JWT) rather than user credentials, so the app operates independently after the initial pairing.

### Integration Flow

```
1.  Admin creates a device in the portal  →  TPN + pairing code, tied to one merchant
2.  Admin shares TPN and pairing code with the device operator

3.  App calls POST /auth/login          →  userJWT
      └─ multi-merchant user → POST /auth/select-merchant  →  userJWT scoped to merchant M

4.  App calls GET /device/session       →  "is my stored token for M still usable?"
      └─ reusable: true                 →  skip to step 7, do NOT ask for TPN
      └─ no stored token, or reusable: false  →  continue to step 5

5.  App calls POST /device/register     →  receives deviceToken (JWT), device status = REGISTERED
6.  App calls POST /device/test-connection  →  validates gateway configuration
                                             →  on success: device status = ACTIVE
      (skipped when step 5 returns nextStep: "none")

7.  App stores deviceToken under merchant M  →  secure storage, keyed by merchantId
8.  App calls GET /device/parameters     →  loads configuration
9.  If posLite.enabled is true, app calls GET /device/products → current branch catalog
10. App calls GET /device/heartbeat periodically  →  detects config changes
11. When paramVersion changes, app calls GET /device/parameters again

12. Switch merchant: POST /auth/switch-merchant  →  new userJWT, then back to step 4

13. If device.paymentAdjustmentType != "NONE", call GET /transactions/fee-preview before
    displaying price to customer  →  always use server-computed price, never calculate in app
14. Before each sale in dual-pricing mode (pricingModel = cashdiscount), call
    GET /transactions/fee-preview  →  displays cash / credit / ACH prices
```

> The login/session and merchant-switching portions are the v2.4.0 flow. The session check is what
> removes the repeated TPN prompt — see Multi-merchant device storage.

### Dual pricing (cash / credit / ACH display)

Merchants in dual-pricing mode (`pricingModel: cashdiscount`) require the POS to show **posted cash price** and **credit (card) price** before payment — a compliance requirement in states such as New York. AuxCORE computes these server-side; **do not calculate adjustments in the app**.

```
GET /api/v1/transactions/fee-preview?baseAmount=100&tipAmount=0
```

Use the `prices.cash`, `prices.card`, and `prices.ach` objects from the response. When `dualPricingRequired` is `true`, all three labels must be shown when ACH is enabled.

Full reference: [Fee Preview API](../core/fee-preview.md)

### Device Status Lifecycle

```
Admin creates device
       │
       ▼
   PENDING  ──── App calls /register ────►  REGISTERED  ──── /test-connection pass ────►  ACTIVE
      │                                         │                                              │
      │ admin disable                     re-register ok                               admin disable
      ▼                                   (refresh token)                                     ▼
   INACTIVE  ◄────────────────────────────────────────────────────────────────────────  INACTIVE
      │                                                                                        │
      └──────────────────────── admin suspend ────────────────────────────────────────►  SUSPENDED
```

| Status | Meaning | Can process payments? |
|--------|---------|----------------------|
| `PENDING` | Admin created, app not yet registered | ❌ No |
| `REGISTERED` | App registered, test-connection not yet passed | ❌ No |
| `ACTIVE` | Fully activated — test passed | ✅ Yes |
| `INACTIVE` | Deactivated (unregister or admin action) | ❌ No |
| `SUSPENDED` | Locked by admin | ❌ No |
| `ARCHIVED` | Archived (soft-deleted) by admin; sessions revoked. Unarchiving restores it to `INACTIVE` | ❌ No |

> There is no separate `UNREGISTERED` status — unregistering a device puts it in `INACTIVE`, from which it can be registered again with the same TPN + pairing code.

---

## Multi-merchant device storage

A merchant user may belong to several merchants and register a different device at each. Each Device has its own TPN + Pairing Code, and after registration the Device becomes **Active**.

**The app owns the memory of which device belongs to which merchant.** Store a map in secure storage:

```
merchantId → deviceToken
```

Rules the app must follow:

| Rule | Behaviour |
| --- | --- |
| Entering a merchant for the first time | No stored token → show Device Registration and call `POST /device/register` |
| Returning to a merchant | Look up the stored token, call `GET /device/session`, and enter directly when `reusable: true` — **do not ask for TPN again** |
| Switching merchant | Call `POST /api/v1/auth/switch-merchant` first (new access token), then run the `GET /device/session` check for that merchant |
| Signing out | **Keep the map.** Sign-out ends the user session only; devices stay `ACTIVE` so the next login re-enters without a TPN prompt |
| Unregistering a device | Call `POST /device/logout` **and** delete that merchant's entry from the map |
| Multiple active devices on one merchant | Only ever replay the token you stored. Never adopt another device just because it is `ACTIVE` on the same merchant |

The server never picks a device for the app. `GET /auth/my-merchants` reports `deviceRegistration.hasActiveDevice` per merchant so the picker can show which merchants still need a TPN — that is a **display hint**; `GET /device/session` is the authority.

**Recovering a lost token.** If the app is reinstalled or storage is cleared, register again with the same TPN + pairing code. The device is still `ACTIVE`, and re-registration is allowed for the user who registered it originally — no admin action needed. A *different* user gets `400 Device already registered`.

### Typical merchant-switch sequence

```
GET  /api/v1/auth/my-merchants            → list + hasActiveDevice per merchant
POST /api/v1/auth/switch-merchant {B}     → new access token scoped to Merchant B
GET  /api/v1/device/session               → with the stored device token for B
        reusable: true   → go to Home / Payment
        reusable: false  → show Device Registration (TPN + pairing code)
```

---

## Base URL

```
https://uat.auxcore.net
```

---

## Authentication

### Required Headers (All Requests)

| Header         | Value              | Description           |
| -------------- | ------------------ | --------------------- |
| `X-Tenant-ID`  | `your-tenant-id`   | Identifies the tenant. Through the API gateway it is overwritten with the `tenantId` from the user JWT, so it must match the token |
| `Content-Type` | `application/json` | For POST/PUT requests |

### Dual-Header Pattern

The mobile app uses **two separate headers** for two separate concerns:

| Header                             | Purpose                                                        | When to send                                                          |
| ---------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `Authorization: Bearer <user_jwt>` | **WHO** is calling — user identity, permissions, tenant        | Login flow + all payment/notification APIs                            |
| `X-Device-Token: <device_token>`   | **WHERE** the call originates — device identity, state, config | Device-only endpoints (required) + payment APIs (optional enrichment) |

### Step 1 — Registration

Requires a **user JWT** (merchant user must be logged in via `POST /api/v1/auth/login`).

```http
Authorization: Bearer <user_jwt>
X-Tenant-ID: your-tenant-id
```

### Step 2 — Device-Only Endpoints (`/device/me`, `/device/parameters`, `/device/heartbeat`)

Require **both headers** — user JWT for identity + device token for device context.

```http
Authorization: Bearer <user_jwt>
X-Device-Token: <device_token>
X-Tenant-ID: your-tenant-id
```

> Store the `deviceToken` in secure on-device storage (Android Keystore / iOS Keychain). This token does not expire on a timer — it stays valid until the device is suspended, deleted, or re-paired.

### Step 3 — Payment & Notification APIs (`/transactions/*`, `/payment-links`, etc.)

Send **both headers**. The user JWT authenticates the call; the device token enriches it with device context (device-level credentials, limits, transaction type restrictions).

```http
Authorization: Bearer <user_jwt>
X-Device-Token: <device_token>
X-Tenant-ID: your-tenant-id
```

> **Important:** If `X-Device-Token` is included, it **must** be valid. An invalid or revoked device token returns `401` (a suspended device returns `403 DEVICE_SUSPENDED`) even if the user JWT is valid. Omit the header entirely for calls that do not originate from a registered device (e.g. Virtual Terminal use cases).
>
> On payment APIs the device token is checked for a live session and a non-suspended device only — the `ACTIVE` status is **not** re-checked there, so gate payments in the app on a successful `test-connection` / `GET /device/session`.

### Header Summary by Endpoint

| Endpoint                         | `Authorization`        | `X-Device-Token`                                   | Required Status  |
| -------------------------------- | :--------------------: | :------------------------------------------------: | :--------------: |
| `GET /auth/my-merchants`         | ✅ Required (user JWT) | ❌ Not applicable                                  | —                |
| `POST /auth/switch-merchant`     | ✅ Required (user JWT) | ❌ Not applicable                                  | —                |
| `POST /device/register`          | ✅ Required (user JWT) | ❌ Not applicable                                  | `PENDING` / `REGISTERED` / `INACTIVE` / `ACTIVE` (owner re-pair) |
| `POST /device/test-connection`   | ✅ Required (user JWT) | ✅ Required                                        | `REGISTERED`     |
| `GET /device/session`            | ✅ Required (user JWT) | ✅ Required                                        | Any status       |
| `GET /device/me`                 | ✅ Required (user JWT) | ✅ Required                                        | `ACTIVE`         |
| `GET /device/parameters`         | ✅ Required (user JWT) | ✅ Required                                        | `ACTIVE`         |
| `GET /device/products`           | ✅ Required (user JWT) | ✅ Required                                        | `ACTIVE`         |
| `GET /device/heartbeat`          | ✅ Required (user JWT) | ✅ Required                                        | `ACTIVE`         |
| `POST /device/logout`            | ✅ Required (user JWT) | ✅ Required                                        | `ACTIVE`         |
| `POST /transactions/sale`        | ✅ Required            | ⚠️ Optional (send for device context)              | Not suspended    |
| `POST /transactions/auth`        | ✅ Required            | ⚠️ Optional                                        | Not suspended    |
| `POST /transactions/token-sale`  | ✅ Required            | ⚠️ Optional                                        | Not suspended    |
| `POST /transactions/{id}/refund` | ✅ Required            | ⚠️ Optional                                        | Not suspended    |
| `POST /transactions/{id}/refund/partial` | ✅ Required    | ⚠️ Optional                                        | Not suspended    |
| `POST /transactions/{id}/void`   | ✅ Required            | ⚠️ Optional                                        | Not suspended    |
| `POST /transactions/{id}/capture` | ✅ Required           | ⚠️ Optional                                        | Not suspended    |
| `POST /transactions/ach-sale`    | ✅ Required            | ⚠️ Optional                                        | Not suspended    |
| `POST /transactions/external-payment` | ✅ Required       | ✅ Required                                        | Not suspended    |
| `GET /transactions`              | ✅ Required            | ⚠️ Optional (filters to device's own transactions) | Not suspended    |
| `GET /transactions/{id}`         | ✅ Required            | ⚠️ Optional                                        | Not suspended    |
| `GET /transactions/fee-preview`  | ✅ Required            | ❌ Not applicable                                  | —                |
| `POST /merchants/verify-pin`     | ✅ Required            | ⚠️ Optional                                        | Not suspended    |
| `POST /payment-links`, `GET /payment-links` | ✅ Required | ⚠️ Optional                                        | Not suspended    |

---

## Endpoints

---

### List My Merchants

Merchants the signed-in user can work in, each flagged with whether **this user** already has an ACTIVE device registered there. Use it to build the merchant picker.

```
GET /api/v1/auth/my-merchants
```

**Auth:** User JWT. No device token.

| Query | Default | Description |
| --- | --- | --- |
| `includeDeviceStatus` | `true` | Set `false` to skip the per-merchant device lookup |

**Response (200 OK):**

```json
{
  "success": true,
  "data": {
    "currentMerchantId": "merchant_123",
    "merchants": [
      {
        "merchantId": "merchant_123",
        "companyName": "Acme Coffee",
        "role": "merchant_admin",
        "status": "active",
        "deviceRegistration": {
          "hasActiveDevice": true,
          "activeDeviceCount": 1,
          "device": {
            "deviceId": "550e8400-e29b-41d4-a716-446655440001",
            "deviceLabel": "Counter 1",
            "tpnLast4": "4651",
            "status": "ACTIVE",
            "lastActiveAt": "2026-08-10T09:14:02.000Z"
          }
        }
      }
    ]
  }
}
```

Devices registered by **other** staff at the same merchant are never counted here — a colleague's ACTIVE device does not make the merchant ready for this user.

> `hasActiveDevice` is a **display hint** for the picker badge, computed server-side. It can disagree with what the app has in secure storage (storage cleared, or the token revoked after the list was fetched). Always call `GET /device/session` after switching to decide what to show.

---

### Switch Merchant

Changes the active merchant without logging out. Returns a fresh access token scoped to the new merchant — replace the stored `userJWT` with it.

```
POST /api/v1/auth/switch-merchant
```

**Auth:** User JWT. No device token.

**Request Body:**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `merchantId` | string | Yes | A merchant from `GET /auth/my-merchants` |

```bash
curl -X POST "https://uat.auxcore.net/api/v1/auth/switch-merchant" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{ "merchantId": "merchant_456" }'
```

**Response (200 OK):** same shape as `POST /auth/select-merchant`.

```json
{
  "success": true,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "refreshToken": "Qm9vZ3VzUmVmcmVzaFRva2VuRXhhbXBsZQ...",
    "tokenType": "Bearer",
    "expiresIn": 86400,
    "user": {
      "id": "8d2f6b1e-4a7c-4c1e-9b3a-2f5e6d7c8a90",
      "email": "cashier@example.com",
      "fullName": "Jane Cashier",
      "role": "merchant_admin",
      "merchantId": "merchant_456",
      "mfaEnabled": false,
      "permissions": ["transactions.view", "transactions.refund"]
    },
    "features": { "invoicing": true },
    "navigation": []
  }
}
```

`token`, `refreshToken` and `tokenType` are omitted when the deployment uses HttpOnly auth cookies — the tokens are then set as cookies instead. `expiresIn` is the access-token lifetime in seconds (24 hours by default). `role` is the role the user holds **at the new merchant**. `permissions` is omitted when empty.

| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `VALIDATION_ERROR` | `merchantId` missing or not a string |
| `401` | `UNAUTHORIZED` | The user has no access to that merchant, the merchant is closed, or the account cannot authenticate |
| `404` | `NOT_FOUND` | The user record no longer exists |
| `429` | `RATE_LIMITED` | More than 120 requests in 15 minutes from one IP (bucket shared with `/auth/refresh` and `/auth/select-merchant`) |

Error bodies use `{ "success": false, "error": { "code": "...", "message": "..." } }`.

After switching, send the **new** `userJWT` on every subsequent call. Using the previous token returns `DEVICE_CONTEXT_MISMATCH` on device routes.

> `POST /auth/select-merchant` is a different endpoint: it completes login and requires the 10-minute `selectionToken` returned by `/auth/login`. It cannot be used to switch merchant later in the session.

---

### Register Device

Pairs the app to a device record provisioned in the portal. Call this once per device installation (or after a pairing code refresh).

```
POST /api/v1/device/register
```

**Auth:** User JWT (`Authorization: Bearer <user_jwt>`)

The merchant comes from the user JWT, so switch merchant **before** registering a device for another merchant. The user in that JWT is recorded as the device's owner:

- Only that user may register the device again while it is `ACTIVE` — which is how an app recovers after losing its stored token. The pairing code is still required.
- Any other user attempting to register an `ACTIVE` device gets `400 Device already registered. Ask admin to deactivate or refresh the pairing code.`
- Devices registered before this ownership tracking existed reject **every** re-registration while `ACTIVE`, as before.

**Request Body:**

| Field         | Type   | Required | Description                                         |
| ------------- | ------ | -------- | --------------------------------------------------- |
| `tpn`         | string | Yes      | 12-digit numeric TPN provided by the merchant admin (validated as exactly 12 digits) |
| `pairingCode` | string | Yes      | 6-digit pairing code provided by the merchant admin (must match the device record exactly) |
| `appVersion`  | string | No       | Your app's version string, e.g. `"2.1.0"`           |
| `osVersion`   | string | No       | Device OS version, e.g. `"14.0"`                    |
| `osPlatform`  | enum   | No       | `ANDROID` \| `IOS`                                  |

**Example:**

```bash
curl -X POST "https://uat.auxcore.net/api/v1/device/register" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "tpn": "483920174651",
    "pairingCode": "619274",
    "appVersion": "2.1.0",
    "osVersion": "14.0",
    "osPlatform": "ANDROID"
  }'
```

**Response (201 Created) — Android + NearPay example:**

```json
{
  "success": true,
  "data": {
    "device": {
      "id": "550e8400-e29b-41d4-a716-446655440001",
      "tenantId": "your-tenant-id",
      "merchantId": "merchant_123",
      "branchId": "branch-456",
      "deviceLabel": "Drive-Thru Terminal",
      "deviceNumber": 1042,
      "tpn": "483920174651",
      "manufacturer": "ANDROID_PHONE",
      "deviceType": "TAP_ON_PHONE",
      "paymentMode": "NEARPAY",
      "merchantGatewayId": null,
      "gateway": null,
      "templateId": null,
      "mid": null,
      "terminalId": null,
      "iposTpn": null,
      "iposTpnCode": null,
      "nearpayTid": "TID-NP-001",
      "sunmiSN": null,
      "paymentAdjustmentType": "FLAT",
      "paymentAdjustmentValue": "1.5000",
      "paymentAdjustmentCardValue": "2.0000",
      "paymentAdjustmentAchValue": "0.5000",
      "autoSettleEnabled": true,
      "settleAt": "17:00",
      "status": "REGISTERED",
      "skipTestConnection": false,
      "pairingCode": "619274",
      "appVersion": "2.2.0",
      "osVersion": "14.0",
      "osPlatform": "ANDROID",
      "registeredAt": "2026-04-14T10:00:00.000Z",
      "registeredByUserId": "8d2f6b1e-4a7c-4c1e-9b3a-2f5e6d7c8a90",
      "assignedUserId": null,
      "isShared": true,
      "lastActiveAt": "2026-04-14T10:00:00.000Z",
      "txCount": 0,
      "txVolumeUsd": "0.00",
      "createdBy": "3b9e1c22-7d4f-4e8a-a1b2-c3d4e5f6a7b8",
      "updatedBy": null,
      "createdAt": "2026-04-10T00:00:00.000Z",
      "updatedAt": "2026-04-14T10:00:00.000Z",
      "deletedAt": null
    },
    "deviceToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "jwtNearpayDevice": "eyJhbGciOiJSUzI1NiJ9...",
    "nextStep": "test-connection",
    "testConnectionRequired": true
  }
}
```

**Device object fields:**

| Field | Type | Notes |
|-------|------|-------|
| `id` | string (UUID) | Internal device ID |
| `tenantId` | string | Tenant identifier |
| `merchantId` | string | Merchant identifier |
| `branchId` | string \| null | Branch this device belongs to; null = top-level merchant |
| `deviceLabel` | string | Human-readable label |
| `deviceNumber` | number \| null | Per-merchant sequence number; the portal shows it as e.g. `DEV-1042` |
| `tpn` | string | 12-digit Terminal Processor Number |
| `manufacturer` | string | Built-in `ANDROID_PHONE` \| `APPLE_PHONE` \| `SUNMI`, or the name of an equipment provider from the provider catalog |
| `deviceType` | string | Built-in `TAP_ON_PHONE` \| `CARD_READER` \| `POS_TERMINAL`, or a provider device model name |
| `paymentMode` | enum | `AUXVAULT` \| `IPOS` \| `NEARPAY` |
| `merchantGatewayId` | string (UUID) \| null | Merchant gateway selected for this device |
| `gateway` | string \| null | Provider code of the selected merchant gateway, e.g. `TsysSierra`, `NMI`, `FluidPay`; null when none is linked |
| `templateId` | string \| null | Parameter template applied at creation |
| `mid` | string \| null | Merchant ID / MID for gateway auth |
| `terminalId` | string \| null | Terminal ID / TID for gateway auth |
| `iposTpn` | string \| null | iPOSpays TPN — present when `paymentMode = IPOS` |
| `iposTpnCode` | string \| null | iPOSpays secondary code — present when `paymentMode = IPOS` |
| `nearpayTid` | string \| null | NearPay Terminal ID — present when `paymentMode = NEARPAY` |
| `sunmiSN` | string \| null | Sunmi serial number — present when `manufacturer = SUNMI` |
| `paymentAdjustmentType` | enum | `NONE` \| `FLAT` \| `PERCENTAGE` |
| `paymentAdjustmentValue` | decimal string \| null | Default/single adjustment value; null when type is `NONE` |
| `paymentAdjustmentCardValue` | decimal string \| null | Card-specific adjustment value; null when type is `NONE` |
| `paymentAdjustmentAchValue` | decimal string \| null | ACH-specific adjustment value; null when type is `NONE` |
| `autoSettleEnabled` | boolean | Whether auto batch settlement is configured |
| `settleAt` | string \| null | Settlement time `"HH:MM"` (24h); null when disabled |
| `status` | enum | `REGISTERED` immediately after register (`ACTIVE` when `skipTestConnection` is `true`) |
| `skipTestConnection` | boolean | If `true`, device skips test-connection and is immediately `ACTIVE` |
| `pairingCode` | string | The pairing code used for registration |
| `appVersion` | string \| null | App version sent during registration |
| `osVersion` | string \| null | OS version sent during registration |
| `osPlatform` | enum \| null | `ANDROID` \| `IOS` |
| `registeredAt` | ISO timestamp | First registration time |
| `registeredByUserId` | string \| null | User who registered the app on this device (the owner for re-registration rules) |
| `assignedUserId` | string \| null | Team member the merchant assigned the device to; null while shared |
| `isShared` | boolean | Whether anyone at the location may use the device |
| `lastActiveAt` | ISO timestamp | Most recent activity |
| `txCount` | number | Total approved transactions |
| `txVolumeUsd` | decimal string | Total volume in USD |
| `createdBy` / `updatedBy` | string \| null | Portal user who created / last edited the device record |
| `createdAt` | ISO timestamp | Device record creation time |
| `updatedAt` | ISO timestamp | Last update time |
| `deletedAt` | timestamp \| null | Soft-delete timestamp; null = active |

The encrypted device API key is never returned.

**`jwtNearpayDevice`:** A signed RS256 JWT for the NearPay SDK. Present (non-null) only when `paymentMode = "NEARPAY"` and a Terminal ID is configured. Store it in secure storage alongside `deviceToken` and pass it to the NearPay SDK during initialization. `null` for all other payment modes.

**Save the `deviceToken` immediately** — store it in secure storage. Use it as the `X-Device-Token` header for the next required step: `POST /device/test-connection`.

> ⚠️ **Device is NOT yet active.** `status = REGISTERED` means the app has paired successfully, but the device must pass `POST /device/test-connection` before it can process payments. Navigate to the Test Connection screen immediately after receiving this response.

**Error Responses:**

Errors from this endpoint use `{ "success": false, "message": "..." }` (no `error.code`).

| HTTP  | Message                                                                             | Cause                                                                            |
| ----- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `400` | `"tpn and pairingCode are required"`                                                | A required field is missing                                                      |
| `400` | `"tpn must be a 12-digit numeric code"`                                             | TPN is not exactly 12 digits                                                     |
| `400` | `"Invalid osPlatform value"`                                                        | `osPlatform` is not `ANDROID` or `IOS`                                           |
| `400` | `"merchantId not found in token — merchant user login required"`                    | The user JWT is not scoped to a merchant — select or switch merchant first       |
| `400` | `"Invalid TPN"`                                                                     | TPN not found or does not belong to the authenticated user's merchant            |
| `400` | `"Invalid pairing code"`                                                            | Pairing code does not match the device record                                    |
| `400` | `"Device is not available for registration"`                                        | Device is `SUSPENDED` or `ARCHIVED` — admin must take action                     |
| `400` | `"Device already registered. Ask admin to deactivate or refresh the pairing code."` | Device is `ACTIVE` and was registered by a different user (or has no recorded owner) |
| `500` | `"Registration failed"`                                                             | Unexpected server error                                                          |

**Side effects:**

- Device status transitions to `REGISTERED` (not ACTIVE — must complete test-connection), or straight to `ACTIVE` when `skipTestConnection` is set (`nextStep: "none"`, `testConnectionRequired: false`)
- `registeredByUserId` is set to the calling user
- Any existing active sessions are revoked immediately
- `registeredAt` is set on first registration; preserved on re-registration
- `lastActiveAt` updated

---

### Test Connection

Validates that the **merchant payment gateway configuration** this device will use is set up correctly — there is an **ACTIVE** gateway of the device's type, with complete credentials accepted by the gateway adapter (plus a VAR sheet for TSYS). This is **config validation only**: no transaction is sent to the processor and **no money moves**. On success, the device transitions from `REGISTERED` to `ACTIVE`, enabling payment processing.

> **Must be called after every `POST /device/register`** (when `testConnectionRequired = true`) before the device can process transactions.

> **Changed in v2.3.0** — this endpoint previously validated device-level credentials and pinged the processor. It now validates the merchant gateway config and returns a specific failure `reason`. Error codes changed (see below).

```
POST /api/v1/device/test-connection
```

**Auth:** User JWT + device token — both required.

```http
Authorization: Bearer <user_jwt>
X-Device-Token: <device_token>
X-Tenant-ID: your-tenant-id
```

**Request Body:** None.

**Example:**

```bash
curl -X POST "https://uat.auxcore.net/api/v1/device/test-connection" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response (200 OK — Success):**

```json
{
  "success": true,
  "message": "Connection validated successfully",
  "data": {
    "deviceId": "550e8400-e29b-41d4-a716-446655440001",
    "status": "ACTIVE",
    "gatewayType": "TsysSierra",
    "configStatus": "VALID",
    "processorStatus": "CONNECTED",
    "validatedAt": "2026-06-25T10:30:00Z",
    "testDurationMs": 142
  },
  "timestamp": "2026-06-25T10:30:00Z"
}
```

| Field | Type | Notes |
|-------|------|-------|
| `gatewayType` | string | Gateway the device resolves to: `IPOS` for `paymentMode = IPOS`, `NearPay` for `NEARPAY`, otherwise the provider code stored on the device (`gateway`, e.g. `TsysSierra`, `NMI`, `FluidPay`; older devices may carry a legacy label). **New in v2.3.0** |
| `configStatus` | `"VALID"` | Gateway config is complete. **New in v2.3.0** |
| `processorStatus` | `"CONNECTED"` | Retained for backward compatibility with v2.2 clients |

**Already active (idempotent):** if the device is already `ACTIVE`, the endpoint returns **`200`** with `alreadyActive: true` (not an error) — safe for clients that always call it after registration.

```json
{
  "success": true,
  "message": "Device is already active — no test-connection required",
  "data": { "deviceId": "550e8400-...", "status": "ACTIVE", "alreadyActive": true }
}
```

**After success:** Device is now `ACTIVE`. Navigate to the main dashboard — the device can process payments.

**Error Responses:**

| HTTP  | Code | `reason` | Cause | Retryable |
| ----- | ---- | -------- | ----- | --------- |
| `422` | `GATEWAY_NOT_CONFIGURED` | `NO_ACTIVE_GATEWAY` | No active gateway of the device's type for this merchant | ❌ No |
| `422` | `GATEWAY_NOT_CONFIGURED` | `NO_CREDENTIALS` | Gateway has no saved credentials | ❌ No |
| `422` | `GATEWAY_NOT_CONFIGURED` | `INVALID_CREDENTIALS` | Credentials incomplete/invalid for the gateway | ❌ No |
| `422` | `GATEWAY_NOT_CONFIGURED` | `MISSING_VAR_SHEET` | TSYS gateway has no VAR sheet configured | ❌ No |
| `422` | `GATEWAY_NOT_CONFIGURED` | `INVALID_GATEWAY_CONFIG` | Gateway config rejected for any other reason | ❌ No |
| `400` | `GATEWAY_NOT_SUPPORTED` | — | No payment adapter for the device's resolved gateway | ❌ No |
| `400` | `NO_DEVICE_GATEWAY` | — | `AUXVAULT` device has no merchant gateway linked — an admin must assign one. The body says `retryable: true`, but retrying without that fix will not succeed | ❌ No |
| `403` | `DEVICE_NOT_REGISTERED` | — | Device is `PENDING`, `INACTIVE` or otherwise not `REGISTERED` — call `/register` first | ❌ No |
| `403` | `DEVICE_CONTEXT_MISMATCH` | — | The device token belongs to a different tenant/merchant than the user JWT | ❌ No |
| `404` | `DEVICE_NOT_FOUND` | — | Device not found for this tenant/merchant | ❌ No |
| `503` | `PROCESSOR_UNREACHABLE` | — | The validation service was unreachable | ✅ Yes |
| `500` | `INTERNAL_SERVER_ERROR` | — | Unexpected backend error (body also carries `requestId`, `supportEmail`, `durationMs`) | ✅ Yes |

`DEVICE_NOT_REGISTERED` and `DEVICE_CONTEXT_MISMATCH` come from the route guard and use the nested shape `{ "success": false, "error": { "code": "DEVICE_NOT_REGISTERED", "message": "...", "currentStatus": "PENDING" } }`. All other errors use the flat shape below.

**`422 GATEWAY_NOT_CONFIGURED` body example:**

```json
{
  "success": false,
  "error": "GATEWAY_NOT_CONFIGURED",
  "message": "No active TsysSierra gateway is configured for this merchant",
  "userMessage": "No active TsysSierra gateway is configured for this merchant",
  "code": 422,
  "retryable": false,
  "reason": "NO_ACTIVE_GATEWAY",
  "gatewayType": "TsysSierra",
  "timestamp": "2026-06-25T10:30:00Z"
}
```

**Retry guidance:**
- **`GATEWAY_NOT_CONFIGURED` (422)** is **not** retryable by the app — an administrator must fix the merchant gateway configuration in the portal (add the VAR sheet, set credentials, activate a gateway). Display the `message` and a "contact your administrator" hint.
- **`PROCESSOR_UNREACHABLE` (503)** is retryable — show a **Retry** button; retry after 10–30 seconds.
- Always display the `requestId` (if present) for support reference. It is echoed from your `X-Request-ID` header on `500` responses, so send one.

**Side effects:**

- On success: device status transitions from `REGISTERED` → `ACTIVE`.
- Every attempt (pass or fail) is logged in the `device_test_attempts` table.

> **NearPay devices** (`paymentMode = NEARPAY`) are validated **locally**: NearPay settles through the device SDK, so there is no server-side gateway credential. Config is valid when the device has a Terminal ID (`nearpayTid`) and the server can mint the NearPay JWT — otherwise a `422 GATEWAY_NOT_CONFIGURED` is returned.

---

### Check Device Session

Answers **"can I keep using the device token I already stored for this merchant?"** Call it when entering a merchant — on app start, after sign-in, and after every merchant switch — so the app knows whether to skip Device Registration.

```
GET /api/v1/device/session
```

**Auth:** User JWT + device token — both required. The merchant comes from the user JWT; the device comes from the device token.

Unlike `/device/me` and `/device/parameters`, this endpoint has **no ACTIVE requirement**. A device that can no longer be used is a normal `200` answer, not a `403`, so the app can branch on the result instead of parsing an error.

**Response (200 OK) — reusable:**

```json
{
  "success": true,
  "data": {
    "reusable": true,
    "status": "ACTIVE",
    "device": { "id": "550e8400-...", "deviceLabel": "Drive-Thru Terminal", "status": "ACTIVE", "...": "full device object, as in /device/register" }
  }
}
```

**Response (200 OK) — not reusable:**

```json
{
  "success": true,
  "data": {
    "reusable": false,
    "reason": "NOT_ACTIVE",
    "status": "INACTIVE",
    "device": { "id": "550e8400-...", "status": "INACTIVE" }
  }
}
```

| `reason` | Meaning | App action |
| --- | --- | --- |
| `NOT_ACTIVE` | Device was unregistered, deactivated, suspended or archived | Show Device Registration |
| `CONTEXT_MISMATCH` | This token belongs to a different merchant | Use the token stored for the current merchant, or register |
| `REGISTERED_BY_ANOTHER_USER` | The device is owned by a different user | Show Device Registration; the other user must unregister or an admin must deactivate |
| `DEVICE_NOT_FOUND` | The device record no longer exists | Delete the stored token and show Device Registration |

`device` is the full device object for `reusable: true` and `NOT_ACTIVE`, and `null` for `CONTEXT_MISMATCH`, `REGISTERED_BY_ANOTHER_USER` and `DEVICE_NOT_FOUND`. `status` is the device's current status in every case except `DEVICE_NOT_FOUND`, where it is `null`.

A user JWT that is not scoped to a merchant returns `400` with `"merchantId not found in token — merchant user login required"`.

Two cases are answered before the handler runs, and the app must treat both exactly like `reusable: false` — drop the stored token and show Device Registration:

- **`401 Unauthorized`** — the device token is missing, invalid or revoked (for example after an admin deactivation, an archive, or a pairing-code refresh).
- **`403 DEVICE_SUSPENDED`** — the device was suspended by an admin.

```bash
curl "https://uat.auxcore.net/api/v1/device/session" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id"
```

---

### Get Current Device

Returns the current device record for the authenticated device. Use this to verify connectivity and device metadata on app startup.

```
GET /api/v1/device/me
```

**Auth:** User JWT + device token — both required.

```http
Authorization: Bearer <user_jwt>
X-Device-Token: <device_token>
X-Tenant-ID: your-tenant-id
```

**Example:**

```bash
curl "https://uat.auxcore.net/api/v1/device/me" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response (200 OK):**

The response spreads all device fields directly into `data`, plus `nearpayJWT` at the same level.

> ⚠️ **Note on NearPay JWT key name:** `POST /device/register` returns the NearPay JWT as `data.jwtNearpayDevice`. This endpoint returns it as `data.nearpayJWT`. Both carry the same RS256-signed token.

```json
{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "tenantId": "your-tenant-id",
    "merchantId": "merchant_123",
    "branchId": "branch-456",
    "deviceLabel": "Drive-Thru Terminal",
    "deviceNumber": 1042,
    "tpn": "483920174651",
    "manufacturer": "ANDROID_PHONE",
    "deviceType": "TAP_ON_PHONE",
    "paymentMode": "NEARPAY",
    "merchantGatewayId": null,
    "gateway": null,
    "templateId": null,
    "mid": null,
    "terminalId": null,
    "iposTpn": null,
    "iposTpnCode": null,
    "nearpayTid": "TID-NP-001",
    "sunmiSN": null,
    "paymentAdjustmentType": "FLAT",
    "paymentAdjustmentValue": "1.5000",
    "paymentAdjustmentCardValue": "2.0000",
    "paymentAdjustmentAchValue": "0.5000",
    "autoSettleEnabled": true,
    "settleAt": "17:00",
    "status": "ACTIVE",
    "skipTestConnection": false,
    "pairingCode": "619274",
    "appVersion": "2.2.0",
    "osVersion": "14.0",
    "osPlatform": "ANDROID",
    "registeredAt": "2026-04-14T10:00:00.000Z",
    "registeredByUserId": "8d2f6b1e-4a7c-4c1e-9b3a-2f5e6d7c8a90",
    "assignedUserId": null,
    "isShared": true,
    "lastActiveAt": "2026-06-16T10:30:00.000Z",
    "txCount": 42,
    "txVolumeUsd": "3810.00",
    "createdBy": "3b9e1c22-7d4f-4e8a-a1b2-c3d4e5f6a7b8",
    "updatedBy": null,
    "createdAt": "2026-04-10T00:00:00.000Z",
    "updatedAt": "2026-06-16T10:30:00.000Z",
    "deletedAt": null,
    "nearpayJWT": "eyJhbGciOiJSUzI1NiJ9..."
  }
}
```

**Key fields reference:**

| Field | Type | Description |
|-------|------|-------------|
| `manufacturer` | string | Built-in `ANDROID_PHONE` \| `APPLE_PHONE` \| `SUNMI`, or an equipment provider name |
| `deviceType` | string | Built-in `TAP_ON_PHONE` \| `CARD_READER` \| `POS_TERMINAL`, or a provider device model name |
| `paymentMode` | enum | `AUXVAULT` \| `IPOS` \| `NEARPAY` — the payment gateway type |
| `gateway` | string \| null | Provider code of the linked merchant gateway (e.g. `TsysSierra`); null when none is linked |
| `mid` | string \| null | Merchant ID / MID for gateway credential validation |
| `terminalId` | string \| null | Terminal ID / TID for gateway credential validation |
| `iposTpn` | string \| null | iPOSpays TPN — present when `paymentMode = IPOS` |
| `iposTpnCode` | string \| null | iPOSpays secondary code — present when `paymentMode = IPOS` |
| `nearpayTid` | string \| null | NearPay Terminal ID — present when `paymentMode = NEARPAY` |
| `sunmiSN` | string \| null | Sunmi serial number — present when `manufacturer = SUNMI` |
| `paymentAdjustmentType` | enum | `NONE` \| `FLAT` \| `PERCENTAGE` |
| `paymentAdjustmentValue` | decimal string \| null | Default/single adjustment amount; null when type is `NONE` |
| `paymentAdjustmentCardValue` | decimal string \| null | Card-specific adjustment amount; null when type is `NONE` |
| `paymentAdjustmentAchValue` | decimal string \| null | ACH-specific adjustment amount; null when type is `NONE` |
| `autoSettleEnabled` | boolean | Whether auto batch settlement is configured |
| `settleAt` | string \| null | Settlement time `"HH:MM"` (24h); null when disabled |
| `skipTestConnection` | boolean | Whether this device skips the test-connection step on registration |
| `nearpayJWT` | string \| null | RS256 JWT for NearPay SDK initialization; non-null only when `paymentMode = NEARPAY` and TID is configured |

---

### Get Device Parameters

Loads the full configuration for this device. Call this on app startup and whenever the heartbeat reports a new `paramVersion`.

```
GET /api/v1/device/parameters
```

**Auth:** User JWT + device token — both required. Device must be `ACTIVE`.

**Example:**

```bash
curl "https://uat.auxcore.net/api/v1/device/parameters" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response (200 OK):**

```json
{
  "success": true,
  "data": {
    "version": 7,
    "signatureThreshold": "0.00",
    "isViewAllMerchantTransaction": false,
    "transactions": {
      "saleEnabled": true,
      "refundEnabled": true,
      "voidEnabled": true,
      "preauthEnabled": false
    },
    "requirePin": {
      "pinVoidEnabled": false,
      "pinRefundEnabled": true
    },
    "paymentTypes": {
      "tapToPayEnabled": true,
      "keyedInEnabled": false,
      "qrCodeEnabled": false,
      "paymentLinkEnabled": false,
      "achTransferEnabled": false,
      "cashEnabled": true
    },
    "limits": {
      "perTransaction": "500.00",
      "daily": "5000.00",
      "weekly": null,
      "monthly": null
    },
    "receipt": {
      "headerMsg": "Welcome!",
      "footerMsg": "Thank you!",
      "disclaimer": null,
      "autoPrintEnabled": true,
      "printMerchantCopy": true,
      "smsEnabled": true,
      "emailEnabled": true,
      "signatureCapture": false
    },
    "posLite": {
      "enabled": true
    },
    "productCart": {
      "enabled": true
    },
    "tips": {
      "allowTips": true,
      "presets": [10, 15, 20, 25],
      "maxAmount": "100.00",
      "maxPercentage": 30
    },
    "tax": {
      "enabled": true,
      "enableRemove": false,
      "removePasswordProtected": false,
      "tax1": { "label": "Sales Tax", "value": "8.25" },
      "tax2": { "label": null, "value": null }
    },
    "inputFields": [
      {
        "key": "billing_name",
        "label": "Cardholder Name",
        "group": "general",
        "type": "text",
        "display": true,
        "required": true,
        "order": 1
      }
    ],
    "shipping": {
      "enabled": false,
      "sameAsBillingVisible": false
    }
  }
}
```

**Parameter Sections:**

| Section | Description |
|---------|-------------|
| `version` | Monotonically increasing integer; compare to locally cached version to detect changes |
| `signatureThreshold` | Decimal string; amount above which signature capture is required (`"0.00"` = always) |
| `isViewAllMerchantTransaction` | Whether the cashier can view all merchant transactions (not just this device's) |
| `transactions` | Which transaction types the device may perform |
| `requirePin` | Whether a merchant PIN is required to authorize void or refund |
| `paymentTypes` | Accepted payment input methods |
| `limits` | Per-transaction and rolling amount limits (null = no limit) |
| `receipt` | Receipt delivery and display options |
| `tips` | Tip prompting configuration |
| `tax` | Tax calculation and display |
| `inputFields` | Which billing/AVS fields to show and which are required |
| `shipping` | Whether to show shipping address fields |

Returns `404` with `"Device parameters not found"` when the device has no parameter record.

---

### Heartbeat

A lightweight keep-alive that returns the current parameter version. Poll this to detect config changes without fetching the full parameter payload.

```
GET /api/v1/device/heartbeat
```

**Auth:** User JWT + device token — both required. Device must be `ACTIVE`.

**Example:**

```bash
curl "https://uat.auxcore.net/api/v1/device/heartbeat" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response (200 OK):**

```json
{
  "success": true,
  "data": {
    "paramVersion": 7,
    "serverTime": "2026-04-14T10:30:00.000Z"
  }
}
```

**Usage pattern:**

```
App startup:
  1. GET /device/parameters  →  store locally with version = 7

Every N minutes:
  2. GET /device/heartbeat  →  returns { paramVersion: 8 }
  3. If paramVersion != local version:
       GET /device/parameters  →  refresh local config
```

> **Recommended heartbeat interval:** Every 60–120 seconds when the app is in the foreground; longer when in the background.

**Side effect:** Updates `lastUsedAt` on the device session (fire-and-forget, non-blocking).

`paramVersion` is `null` when the device has no parameter record.

---

### Unregister Device (`/device/logout`)

Releases this device from the merchant: the session is revoked and the device becomes `INACTIVE`. Call it only when the user deliberately unregisters the device, or when the app is being reset.

> ⚠️ **Despite the endpoint name, this is not user sign-out.** Signing a user out must **not** call this endpoint. Sign-out ends the user session only — the device stays `ACTIVE` and the app keeps its stored `merchantId → deviceToken` map, so the next login re-enters the merchant without a TPN prompt (see Multi-merchant device storage).
>
> After unregistering, also delete that merchant's entry from the stored map. The device can be registered again immediately with the same TPN + pairing code — no admin action required.

```
POST /api/v1/device/logout
```

**Auth:** User JWT + device token — both required.

```http
Authorization: Bearer <user_jwt>
X-Device-Token: <device_token>
X-Tenant-ID: your-tenant-id
```

**Example:**

```bash
curl -X POST "https://uat.auxcore.net/api/v1/device/logout" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response (200 OK):**

```json
{
  "success": true,
  "data": {
    "message": "Device logged out"
  }
}
```

**After calling this endpoint:**

1. Clear the stored `deviceToken` from secure storage.
2. The device status is now `INACTIVE` — the app cannot process payments until re-registered.
3. To reactivate: log in (`POST /auth/login`) and call `POST /device/register` again with the same TPN + pairing code. No admin action is required.

> **Note:** If you want to fully disable the device and prevent re-registration, ask an admin to **suspend** the device from the portal instead.

**Error Responses:**

| HTTP  | Message                  | Cause                                                 |
| ----- | ------------------------ | ----------------------------------------------------- |
| `401` | `UNAUTHORIZED`           | Missing, invalid, or already-revoked `X-Device-Token` (nested `error` body) |
| `403` | `DEVICE_NOT_ACTIVE`      | Device is not `ACTIVE` (route guard; nested `error` body with `currentStatus`) |
| `403` | `DEVICE_SUSPENDED`       | Device suspended by an admin |
| `403` | `DEVICE_CONTEXT_MISMATCH` | Device token belongs to a different merchant than the user JWT |
| `400` | `"Device is not active"` | Flat `{ success, message }` body; only reachable if the status changes between the guard and the update |

**Side effects:**

- Device status transitions to `INACTIVE`
- Current device session is revoked immediately (`DEVICE_LOGOUT`)

---

## Token Lifecycle

### When the Token is Revoked

The device JWT becomes invalid in the following situations:

| Reason              | Who Triggers It                 | Action Required                                                   |
| ------------------- | ------------------------------- | ----------------------------------------------------------------- |
| `NEW_REGISTRATION`  | New registration on same device | Re-register if this device was displaced                          |
| `PAIRING_REFRESHED` | Admin refreshes pairing code    | Obtain new TPN + pairing code from admin and re-register          |
| `SUSPENDED`         | Admin suspends the device, **or moves it out of `ACTIVE` to any status** (e.g. `INACTIVE`) | Contact admin, or re-register once the device is registrable again |
| `DELETED`           | Admin deletes the device        | Contact admin                                                     |
| `DEVICE_LOGOUT`     | App calls `POST /device/logout` | Re-register with same TPN + pairing code (no admin action needed) |
| `ARCHIVED`          | Admin archives the device       | Contact admin — an unarchived device returns as `INACTIVE` and must be re-registered |

> An admin deactivating a device now revokes its sessions immediately — the stored token stops working the moment the device leaves `ACTIVE`, it is not merely blocked at the route.

### Detecting Revocation

When the token is revoked, all device API calls (`/device/session`, `/device/me`, `/device/parameters`, `/device/heartbeat`, `/device/logout`, `/device/test-connection`) return:

```json
{
  "success": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Device token has been revoked"
  }
}
```

`message` is one of `"Device token is required in X-Device-Token header"`, `"Invalid device token"`, `"Invalid token type"`, `"Device session not found"`, `"Device token has been revoked"` or `"Device not found"`.

**HTTP Status:** `401 Unauthorized`

Your app should catch `401` responses on device endpoints, clear the stored `deviceToken`, and present the registration screen to the operator.

For payment APIs (`/transactions/*`, etc.) where `X-Device-Token` was included, a revoked or unknown device token returns the same `401 UNAUTHORIZED` body as above. A token that cannot be decoded at all returns:

```json
{
  "success": false,
  "error": {
    "code": "INVALID_DEVICE_TOKEN",
    "message": "Device token is malformed"
  }
}
```

If the device token cannot be checked because the merchant service is unreachable, payment APIs return `503 SERVICE_UNAVAILABLE` (`"Could not validate device token"`) — retry. `POST /merchants/verify-pin` reports every invalid device token as `401 INVALID_DEVICE_TOKEN`.

**HTTP Status:** `401 Unauthorized` — treat the same way: clear token, show registration screen.

---

## Implementation Guide

### Android

```kotlin
// Store device token securely
val sharedPreferences = EncryptedSharedPreferences.create(
    "device_prefs",
    MasterKey.DEFAULT_MASTER_KEY_ALIAS,
    context,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
sharedPreferences.edit().putString("device_token", deviceToken).apply()

// Device-only endpoints (me, parameters, heartbeat) — use both headers
val userJwt = sharedPreferences.getString("user_jwt", null)
val deviceToken = sharedPreferences.getString("device_token", null)
val deviceRequest = Request.Builder()
    .url(deviceEndpointUrl)
    .addHeader("Authorization", "Bearer $userJwt")
    .addHeader("X-Device-Token", deviceToken ?: "")
    .addHeader("X-Tenant-ID", tenantId)
    .build()

// Payment/notification endpoints — use both headers
val userJwt = sharedPreferences.getString("user_jwt", null)
val paymentRequest = Request.Builder()
    .url(transactionUrl)
    .addHeader("Authorization", "Bearer $userJwt")
    .addHeader("X-Device-Token", deviceToken ?: "")
    .addHeader("X-Tenant-ID", tenantId)
    .build()
```

### iOS (Swift)

```swift
// Store device token in Keychain
let tokenData = deviceToken.data(using: .utf8)!
let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "device_token",
    kSecValueData as String: tokenData
]
SecItemAdd(query as CFDictionary, nil)

// Device-only endpoints (me, parameters, heartbeat) — use both headers
var deviceRequest = URLRequest(url: deviceEndpointURL)
deviceRequest.setValue("Bearer \(userJwt)", forHTTPHeaderField: "Authorization")
deviceRequest.setValue(deviceToken, forHTTPHeaderField: "X-Device-Token")
deviceRequest.setValue(tenantId, forHTTPHeaderField: "X-Tenant-ID")

// Payment/notification endpoints — use both headers
var paymentRequest = URLRequest(url: transactionURL)
paymentRequest.setValue("Bearer \(userJwt)", forHTTPHeaderField: "Authorization")
paymentRequest.setValue(deviceToken, forHTTPHeaderField: "X-Device-Token")
paymentRequest.setValue(tenantId, forHTTPHeaderField: "X-Tenant-ID")
```

---

## Startup Checklist

Implement the following sequence when your app launches:

```
1. Read userJWT + devices{} map from secure storage
   └─ userJWT not found / expired → show Login screen
   └─ Resolve the active merchantId M from the userJWT

2. GET /device/session   (can I reuse the token stored for M?)
   Headers: Authorization: Bearer <userJWT>  +  X-Device-Token: devices[M]
   └─ devices[M] missing     → show Registration screen (enter TPN + pairing code)
   └─ 401 on userJWT         → refresh JWT first (POST /auth/refresh), retry
   └─ 401 / 403 on device    → delete devices[M], show Registration screen
   └─ reusable: false        → delete devices[M], show Registration screen
                               (reason NOT_ACTIVE / CONTEXT_MISMATCH /
                                REGISTERED_BY_ANOTHER_USER / DEVICE_NOT_FOUND)
   └─ reusable: true         → continue to step 3, do NOT prompt for TPN

3. GET /device/parameters  (load or refresh config)
   Headers: Authorization: Bearer <userJWT>  +  X-Device-Token: devices[M]
   └─ Store paramVersion locally

4. Refresh userJWT if needed
   └─ POST /auth/refresh with refreshToken
   └─ The refreshed token keeps merchant M — no need to re-select
   └─ Store new userJWT in secure storage

5. Start heartbeat loop (every 60–120s)
   Headers: Authorization: Bearer <userJWT>  +  X-Device-Token: devices[M]
   └─ GET /device/heartbeat
   └─ If paramVersion changed → GET /device/parameters
   └─ If 401 → delete devices[M], prompt re-registration
   └─ If 403 DEVICE_NOT_ACTIVE → navigate to Test Connection screen

6. For payment operations (transactions, payment-links):
   └─ Send both: Authorization: Bearer <userJWT>  +  X-Device-Token: devices[M]
   └─ If 401 UNAUTHORIZED / INVALID_DEVICE_TOKEN on the device token → delete devices[M], prompt re-registration
   └─ If 401 on userJWT (TOKEN_EXPIRED) → refresh JWT, retry once
   └─ If 403 DEVICE_SUSPENDED → show suspended screen
   └─ If 503 SERVICE_UNAVAILABLE → device check unavailable, retry

7. On merchant switch:
   └─ GET /auth/my-merchants          (list + hasActiveDevice badge per merchant)
   └─ POST /auth/switch-merchant      → store the new userJWT, M becomes the new merchant
   └─ Go back to step 2 with devices[new M]
   └─ Do NOT touch the device of the merchant being left — it stays ACTIVE

8. On user sign-out:
   └─ Clear userJWT + refreshToken only
   └─ KEEP the devices{} map — signing out does not release any device
   └─ Do NOT call POST /device/logout
   └─ Navigate to Login screen
       (next login re-enters each merchant without a TPN prompt)

9. On "Unregister this device" (explicit user action only):
   └─ POST /device/logout  (both headers — requires ACTIVE status)
   └─ Delete devices[M] from the map
   └─ (Device is now INACTIVE — re-register with the same TPN + pairing code to reactivate)
```

> **Steps 8 and 9 replace the old single "On user logout" step.** Previously the checklist told the app to call `/device/logout` and clear the token at sign-out; doing that now breaks the reuse behaviour, because the device drops to `INACTIVE` and the next login has to ask for the TPN again.

### Entering a merchant (first time and every time after)

The same path runs on first launch, after sign-in, and after every merchant switch. Only the left branch prompts for a TPN.

```
App start / sign-in / merchant switch
         │
         ▼
  POST /auth/login  (+ /auth/select-merchant if multi-merchant)
  or POST /auth/switch-merchant          →  userJWT scoped to merchant M
         │
         ▼
  devices[M] in secure storage?
         │
    ┌────┴─────────────────────┐
   no                         yes
    │                          │
    │                          ▼
    │                 GET /device/session
    │                          │
    │           ┌──────────────┴──────────────┐
    │      reusable: false               reusable: true
    │      401 / 403                          │
    │           │                             │
    │  delete devices[M]                      │
    ▼           ▼                             │
  Registration screen → enter TPN + pairing code
         │                                    │
         ▼                                    │
  POST /device/register                       │
         │                                    │
         ├─ Error → show message + retry      │
         │                                    │
         ▼                                    │
  nextStep == "test-connection" ?             │
         │                                    │
    ┌────┴────┐                               │
   yes        no                              │
    │         │                               │
    ▼         │                               │
  POST /device/test-connection                │
    │         │                               │
  ┌─┴──┐      │                               │
 Pass Fail    │                               │
  │    └→ show error + Retry                  │
  ▼         │                                 │
 status = ACTIVE                              │
  │         │                                 │
  └────┬────┘                                 │
       ▼                                      │
  store devices[M] = deviceToken              │
       │                                      │
       └──────────────┬───────────────────────┘
                      ▼
        Main Dashboard (ready for payments)
```

---

## Error Reference

### Standard Error Format

Device controller errors (register, me, parameters, heartbeat, logout, session) use a flat body:

```json
{
  "success": false,
  "message": "Human-readable description"
}
```

Route guards (device token, device status, user JWT) and the transaction service use a nested body — branch on `error.code`:

```json
{
  "success": false,
  "error": { "code": "DEVICE_NOT_ACTIVE", "message": "Device is not yet activated. Complete test-connection first." }
}
```

`POST /device/test-connection` uses its own flat body with `error`, `message`, `code` (HTTP status) and `retryable` — see Test Connection.

### Common Errors

| HTTP  | Code                     | Message                                                           | Recommended Action                                                                              |
| ----- | ------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `400` | —                        | `"tpn must be a 12-digit numeric code"`                           | TPN must be exactly 12 digits                                                                   |
| `400` | —                        | `"Invalid TPN"`                                                   | TPN not found or does not belong to the merchant in the user JWT                                |
| `400` | —                        | `"Invalid pairing code"`                                          | Verify the 6-digit code with admin                                                              |
| `400` | —                        | `"Device is not available for registration"`                      | Device is `SUSPENDED` or `ARCHIVED` — contact admin                                             |
| `400` | —                        | `"Device already registered..."`                                  | Device is `ACTIVE` — ask admin to deactivate or refresh pairing code                            |
| `422` | `GATEWAY_NOT_CONFIGURED` | `"No active TsysSierra gateway is configured for this merchant"`  | `test-connection`: merchant gateway config incomplete. `reason` ∈ `NO_ACTIVE_GATEWAY` \| `NO_CREDENTIALS` \| `INVALID_CREDENTIALS` \| `MISSING_VAR_SHEET` \| `INVALID_GATEWAY_CONFIG` — admin must fix in portal (not app-retryable) |
| `400` | `GATEWAY_NOT_SUPPORTED`  | `"Gateway <gateway> is not supported"`                            | `test-connection`: the device's resolved gateway has no payment adapter — contact admin         |
| `401` | `UNAUTHORIZED`           | `"Device token has been revoked"` (or another device-token message) | Device token revoked — clear `deviceToken`, show registration screen                            |
| `401` | `INVALID_DEVICE_TOKEN`   | `"Device token is malformed"`                                     | Sent an undecodable `X-Device-Token` on a payment API (`verify-pin` also uses this code for invalid/revoked tokens) — clear `deviceToken`, re-register |
| `401` | `TOKEN_EXPIRED`          | `"Access token has expired. Please refresh your token."`          | User JWT expired — call `POST /auth/refresh`, retry                                             |
| `403` | `DEVICE_SUSPENDED`       | `"This device has been suspended"`                                | Contact admin                                                                                   |
| `403` | `DEVICE_NOT_REGISTERED`  | `"Device has not been registered yet"`                            | Call `POST /device/register` first, then retry `test-connection`                                |
| `403` | `DEVICE_CONTEXT_MISMATCH` | `"Device token does not match authenticated merchant context"`   | Use the device token stored for the current merchant (or switch merchant)                       |
| `403` | `DEVICE_NOT_ACTIVE`      | `"Device is not yet activated. Complete test-connection first."`  | Navigate to Test Connection screen, call `POST /device/test-connection`                         |
| `404` | —                        | `"Device not found"`                                              | Token is valid but device was deleted — contact admin                                           |
| `200` | `alreadyActive`          | `"Device is already active — no test-connection required"`        | Calling `test-connection` on an already-`ACTIVE` device returns `200` (idempotent, not an error) — go to dashboard |
| `422` | `LIMIT_EXCEEDED`         | `"Amount exceeds device per-transaction limit of $500.00"`        | Amount exceeds the device's per-transaction limit, or would exceed its daily / weekly / monthly limit (`"Amount would exceed device daily limit of $5000"`) |
| `403` | `TX_TYPE_DISABLED`       | `"sale is not enabled on this device"`                            | Transaction type (`sale`, `auth`, `refund`, `void`) disabled in device parameters — update via portal |
| `403` | `PAYMENT_TYPE_DISABLED`  | `"ACH transfers are not enabled on this device"`                  | ACH sale attempted on a device with `achTransferEnabled: false`                                 |
| `400` | `DEVICE_CONTEXT_REQUIRED` | `"A valid X-Device-Token is required for this endpoint"`         | `POST /transactions/external-payment` called without a device token                            |
| `400` | —                        | `"Device is not active"`                                          | Calling `POST /device/logout` on a device that is not `ACTIVE`                                  |
| `503` | `PROCESSOR_UNREACHABLE`  | `"Failed to connect to payment processor"`                        | `test-connection`: the validation service was unreachable — retry after a short wait            |
| `500` | —                        | Server error                                                      | Retry with exponential backoff; alert if persistent                                             |

---

## Device Parameter Reference

### Top-level fields

| Field | Type | Description |
|-------|------|-------------|
| `version` | number | Parameter version. Increment detected via `/device/heartbeat` |
| `isViewAllMerchantTransaction` | boolean | If `true`, the cashier can see all merchant transactions, not only this device's |
| `signatureThreshold` | decimal string | Amount above which a signature is required; `"0.00"` = always require |

### `transactions` Section

| Field            | Type    | Description                               |
| ---------------- | ------- | ----------------------------------------- |
| `saleEnabled`    | boolean | Whether Sale transactions are permitted   |
| `refundEnabled`  | boolean | Whether Refund transactions are permitted |
| `voidEnabled`    | boolean | Whether Void transactions are permitted   |
| `preauthEnabled` | boolean | Whether Pre-authorization is permitted    |

### `requirePin` Section

| Field | Type | Description |
|-------|------|-------------|
| `pinVoidEnabled` | boolean | Whether a merchant PIN must be entered to authorize a Void |
| `pinRefundEnabled` | boolean | Whether a merchant PIN must be entered to authorize a Refund |

### `paymentTypes` Section

| Field                | Type    | Description         |
| -------------------- | ------- | ------------------- |
| `tapToPayEnabled`    | boolean | NFC / Tap-to-pay    |
| `keyedInEnabled`     | boolean | Manual card entry   |
| `qrCodeEnabled`      | boolean | QR code payment     |
| `paymentLinkEnabled` | boolean | Send-a-link payment |
| `achTransferEnabled` | boolean | ACH bank transfer   |
| `cashEnabled`        | boolean | Cash / check payment |

### `limits` Section

All values are decimal strings (e.g. `"500.00"`) or `null` (no limit).

| Field            | Description                       |
| ---------------- | --------------------------------- |
| `perTransaction` | Maximum single transaction amount |
| `daily`          | Maximum cumulative daily volume   |
| `weekly`         | Maximum cumulative weekly volume  |
| `monthly`        | Maximum cumulative monthly volume |

### `tips` Section

| Field           | Type             | Description                                      |
| --------------- | ---------------- | ------------------------------------------------ |
| `allowTips`     | boolean          | Whether tip prompt is shown                      |
| `presets`       | number[] \| null | Preset tip percentages (e.g. `[10, 15, 20, 25]`) |
| `maxAmount`     | decimal string   | Maximum tip amount in dollars                    |
| `maxPercentage` | number           | Maximum tip percentage                           |

### `tax` Section

| Field                     | Type                   | Description                                    |
| ------------------------- | ---------------------- | ---------------------------------------------- |
| `enabled`                 | boolean                | Whether tax is calculated                      |
| `enableRemove`            | boolean                | Whether cashier can remove tax                 |
| `removePasswordProtected` | boolean                | Whether removing tax requires a password       |
| `tax1.label`              | string                 | Primary tax label (e.g. `"Sales Tax"`)         |
| `tax1.value`              | decimal string         | Primary tax rate as percentage (e.g. `"8.25"`) |
| `tax2.label`              | string \| null         | Secondary tax label (optional)                 |
| `tax2.value`              | decimal string \| null | Secondary tax rate (optional)                  |

### `receipt` Section

| Field               | Type           | Description                          |
| ------------------- | -------------- | ------------------------------------ |
| `headerMsg`         | string \| null | Custom header printed on receipt     |
| `footerMsg`         | string \| null | Custom footer printed on receipt     |
| `disclaimer`        | string \| null | Legal disclaimer text                |
| `autoPrintEnabled`  | boolean        | Automatically print the receipt after a successful payment. `false` = do not print automatically (the cashier may still trigger a manual print). Defaults to `true` |
| `printMerchantCopy` | boolean        | Whether to print a merchant copy     |
| `smsEnabled`        | boolean        | Offer SMS receipt                    |
| `emailEnabled`      | boolean        | Offer email receipt                  |
| `signatureCapture`  | boolean        | Whether to capture digital signature |

### `posLite` Section

| Field     | Type    | Description |
| --------- | ------- | ----------- |
| `enabled` | boolean | **POS Lite** — show the product cart (cart icon, product tiles, misc item) on this device. `false` = hide it; the app takes a bare amount as before. Defaults to `true`. A display switch only: the API does not refuse an itemised sale from a device that has it off. |

Configured per device by the merchant or an admin (Locations & Devices → device → Edit Parameters →
Transactions tab → Device → **POS Lite**). Changing it bumps `version`, so the heartbeat tells the app
to re-fetch.

`productCart` carries the same `enabled` value under the section's former name. It is deprecated:
read `posLite`; `productCart` is kept only for app builds released before the rename.

### `inputFields` Section

Each entry in `inputFields` describes a field on the payment form:

| Property   | Type    | Description                                                     |
| ---------- | ------- | --------------------------------------------------------------- |
| `key`      | string  | Unique field identifier                                         |
| `label`    | string  | Display label                                                   |
| `group`    | enum    | `general` \| `avs` \| `transaction` \| `security` \| `shipping` |
| `type`     | enum    | `text` \| `email` \| `phone` \| `textarea` \| `checkbox`        |
| `display`  | boolean | Whether to show this field                                      |
| `required` | boolean | Whether the field is mandatory                                  |
| `order`    | number  | Render order (ascending)                                        |
| `locked`   | boolean | Optional. `true` when the merchant's Field Config requires the field — do not let the cashier change it |

Only render fields where `display: true`. Enforce `required: true` fields on form submission.

When the merchant saves their Field Config, the `device` column (terminals) or `mobile` column (phones) is applied
here: a required field becomes `display: true, required: true, locked: true`, a field switched off becomes
`display: false`. Billing Address drives the `avs` group; Shipping Address is not applicable to devices and leaves
the `shipping` group as configured per device.

---

---

## Payment Flow by Device Type

The combination of `manufacturer`, `deviceType`, and `paymentMode` (from `/device/register` or `/device/me`) determines the payment UI and SDK behavior:

| manufacturer | deviceType | paymentMode | CVV required? | Payment SDK | Notes |
|---|---|---|---|---|---|
| `ANDROID_PHONE` | `TAP_ON_PHONE` | `NEARPAY` | ❌ No | NearPay Android SDK | NFC tap-to-pay; use `nearpayTid` + NearPay JWT |
| `APPLE_PHONE` | `TAP_ON_PHONE` | `IPOS` | ❌ No | iPOSpays SDK | Apple flow via `iposTpn` |
| `SUNMI` | `CARD_READER` | `AUXVAULT` | ✅ Yes | AuxCORE SDK | Card entered on Sunmi hardware |
| `SUNMI` | `POS_TERMINAL` | `NEARPAY` | ❌ No | NearPay Android SDK | NFC tap on Sunmi POS; use `nearpayTid` + NearPay JWT |
| `SUNMI` | `POS_TERMINAL` | `AUXVAULT` | ✅ Yes | AuxCORE SDK | Keyed entry on Sunmi POS |

### NearPay SDK initialization

When `paymentMode = "NEARPAY"`:

1. From `POST /device/register` → read `data.jwtNearpayDevice`.
2. From `GET /device/me` → read `data.nearpayJWT` (same RS256 token, different key name).
3. Read `nearpayTid` from the device object.
4. Initialize: `NearPay.init(tid: nearpayTid, jwt: <jwtNearpayDevice|nearpayJWT>)`.

Persist whichever JWT you receive and reuse it until it expires or the device re-registers.

### Price adjustment display

When `paymentAdjustmentType != "NONE"`, call `GET /transactions/fee-preview` **before** showing the price — always display the server-computed amount, never calculate the adjustment in the app. See Dual pricing.

---

## Payment & Notification APIs from Device

After device registration, the mobile app calls standard payment APIs using **both headers**. The server uses the device token to apply device-level rules and record which device processed each transaction.

### Calling a Transaction

```bash
curl -X POST "https://uat.auxcore.net/api/v1/transactions/sale" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "merchantId": "merchant_123",
    "amount": 125.00,
    "currency": "USD",
    "pciToken": "pci_tok_9f2c4e7a1b3d"
  }'
```

When `X-Device-Token` is present, the server:

- Enforces **device parameter limits** (`perTransaction`, plus `daily` / `weekly` / `monthly` volume) — `422 LIMIT_EXCEEDED`
- Validates the **transaction type is enabled** on this device (`saleEnabled`, `preauthEnabled`, `refundEnabled`, `voidEnabled`) — `403 TX_TYPE_DISABLED`
- Rejects ACH sales when `achTransferEnabled` is off — `403 PAYMENT_TYPE_DISABLED`
- Records the `deviceId` and the device's location (`branchId`, `branchName`) on the transaction for audit and reporting

Gateway routing still uses the merchant's gateway configuration; the device's `mid` / `terminalId` are only stamped on transactions recorded through `POST /transactions/external-payment`.

### Cash Sale

A cash (or check) sale is a normal sale with `paymentMethod: "Cash"` (or `"Check"`, case-insensitive). It skips gateway routing and pricing and is recorded immediately as `settled`.

```http
POST /api/v1/transactions/sale
```

```bash
curl -X POST "https://uat.auxcore.net/api/v1/transactions/sale" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "merchantId": "merchant_123",
    "amount": 21.65,
    "baseAmount": 20.00,
    "taxAmount": 1.65,
    "tipAmount": 0,
    "currency": "USD",
    "paymentMethod": "Cash"
  }'
```

- Only `sale` is supported for cash — `POST /transactions/auth` with `paymentMethod: "Cash"` returns `400 VALIDATION_ERROR`.
- The record gets `transactionId` `CASH-<timestamp>-<suffix>`, `status: "settled"`, `gatewayName: "Cash"`, and no convenience fee. `baseAmount` defaults to `amount - tipAmount - taxAmount`.
- Cash transactions **cannot be voided** (`400 "Void is not supported for Cash transactions. Please issue a refund instead."`). Refund them with `POST /transactions/:id/refund` — **full refund only**; a smaller `amount` returns `400 "Cash refunds support full refund only"`.

### Record External SDK Payment

For `IPOS` and `NEARPAY` devices the card is charged by the provider SDK on the device, not by AuxCORE. After the SDK approves, record the platform fee breakdown (which the provider webhook never sees) with this endpoint.

```http
POST /api/v1/transactions/external-payment
```

**Auth:** User JWT + device token — the device token is **required** (`400 DEVICE_CONTEXT_REQUIRED` without it). The merchant and device come from the device token.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `gatewayTransactionId` | string | Yes | Transaction ID returned by the provider SDK |
| `paymentMode` | string | Yes | `IPOS` \| `NEARPAY` (case-insensitive) |
| `amount` | number | Yes | Total charged; must be greater than 0 |
| `type` | string | No | `sale` (default) \| `auth` |
| `baseAmount` | number | No | Defaults to `amount - tipAmount - taxAmount - convenienceFee` |
| `tipAmount` | number | No | Default 0 |
| `taxAmount` | number | No | Default 0 |
| `convenienceFee` | number | No | Default 0 |
| `currency` | string | No | Default `USD` |
| `cardLast4` | string | No | Last 4 digits of the card |
| `cardBrand` | string | No | Card brand |
| `invoiceNumber` | string | No | Invoice number this payment settles |
| `invoiceId` | string | No | Invoice ID this payment settles |
| `orderId` | string | No | Your order reference |
| `metadata` | object | No | Free-form metadata |

```bash
curl -X POST "https://uat.auxcore.net/api/v1/transactions/external-payment" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "gatewayTransactionId": "NP-7781203345",
    "paymentMode": "NEARPAY",
    "amount": 54.00,
    "baseAmount": 50.00,
    "tipAmount": 4.00,
    "cardLast4": "4242",
    "cardBrand": "Visa"
  }'
```

**Responses:**

- `201 Created` — no record existed yet; a `pending` transaction is created (`transactionId` `NPAY-<gatewayTransactionId>` or `IPOS-<gatewayTransactionId>`). The provider webhook later sets the final status.
- `200 OK` — the webhook already created the transaction; only the fee breakdown (and a missing `invoiceId`) is filled in. Status and card data stay as the webhook set them.

Both return `{ "success": true, "data": { ...transaction } }`. Validation failures return `400 VALIDATION_ERROR` (`"gatewayTransactionId is required"`, `"paymentMode must be one of: IPOS, NEARPAY"`, `"Valid amount is required"`, `"type must be one of: sale, auth"`).

### Itemised sales (POS cart)

For the focused, shareable five-endpoint request contract and Postman verification matrix, see
[POS Itemised Payment Contract](./pos-itemised-payment-contract.md).

A sale may carry the cart it sold, so the transaction detail and the receipt list the items and the
products count toward "most sold" (see [Product Catalog](./product-catalog.md), section "Tiles").
Send `lineItems` on any of:

- `POST /transactions/sale` (card and cash) and `POST /transactions/auth`
- `POST /transactions/token-sale`
- `POST /transactions/ach-sale`
- `POST /transactions/external-payment` (IPOS / NearPay)

Without `lineItems` a sale works exactly as before.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | No | `product` \| `misc`. Defaults to `product` when `productId` is present, otherwise `misc` |
| `productId` | uuid | For `product` | Device catalog product id (`GET /device/products`). Must be absent on a `misc` line |
| `name` | string | Yes | As shown on the receipt, max 150 characters. Copy the product name for a product line; free text for misc |
| `quantity` | number | Yes | Greater than 0, up to **3 decimal places** (`2.5` gallons) |
| `unit` | string | No | Send the product's `unit` code (`gallon`, `hour`, …; see [Products](../invoicing/products.md)). Receipts print it as its label (`2.5 gallon`); other text is printed as sent. Max 50 characters |
| `unitPrice` | number | Yes | 0 or greater, at most 2 decimal places |
| `lineTotal` | number | No | `quantity × unitPrice` rounded to cents. Computed when omitted; if sent it must match within $0.01 |

This `type` belongs to the transaction line, not the catalog item. A catalog-backed row uses
`type: "product"` plus its `productId` whether `GET /device/products` returned catalog type
`product` or `service`; the server resolves the catalog type during preflight, and a service never
tracks inventory. Reserve `type: "misc"` for a free-text till row with no `productId`. Do not send
`type: "service"` in `lineItems`.

Rules:

- At most 200 lines. An empty array is the same as no cart.
- The lines must add up to the sale's **`baseAmount`** — the amount before tip, tax and convenience fee — within $0.01. Send `baseAmount` explicitly, or the server derives it as `amount - tipAmount - taxAmount - convenienceFee`.
- A **misc** line ("Miscellaneous": a name and an amount typed at the till) is stored on the sale only. It never creates a catalog product and never counts toward most sold.
- The lines are a snapshot: editing or deleting the product later does not change the sale.
- Product quantities are aggregated across duplicate lines and checked against active catalog
  state, `maxQuantity`, the current device branch, inventory policy, and server-calculated
  Available. Card/cash sale, token sale, AUTH, and ACH submission fail closed before payment when
  validation rejects the cart or the Invoice service is unavailable.
- `allowZeroStockSale: false` rejects insufficient Available before payment. With
  `allowZeroStockSale: true`, Available 2 and quantity 5 can complete: inventory records deducted 2,
  not deducted 3, and Available 0. The not-deducted 3 is immutable history; later Quantity In does
  not consume it.
- Service, misc, and untracked product lines need no stock location. A tracked product requires the
  device's current active physical branch; pairing can remain valid even when that branch is absent.

```bash
curl -X POST "https://uat.auxcore.net/api/v1/transactions/sale" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "merchantId": "merchant_123",
    "amount": 19.98,
    "baseAmount": 18.48,
    "taxAmount": 1.50,
    "paymentMethod": "Cash",
    "lineItems": [
      { "productId": "3f2b8c1e-4d5a-4e6f-8a7b-9c0d1e2f3a4b", "name": "Diesel", "quantity": 2.5, "unit": "gallon", "unitPrice": 4.19 },
      { "type": "misc", "name": "Car wash", "quantity": 1, "unitPrice": 8.00 }
    ]
  }'
```

The transaction returned (and `GET /transactions/:id`) carries the normalised cart:

```json
"lineItems": [
  { "type": "product", "productId": "3f2b8c1e-...", "name": "Diesel", "quantity": 2.5, "unit": "gallon", "unitPrice": 4.19, "lineTotal": 10.48 },
  { "type": "misc", "productId": null, "name": "Car wash", "quantity": 1, "unit": null, "unitPrice": 8, "lineTotal": 8 }
]
```

The transaction **list** (`GET /transactions`) does not include `lineItems`; read them from the
detail. Email, printed and PDF receipts list the items; the SMS receipt does not.

Errors are `400 INVALID_LINE_ITEMS`, checked before the card is charged. The message names the line
(`"lineItems[1]: quantity must have at most 3 decimal places"`) and `error.details.line` carries its
index; a total mismatch reports `details.lineItemsTotal` and `details.baseAmount`.

A cart over a product's per-order limit is `400 QUANTITY_LIMIT_EXCEEDED`, also before any charge:

```json
{
  "success": false,
  "error": {
    "code": "QUANTITY_LIMIT_EXCEEDED",
    "message": "\"Diesel\" is limited to 50 per order",
    "details": {
      "violations": [
        {
          "code": "QUANTITY_LIMIT_EXCEEDED",
          "productId": "3f2b8c1e-...",
          "message": "\"Diesel\" is limited to 50 per order",
          "maxQuantity": 50,
          "requested": 60
        }
      ]
    }
  }
}
```

For `external-payment`, send `lineItems` with the device's fee breakdown. If the provider webhook
already created the transaction, the cart is added to it. Because the money already exists, a
failed inventory preflight preserves the financial record; when an external SALE reaches a final
approved/settled status, the server queues inventory work when POS settlement is enabled. Once
Invoice accepts that work, unresolved stock or location outcomes become Pending Review. A terminal
delivery failure remains operational outbox work and never changes the payment result.

#### Payment finality and inventory

The inventory effects below assume the server-side POS inventory settlement rollout is enabled.
The mobile client cannot read or control that setting and must not treat payment success as proof
of a completed stock deduction.

| Payment lifecycle | Physical inventory effect |
|---|---|
| Approved card/token SALE or completed cash-equivalent SALE | Queue one settlement |
| Approved AUTH | None |
| Successful CAPTURE | Queue one settlement |
| ACH accepted/pending | None |
| ACH settled | Queue one settlement |
| Confirmed external SALE | Queue one settlement; accepted stock/location conflicts become Pending Review |
| External AUTH | None until a successful CAPTURE |
| Declined, failed, or velocity `IN_REVIEW` | None; later approval queues once |
| Duplicate callback/finalizer | No additional deduction |
| Void, refund, reversal, dispute, ACH return, or customer return | No automatic restock |

Payment remains successful if inventory delivery or application fails. Staff can retry Invoice
Pending Review or correct a missing/inactive physical location; operational staff handle terminal
Transaction outbox failures separately. These actions are idempotent and must never create a
replacement payment. Physical returns require a separate Quantity In movement with Customer Return
reason. Refresh the device product catalog after a completed sale.

### Querying Transactions from a Device

```bash
curl "https://uat.auxcore.net/api/v1/transactions" \
  -H "Authorization: Bearer USER_JWT_HERE" \
  -H "X-Device-Token: DEVICE_TOKEN_HERE" \
  -H "X-Tenant-ID: your-tenant-id"
```

When `X-Device-Token` is present, results are **filtered to this device only** — unless the device parameter `isViewAllMerchantTransaction` is `true`, in which case they cover the whole merchant. Without the device token, results include all transactions for the merchant.

---

## Related

- [Device Management (Portal API) →](../merchants/devices.md)
- [Authentication →](../getting-started/authentication.md)
- [Error Codes →](../reference/error-codes.md)

---

## Changelog

> **For mobile developers:** This section tracks breaking changes, new endpoints, and behavior changes that require app updates. Review each entry before upgrading your integration.

---

### v2.5.2 — 2026-10-14 — Field Config reaches the right device fields

**Type:** Value fix — no change to the response shape.

- A merchant's Field Config now sets `inputFields` by the field catalog: Billing Address drives the `avs_*` fields (it used to drive `shipping_*`), Customer Name drives `customer_name`, and Shipping Address no longer touches `shipping.enabled` or the `shipping_*` fields. Values change on a device the next time the merchant saves Field Config, and the bumped `version` makes the heartbeat re-fetch.
- `locked` (already sent on some entries) is now documented.

---

### v2.5.1 — 2026-10-14 — Product cart renamed POS Lite

**Type:** Rename — no breaking change.

- `GET /device/parameters` returns the cart switch as `posLite.enabled`. `productCart.enabled` still returns the same value but is deprecated; read `posLite`.

---

### v2.5.0 — 2026-10-14 — POS product cart (TNEW-350)

**Type:** New optional fields — no breaking change.

- `GET /device/parameters` returns `productCart.enabled` (default `true`): show or hide the cart.
- Sales accept an optional `lineItems` cart (product and misc lines, decimal quantities) — see "Itemised sales (POS cart)" above. It is validated against `baseAmount`, stored on the transaction, shown on the portal and printed on email / print / PDF receipts.
- `GET /products` adds `isFavorite`, `soldQuantity`, `unit` / `unitLabel`, the `favorite` filter, `sort`, and `view=most_sold` for the tile view — see [Product Catalog](./product-catalog.md). Favorites and units are set by the merchant in the web portal; the app only reads them.

---

### v2.4.0 — 2026-08-10 — Merchant switching without re-entering TPN

**Type:** New endpoints + behavior change
**Affects:** apps whose users belong to more than one merchant

#### What changed

A user can now switch merchant without logging out, and returns to a merchant they previously registered on **without re-entering TPN + Pairing Code** — as long as that device is still `ACTIVE`.

| Endpoint | Change |
| --- | --- |
| `GET /api/v1/auth/my-merchants` | **New.** Merchants for the signed-in user, each with `deviceRegistration.hasActiveDevice` |
| `POST /api/v1/auth/switch-merchant` | **New.** Re-issues the access token scoped to another merchant |
| `GET /api/v1/device/session` | **New.** Reports whether the stored device token is still reusable — `200` with `reusable` + `reason`, never a `403` |
| `POST /api/v1/auth/refresh` | Now keeps the selected merchant. It previously reverted to the user's primary merchant |
| `GET /api/v1/auth/me` | Now returns the merchant the session is scoped to |
| `POST /device/register` | Records the registering user; that user may re-pair their own `ACTIVE` device after losing the stored token |
| `PATCH /merchants/{merchantId}/devices/{deviceId}/status` | Any move out of `ACTIVE` now revokes device sessions immediately, not only `SUSPENDED` |

#### Required app changes

1. **Store `merchantId → deviceToken`**, not a single token. See Multi-merchant device storage.
2. **Do not call `POST /device/logout` on user sign-out.** That endpoint unregisters the device and sets it `INACTIVE`, which breaks the "sign out then sign back in without a TPN prompt" behaviour. Keep the stored map across sign-out.
3. After switching merchant, call `GET /device/session` before deciding whether to show Device Registration.
4. Replace the startup `GET /device/me` probe with `GET /device/session` — `/device/me` answers `403` for a device that is merely inactive, which is an error path rather than a decision signal.

#### Documentation corrected in this release

The Startup Checklist previously instructed the app to call `POST /device/logout` and clear the stored token on user sign-out. That guidance is now wrong and has been split into two separate steps — sign-out (step 8, keeps everything) and Unregister Device (step 9, the only step allowed to call `/device/logout`). The Integration Flow and the entry-flow diagram were rewritten to include login, merchant selection and the session check.

---

### v2.3.0 — 2026-06-25 — Test Connection validates gateway config

**Type:** Behavior change + error-code change
**Affects:** `POST /device/test-connection` response and error codes

#### What changed

`POST /device/test-connection` previously validated **device-level** credentials and pinged the
processor with a hardcoded health URL. It now validates the **merchant payment gateway
configuration** the device will actually use at payment time — an ACTIVE gateway of the device's
type, with complete credentials accepted by the gateway adapter (plus a VAR sheet for TSYS).
**Config validation only — no processor call, no money moved.**

#### Response — additive fields

```json
{ "status": "ACTIVE", "gatewayType": "TsysSierra", "configStatus": "VALID", "processorStatus": "CONNECTED" }
```

- `gatewayType` (new), `configStatus: "VALID"` (new). `processorStatus` retained for backward compatibility.

#### Error codes — changed

| Old (≤ v2.2) | New (v2.3) |
|---|---|
| `400 INVALID_CREDENTIALS` / `400 CREDENTIALS_MISSING` (device creds) | `422 GATEWAY_NOT_CONFIGURED` with `reason` ∈ `NO_ACTIVE_GATEWAY` \| `NO_CREDENTIALS` \| `INVALID_CREDENTIALS` \| `MISSING_VAR_SHEET` |
| — | `400 GATEWAY_NOT_SUPPORTED` (no adapter for the resolved gateway) |
| `422 DEVICE_ALREADY_ACTIVE` | `200` idempotent with `alreadyActive: true` |
| `503 PROCESSOR_UNREACHABLE` (processor down) | `503 PROCESSOR_UNREACHABLE` (validation service unreachable) |

#### Required app changes

```
☐ Read gatewayType / configStatus from the success response (optional; additive)
☐ Handle 422 GATEWAY_NOT_CONFIGURED as NON-retryable — show reason + "contact administrator"
   (admin must fix the gateway config / VAR sheet / credentials in the portal)
☐ Handle 400 GATEWAY_NOT_SUPPORTED
☐ Treat a 200 alreadyActive response as success (stop expecting 422 DEVICE_ALREADY_ACTIVE)
☐ Keep retry only for 503 PROCESSOR_UNREACHABLE
```

> NearPay devices are validated locally (Terminal ID present + NearPay JWT mintable); see Test Connection and Payment Flow by Device Type.

---

### v2.2.0 — 2026-06-16 — Device Config Redesign

**Type:** ⚠️ Breaking (fields removed) + Additive (new fields)
**Affects:** `POST /device/register` response, `GET /device/me` response

---

#### Impact Assessment

| Area | Impact | Action Required |
|------|--------|-----------------|
| `paymentMethod` removed from response | ❌ **Breaking** | Remove reads; field is absent |
| `routingType` removed from response | ❌ **Breaking** | Remove reads; field is absent |
| `processorId` removed from response | ❌ **Breaking** | Remove reads; field is absent |
| `bin` removed from response | ❌ **Breaking** | Remove reads; field is absent |
| New: `paymentAdjustmentType` / `Value` / `CardValue` / `AchValue` | ✅ Additive | Read and apply for price display |
| New: `autoSettleEnabled` / `settleAt` | ✅ Additive | Informational; no app action needed |
| New: `nearpayTid`, `sunmiSN`, `iposTpn`, `iposTpnCode` | ✅ Additive | Use for SDK init per payment mode |
| New: `jwtNearpayDevice` in register response | ✅ Additive | Pass to NearPay SDK when `paymentMode = NEARPAY` |
| New: `nearpayJWT` in `/device/me` response | ✅ Additive | Pass to NearPay SDK when `paymentMode = NEARPAY` |
| `paymentMode` renamed "Payment Gateway" in portal UI | ✅ No API change | Field name and enum values unchanged |
| `deviceType` portal labels corrected | ✅ No API change | API enum values were always correct |
| New `SUNMI` manufacturer enum value | ✅ Additive | Handle in switch/if statements |
| New `NEARPAY` payment mode enum value | ✅ Additive | Handle in switch/if statements |

---

#### ⚠️ Breaking: 4 fields removed from device responses

The following columns were dropped from the database. They no longer appear in `POST /device/register` or `GET /device/me` responses:

| Removed Field | Was | Replacement |
|--------------|-----|-------------|
| `paymentMethod` | `"CRDT"` \| `"ACH"` \| `"BOTH"` | Moved to Merchant Gateway Config (future) |
| `routingType` | `"SIMPLE"` | Moved to Merchant Gateway Config (future) |
| `processorId` | string \| null | Moved to Merchant Gateway Config (future) |
| `bin` | string \| null | Moved to Merchant Gateway Config (future) |

**Required app changes:**
```
// Remove these from Device model/struct:
paymentMethod  ← absent from response
routingType    ← absent from response
processorId    ← absent from response
bin            ← absent from response
```

---

#### New: `paymentAdjustmentType`, `paymentAdjustmentValue`, `paymentAdjustmentCardValue` and `paymentAdjustmentAchValue`

```json
{
  "paymentAdjustmentType": "FLAT",
  "paymentAdjustmentValue": "1.5000",
  "paymentAdjustmentCardValue": "2.0000",
  "paymentAdjustmentAchValue": "0.5000"
}
```

| Value | Meaning | How to Display |
|-------|---------|----------------|
| `NONE` | No adjustment | Show base price as-is |
| `FLAT` | Fixed dollar surcharge | `displayPrice = baseAmount + parseFloat(value)` |
| `PERCENTAGE` | Percentage surcharge | `displayPrice = baseAmount * (1 + parseFloat(value) / 100)` |

##### Pricing Model Specifics:
- **`flat` pricing model** (or no pricing model configured): Use `paymentAdjustmentValue` for single adjustment display.
- **`cashdiscount` pricing model**: Use `paymentAdjustmentCardValue` for credit card calculations and `paymentAdjustmentAchValue` for ACH payment calculations.

> **Display only** — do not apply to the transaction amount. The server computes the final adjusted amount.

---

#### New: `autoSettleEnabled` and `settleAt`

```json
{ "autoSettleEnabled": true, "settleAt": "17:00" }
```

Informational only. Settlement runs server-side; no mobile app action required.

---

#### New: `jwtNearpayDevice` (register) and `nearpayJWT` (me)

When `paymentMode = "NEARPAY"`, responses include a signed RS256 JWT for the NearPay SDK.

> ⚠️ **Key name differs by endpoint:** `POST /device/register` → `data.jwtNearpayDevice` | `GET /device/me` → `data.nearpayJWT`

Store alongside `deviceToken` and pass to the NearPay SDK with `nearpayTid`.

---

#### New: `nearpayTid`, `sunmiSN`, `iposTpn`, `iposTpnCode`

| Field | Present When |
|-------|-------------|
| `nearpayTid` | `paymentMode = NEARPAY` |
| `sunmiSN` | `manufacturer = SUNMI` |
| `iposTpn` | `paymentMode = IPOS` |
| `iposTpnCode` | `paymentMode = IPOS` |

---

#### New enum values

| Enum | New Value | Meaning |
|------|-----------|---------|
| `manufacturer` | `SUNMI` | Physical Android POS hardware |
| `paymentMode` | `NEARPAY` | NearPay gateway; no CVV required |

---

#### No-impact: `deviceType` portal labels corrected

The portal previously showed "Countertop" / "Mobile Terminal". Corrected to "Card Reader" / "POS Terminal" to match API enum values. **The API values `CARD_READER` and `POS_TERMINAL` were always correct** — no mobile app changes needed.

---

#### Migration checklist for mobile developers

```
Breaking — must fix:
  ☐ Remove paymentMethod from Device model/struct
  ☐ Remove routingType from Device model/struct
  ☐ Remove processorId from Device model/struct
  ☐ Remove bin from Device model/struct

New fields — add to Device model/struct:
  ☐ paymentAdjustmentType: String    ("NONE" | "FLAT" | "PERCENTAGE")
  ☐ paymentAdjustmentValue: String?  (decimal string, e.g. "1.5000")
  ☐ paymentAdjustmentCardValue: String? (decimal string, e.g. "2.0000")
  ☐ paymentAdjustmentAchValue: String?  (decimal string, e.g. "0.5000")
  ☐ autoSettleEnabled: Boolean
  ☐ settleAt: String?                ("HH:MM" format)
  ☐ nearpayTid: String?
  ☐ sunmiSN: String?
  ☐ iposTpn: String?
  ☐ iposTpnCode: String?

New enum values:
  ☐ Add SUNMI to manufacturer enum
  ☐ Add NEARPAY to paymentMode enum

Logic:
  ☐ Apply paymentAdjustmentType/Value/CardValue/AchValue in price display before confirmation
  ☐ Pass jwtNearpayDevice (register) or nearpayJWT (me) + nearpayTid to NearPay SDK
  ☐ SUNMI + CARD_READER → no CVV input
  ☐ NEARPAY → no CVV input
```

---

### v2.0.0 — 2026-06-02 — Device Activation Flow (Phase 1)

**Type:** Breaking change  
**Affects:** Registration flow, device status handling, app navigation after register

#### ⚠️ Breaking: `POST /device/register` no longer activates the device

**Before:**
```
POST /device/register  →  device.status = "ACTIVE"  →  app goes to dashboard
```

**After:**
```
POST /device/register  →  device.status = "REGISTERED"  →  app must call test-connection
POST /device/test-connection (pass)  →  device.status = "ACTIVE"  →  app goes to dashboard
```

**Required app changes:**
1. After `/register` succeeds, check `data.testConnectionRequired === true`
2. Navigate to a new **Test Connection** screen instead of the dashboard
3. Call `POST /device/test-connection` — show loading state
4. On success (`status = "ACTIVE"`) → navigate to dashboard
5. On failure → show error + Retry button

#### New: `REGISTERED` device status

A new intermediate status has been added to the `DeviceStatus` enum:

| Status | Meaning | Can process payments? | Previous behavior |
|--------|---------|----------------------|-------------------|
| `PENDING` | Admin created, app not yet registered | ❌ No | Unchanged |
| `REGISTERED` | App registered, test-connection pending | ❌ No | **New** |
| `ACTIVE` | Fully activated | ✅ Yes | Previously set by `/register` |
| `INACTIVE` | Deactivated | ❌ No | Unchanged |
| `SUSPENDED` | Locked by admin | ❌ No | Unchanged |

**App startup — handle new `REGISTERED` status:**
```
GET /device/me
  └─ status == "REGISTERED"  →  navigate to Test Connection screen  ← NEW case
  └─ status == "ACTIVE"      →  proceed to dashboard (unchanged)
  └─ status == "SUSPENDED"   →  show suspended screen (unchanged)
```

#### New: `POST /device/test-connection` endpoint

| Property | Value |
|----------|-------|
| Method | `POST` |
| Path | `/api/v1/device/test-connection` |
| Auth | `Authorization: Bearer <user_jwt>` + `X-Device-Token: <device_token>` |
| Required device status | `REGISTERED` |
| Body | None |

**New error codes to handle:**

| HTTP | Code | When to show | Action |
|------|------|--------------|--------|
| `403` | `DEVICE_NOT_REGISTERED` | Device is still `PENDING` | Call `/register` first |
| `422` | `DEVICE_ALREADY_ACTIVE` | Device already `ACTIVE` | Go directly to dashboard |
| `503` | `PROCESSOR_UNREACHABLE` | Processor down / network | Show retry; wait 10–30s |
| `400` | `INVALID_CREDENTIALS` | Credentials misconfigured | Show error; contact admin |
| `429` | `RATE_LIMITED` | Too many attempts | Show `retryAfterSeconds` countdown |

#### New: `DEVICE_NOT_ACTIVE` error on device endpoints

Routes `/me`, `/parameters`, `/heartbeat`, `/logout` now return `403 DEVICE_NOT_ACTIVE` if device is `REGISTERED` (not yet activated).

**Handle this in your startup and heartbeat loops:**
```
403 DEVICE_NOT_ACTIVE  →  navigate to Test Connection screen
```

#### Register response — new fields

`POST /device/register` response now includes additional fields to guide the app:

```json
{
  "success": true,
  "data": {
    "device": { "status": "REGISTERED", ... },
    "deviceToken": "...",
    "nextStep": "test-connection",
    "testConnectionRequired": true
  }
}
```

#### Migration checklist for mobile developers

- [ ] Handle `status = "REGISTERED"` in `/device/me` response (startup check)
- [ ] Add Test Connection screen (UI + API call)
- [ ] Call `POST /device/test-connection` after each `POST /device/register`
- [ ] Handle `testConnectionRequired: true` in register response
- [ ] Handle `403 DEVICE_NOT_ACTIVE` on `/device/me`, heartbeat and parameters (payment APIs do not re-check `ACTIVE`)
- [ ] Handle new error codes: `DEVICE_NOT_REGISTERED`, `DEVICE_ALREADY_ACTIVE`, `PROCESSOR_UNREACHABLE`, `RATE_LIMITED`
- [ ] Show Retry button for `retryable: true` errors on test-connection
- [ ] Display `requestId` on error screens for support reference

---

### v1.x.x — Prior to 2026-06-02

Initial device registration flow. `POST /device/register` activated the device immediately upon successful TPN + pairing code validation (`status = "ACTIVE"`).

---
