---
title: "LanaPay POS developer documentation"
description: "Let a physical till take LANA and cash through one REST API. The POS rings up the sale, scans the customer’s Lana card, and gets a synchronous answer while the customer is still at the counter — exchange rates, limits and wallet safety all applied server-side."
canonical: "https://pos.lanapays.us/docs"
language: "en"
generated: "2026-08-20T21:44:15Z — generated from the site's content modules; do not edit by hand"
---

# LanaPay POS developer documentation

Let a physical till take LANA and cash through one REST API. The POS rings up the sale, scans the customer’s Lana card, and gets a synchronous answer while the customer is still at the counter — exchange rates, limits and wallet safety all applied server-side.

## Overview

LanaPay POS is a **server-to-server payment API for tills**. There is no hosted page and no shopper redirect — the counter is the UI. The whole model is two calls and one recovery query:

1. `POST /api/v1/sales` — ring up the order. You get a *sale* with `payment_options`: which of cash / LANA this sale can take right now, and up to what amount.
2. `POST /api/v1/sales/:id/pay` — send the tender (`cash` or `crypto`) plus the customer’s scanned card as **one opaque value**. The answer is synchronous and final: paid with exact amounts, or a typed refusal the cashier can act on.
3. `GET /api/v1/sales?order_id=…` — after any timeout, ask what actually happened before doing anything else ([recovery](https://pos.lanapays.us/docs#idempotency)).

- **Fiat-denominated** — every sale is priced in fiat (EUR, GBP, USD). The LANA amount is never an input.
- **LANA computed at pay time** — derived from the live exchange rate at the moment of payment, not when the sale was created. `payment_options.crypto` carries an indicative preview.
- **The scanned card is opaque** — your till passes the QR content through exactly as read. We interpret it server-side, so your integration never changes when new currencies arrive.
- **Base URL** — `https://pos.lanapays.us`. All examples below use it.

### The two rails

| Method | The customer scan | What happens |
| --- | --- | --- |
| `cash` | Lana card (WIF QR) **or** L-address — both work | The cash purchase is recorded and the customer’s cashback is minted. The charged amount may be [adjusted down](https://pos.lanapays.us/docs#pay-cash) to the customer’s remaining allowance. |
| `crypto` | Lana card (WIF QR) **only** — an address cannot sign | We build and sign the LanaCoin transaction in memory and settle it. The amount is [never adjusted](https://pos.lanapays.us/docs#pay-lana) — it either fits or the call is refused. |

### Currencies, limits & your reward

| What | Value |
| --- | --- |
| Currencies | EUR, GBP, USD — each business unit is bound to one currency |
| API rate limit | 120 requests/min per API key; `/pay` additionally capped at 30/min per key (each pay call can move money) |
| Global rate limit | 1,500 requests per 15 min per IP |
| Your reward | You **earn** 2% standard, 5% enrolled in Lana8Wonder, up to 20% in the Abundance model — paid **to you**, on top of the full invoice, on every successful payment. |
| What LanaPay deducts | Nothing. No setup fee, no monthly fee, no cut of your sales — and nothing at all on failed or expired sales. Your reward is funded by the investor financing the purchase, never by you. |
| Sale lifetime | Default 30 min; configurable per sale via `expires_in` (300–86400 s) or per merchant in Settings |

> **Note:** You don't pay to accept LANA. You earn.
>
> LanaPay takes nothing out of your invoice. You receive the full amount your customer paid — and a reward on top of it. It is the same reward every merchant on mobile.lanapays.us has always been paid.
>
> Standard **2%**, **5%** for merchants enrolled in Lana8Wonder, **up to 20%** for merchants in the Abundance model. Nothing is deducted from the sale itself.

Currency list and reward percentages above render live from `GET /api/v1/public/config` when the service is reachable.

## Authentication

Every `/api/v1` call except `/api/v1/public/*` authenticates with `Authorization: Bearer sk_live_…`. Keys are created in the dashboard (API keys) and the secret is shown **once** at creation — it is stored hashed and cannot be recovered later.

| Credential | Prefix | Used for | Where you get it |
| --- | --- | --- | --- |
| `secret key` | `sk_live_…` | The REST API (`Authorization: Bearer`) — server-side only, never in a browser or on the till’s display | Dashboard → API keys (shown **once** at creation) |
| `webhook secret` | `whsec_…` | Verifying the `LanaPay-Signature` header on deliveries | Dashboard → Webhooks, per endpoint |
| none | — | `GET /api/v1/public/config` — public discovery, open CORS | — |

A key is scoped to **one business unit** (one shop). The key alone identifies the shop — there is no merchant id in any request body, and a key can never read or pay another unit’s sales (`404 NOT_FOUND`, never a hint that the sale exists).

Every error uses the envelope `{ error: { code, message, param?, request_id } }`. Quote `request_id` when contacting support — it is generated per request and appears in our logs.

> **Note:** No CORS on /api/v1 — deliberately
>
> A secret key must never live in a browser. The API is for your POS backend or the till application itself over HTTPS; the one exception with open CORS is `/api/v1/public/*`, which carries no secrets.

## Sales & lifecycle

Create the sale the moment the order is rung up. It is a FIAT-priced record with a lifetime — payment comes as a second, separate call, so the customer can still choose the tender at the counter.

### POST /api/v1/sales — ring up the order

```bash
curl -X POST https://pos.lanapays.us/api/v1/sales \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "19.90",
    "currency": "EUR",
    "order_id": "TILL1-0042",
    "description": "2x espresso, 1x croissant"
  }'
# → 201 with payment_options: which of cash / LANA this sale can take right now.
```

| Field | Required | Rules |
| --- | --- | --- |
| `amount` | yes | String like `"19.90"` preferred (numbers accepted); positive, up to 2 decimals, max `999999.99` |
| `currency` | yes | Must match your unit’s currency |
| `order_id` | yes | `[A-Za-z0-9._-]{1,64}` — your till’s own reference; also the [idempotency key](https://pos.lanapays.us/docs#idempotency) |
| `description` | no | Up to 500 chars; shown in the dashboard and passed to the receipt description of the purchase record |
| `metadata` | no | Object — up to 10 keys, string values up to 200 chars; echoed back on the sale |
| `expires_in` | no | 300–86400 seconds; defaults to your merchant expiry setting (30 min unless changed) |

Returns `201` with the sale object — including `payment_options`, so the till knows *before offering tender* which rails this sale can take.

Illustrative example — ids and timestamps are fake. Timestamps are UTC.

```json
{
  "id": "sale_9f2kq7pxw4ln8vrt3a1b5c6d7e8f90ab",
  "object": "sale",
  "status": "pending",
  "amount": "19.90",
  "currency": "EUR",
  "order_id": "TILL1-0042",
  "description": "2x espresso, 1x croissant",
  "metadata": { "till": "1" },
  "payment_options": {
    "cash": { "available": true, "max_amount": "120.00", "reasons": [] },
    "crypto": {
      "available": true,
      "currencies": [
        { "code": "LANA", "amount_lanoshis": 31093750000, "rate": 0.064,
          "rate_updated_at": "2026-08-20 06:40:00" }
      ],
      "reasons": []
    }
  },
  "payment": null,
  "merchant_reward": { "figures": "indicative", "percent": 2, "amount": "0.40", "currency": "EUR" },
  "customer_cashback": { "figures": "indicative", "percent": 5, "amount": "1.00", "currency": "EUR" },
  "created_at": "2026-08-20 09:00:00",
  "expires_at": "2026-08-20 09:30:00",
  "paid_at": null,
  "cancelled_at": null
}
```

### The sale object

| Field | Meaning |
| --- | --- |
| `id` | Sale id, `sale_…` |
| `object` | Always `sale` |
| `status` | `pending` \| `paid` \| `expired` \| `cancelled` (below) |
| `amount` | Invoice amount as a fixed 2-decimal **string** — never a float |
| `currency` | Sale currency |
| `order_id` | Your reference; unique among open/paid sales of your unit |
| `description` | As provided |
| `metadata` | Your metadata object, echoed back |
| `payment_options` | Fresh while the sale is `pending`; `null` once terminal. See below |
| `payment` | `null` until paid, then the [payment result](https://pos.lanapays.us/docs#api-reference) |
| `merchant_reward` · `customer_cashback` | `merchant_reward` and `customer_cashback` — [indicative until paid, actual after](https://pos.lanapays.us/docs#reward-receipt) |
| `created_at` · `expires_at` · `paid_at` · `cancelled_at` | UTC timestamps; `paid_at` and `cancelled_at` are `null` until the matching transition |

### Statuses

| Status | Meaning |
| --- | --- |
| `pending` | Payable until `expires_at` |
| `paid` | Terminal. `payment` set; `sale.paid` webhook fired |
| `expired` | Terminal. Never paid before `expires_at`; no money moved |
| `cancelled` | Terminal. Cancelled via API or dashboard before payment |

While a pay call is in flight the sale is internally claimed; the API reports it as `pending` and a concurrent pay attempt gets `409 PAYMENT_IN_PROGRESS`. A claim that dies with the process is re-opened by a watchdog within 10 minutes.

### payment_options

A per-rail availability verdict computed from the merchant’s state, the fund capacity and the live rate. `cash.max_amount` is the largest cash amount this unit can currently take (the [clamp](https://pos.lanapays.us/docs#pay-cash) target); `crypto.currencies` carries the indicative LANA amount and rate. When a rail is off, `reasons` says why:

| Reason | Rail | Meaning |
| --- | --- | --- |
| `lana_only_unit` | cash | This unit accepts LANA only (self-service merchants) — cash is refused |
| `merchant_quota_exceeded` | cash | The monthly cash quota is exhausted; resets on the 1st |
| `split_happening` | cash | A network Split is in progress — cash payments are paused |
| `merchant_pending` | both | The merchant is awaiting approval |
| `merchant_suspended` | both | The merchant is suspended |
| `merchant_rejected` | both | The merchant application was rejected |
| `no_fund_capacity` | both | No investor budget is available right now (on crypto, also when the sale amount exceeds the largest budget) |
| `merchant_payout_not_configured` | crypto | The merchant has no payout details for LANA sales — configure them in the dashboard |
| `no_rate` | crypto | No live exchange rate for the sale currency yet |

These verdicts are heartbeat-fresh mirrors, not the final word — every guard is re-checked authoritatively at pay time. An over-promise here costs one refused attempt, never money; the point is the honest *"cash is off for this unit"* before the customer counts out notes.

### Cancelling

```bash
curl -X POST https://pos.lanapays.us/api/v1/sales/sale_9f2kq7.../cancel \
  -H "Authorization: Bearer sk_live_..."
# pending → cancelled (fires sale.cancelled); anything else → 409 NOT_CANCELLABLE
```

`POST /api/v1/sales/:id/cancel` ends a `pending` sale (fires `sale.cancelled`). Anything else answers `409 NOT_CANCELLABLE` — a paid sale can never be cancelled, and a sale mid-payment must finish or time out first.

### Expiry

A `pending` sale past `expires_at` flips to `expired` (fires `sale.expired`) — lazily on the next read and by a background sweep. An expired sale **frees its `order_id`**, so an abandoned order can simply be rung up again.

## Paying in cash

Cash is a first-class rail: `method: "cash"` records the purchase, counts it against the merchant’s cash quota, and mints the customer’s cashback. The customer’s card scan is **required** — it is how the cashback reaches them and how per-customer limits are enforced. Either card form works: the WIF QR or a plain L-address.

A first-time card (an empty, never-registered WIF) is registered automatically during the first cash payment — a new customer’s first purchase just works.

```bash
# Cash still needs the customer's card scan — it mints their cashback.
curl -X POST https://pos.lanapays.us/api/v1/sales/sale_9f2Kq7EXAMPLE/pay \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "cash",
    "customer": { "qr": "<RAW_SCANNED_QR>" }
  }'
# → payment.amount_charged is authoritative. When payment.amount_adjusted is
#   present the sale was clamped to the customer's remaining allowance — the
#   till MUST charge the adjusted amount and show it, never the original.
```

### What is checked, in order

The pay call applies the same guard chain the mobile till uses, server-side:

1. Merchant state — approval, [LANA-only units](https://pos.lanapays.us/docs#sale-lifecycle), monthly quota, [Split lock](https://pos.lanapays.us/docs#pay-cash)
2. Customer identity — fail-closed registry check ([frozen / unregistered cards are refused](https://pos.lanapays.us/docs#errors))
3. Per-customer window — the rolling cash allowance for this customer at your shop
4. **The clamp** — an over-limit amount is adjusted *down* to what is allowed, then the quota is re-checked with the adjusted amount

> **Warning:** amount_adjusted is not optional — the till MUST honor it
>
> When the response carries `payment.amount_adjusted`, the sale was charged at the **adjusted** amount, not the one you sent. The till **must display the adjusted amount and collect exactly that much** through LanaPay. `payment.amount_charged` is always the authoritative figure — print that on the receipt, never the original.

```json
"payment": {
  "method": "cash",
  "currency_code": null,
  "amount_charged": "50.00",          // ← charge THIS through LanaPay
  "amount_adjusted": {
    "original": "75.00",              // what the till sent
    "adjusted": "50.00",              // the customer's remaining allowance
    "currency": "EUR"
  },
  ...
}
```

The difference between the original invoice and the adjusted amount is settled outside LanaPay as ordinary cash — the clamp caps what enters the network, it does not shrink the customer’s bill.

### The per-customer window

Each customer has a rolling cash allowance per shop (limit × configured window days). A customer who exhausted it gets `403 CUSTOMER_WINDOW_EXCEEDED` with `spent`, `limit` and `days` in the error — the cashier’s answer is *"you can still pay in LANA"*. If the window service cannot be reached the check is skipped (fail-open): a lookup never stops a sale at the till.

### The monthly quota

Cash volume and transaction count are capped per unit per month. When the *remaining* volume cannot even fit the clamped amount, the call is refused with `403 MERCHANT_QUOTA_EXCEEDED` — a real block, since an amount cannot be adjusted down to nothing.

### Split lock

While a network Split is happening, cash purchases pause everywhere: `403 SPLIT_HAPPENING`. LANA payments continue — offer the crypto rail.

### LANA-only units

Self-service merchants are marked LANA-only: cash answers `409 LANA_ONLY_UNIT` and `payment_options.cash.available` is `false` from the start.

## Paying in LANA

LANA is `method: "crypto"`. It requires the customer’s **card** — the WIF QR. An L-address scan cannot sign a transaction and answers `422 WIF_REQUIRED_FOR_CRYPTO`.

### The scan travels opaque — and is never logged

Send `customer.qr` **exactly as the scanner read it**, one opaque string. Do not parse it, validate it, normalize it or log it. Server-side it is interpreted, used to sign the LanaCoin transaction **in memory**, and discarded — it never appears in a response, a log line or a database row, on either side of the wire. See [Security obligations](https://pos.lanapays.us/docs#security) for what this demands of your till.

```bash
# LANA requires the customer's Lana card (WIF QR). Pass the scan through
# EXACTLY as read — one opaque value. Never log it, never store it.
curl -X POST https://pos.lanapays.us/api/v1/sales/sale_9f2Kq7EXAMPLE/pay \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "method": "crypto",
    "customer": { "qr": "<RAW_SCANNED_QR>", "name": "Ana" }
  }'
# → 200 "status":"paid" with payment.tx_hash and the exact amounts.
# → 402 CUSTOMER_LANA_FAILED: the money did NOT move — do NOT hand over
#   the goods. Anything 5xx: recover by order_id before retrying.
```

One pay call does the whole settlement: we fetch the allocation preview, build and sign the customer’s LanaCoin transaction in memory, and submit it with the allocations echoed verbatim. If the exchange rate is republished mid-call we retry once internally; a rate moving twice answers `409 RATE_CHANGED` — just repeat the same pay call.

A LANA amount is **never adjusted**: it is baked into the signed transaction. If the amount exceeds the current investor capacity you get `403 FUND_CAPACITY_EXCEEDED` with the workable `max_amount` in the error — a block, never a silent reduction.

> **Warning:** 402 CUSTOMER_LANA_FAILED — the goods stay behind the counter
>
> This is the one answer the cashier must never misread: the customer’s LANA transfer **failed at broadcast. NO money moved.** The response carries `do_not_hand_over_goods: true`. Do not hand over the goods, do not print a paid receipt — the sale is back to `pending`; retry with a fresh scan or take another tender.
>
> Do not confuse it with a **timeout or 5xx**: those mean the outcome is *unknown* (the payment may well have succeeded) — never re-tender after one; run the [recovery procedure](https://pos.lanapays.us/docs#idempotency) instead. A `402` is the opposite: a *known* failure, money provably did not move.

```json
{
  "error": {
    "code": "CUSTOMER_LANA_FAILED",
    "message": "The LANA transfer failed — NO money moved. Do not hand over the goods; retry with a fresh scan.",
    "do_not_hand_over_goods": true,
    "request_id": "req_8c3b1a9e5f2d4c07"
  }
}
```

### Other LANA refusals

| Answer | Meaning |
| --- | --- |
| `402 INSUFFICIENT_CUSTOMER_BALANCE` | The card does not hold enough LANA for this sale — the customer needs another tender |
| `409 TOO_MANY_UTXOS` | The customer wallet is fragmented into too many small UTXOs to spend in one transaction — it must be consolidated first (outside the till) |
| `409 RATE_CHANGED` | The exchange rate changed twice during the call — retry the same pay call |
| `403 FUND_CAPACITY_EXCEEDED` | No investor budget covers this amount right now; `max_amount` in the error says what would fit |

## Reward & receipt

Every sale object carries two money-out blocks: `merchant_reward` — what **you earn on top of the invoice** — and `customer_cashback` — what the customer gets back in LANA. Both have `percent`, `amount` and `currency`.

### figures: indicative vs actual

Until the sale is paid, both blocks are computed from the current policy and marked `figures: "indicative"`. The pay response (and every later read) carries `figures: "actual"` — snapshotted from the real settlement at payment time. **Print only actual figures on the receipt.**

### What the receipt can say

The synchronous pay response has everything a receipt needs: the authoritative charged amount, the LANA details for a crypto sale, and the customer’s cashback. A till that prints the cashback line gives the customer a visible reason to scan their card at every visit:

Illustrative layout — render it however your receipt template likes; the values come from the pay response.

```text
CAFE EXAMPLE                TILL1-0042
2x espresso                     12.00
1x croissant                     7.90
-------------------------------------
TOTAL EUR                       19.90
Paid in LANA            310.9375 LANA
Rate                   0.064 EUR/LANA
tx b7e2c4a6…f0a2

Lana cashback earned:  1.00 EUR (in LANA)
Thank you — scan your Lana card
every visit to keep earning.
```

Print `payment.amount_charged` as the LanaPay amount (see [the clamp](https://pos.lanapays.us/docs#pay-cash)), and the `customer_cashback.amount` with its currency. The `merchant_reward` block is for your back office — it is what you earned on this sale, on top of it.

> **Note:** The reward is earned, never charged
>
> The percentages are money the merchant **earns on top of the full invoice**, funded by the investor financing the purchase. They are never a fee, never deducted from the sale, and the customer always pays exactly the invoice total.

## Pre-flight limits

Before the basket is even rung up, `GET /api/v1/limits?currency=EUR` answers *"how much can this till take right now, and on which rails?"* — the same computation the [payment_options](https://pos.lanapays.us/docs#sale-lifecycle) block runs per sale, without creating anything.

```bash
curl "https://pos.lanapays.us/api/v1/limits?currency=EUR" \
  -H "Authorization: Bearer sk_live_..."
```

Illustrative example:

```json
{
  "currency": "EUR",
  "max_amount": 120,
  "source": "merchant",
  "merchant_limit": 120,
  "fund_limit": 480.5,
  "default_limit": null,
  "cash_available": true,
  "crypto_available": true,
  "reasons": []
}
```

### Response fields

| Field | Meaning |
| --- | --- |
| `currency` | The currency the limits are computed in (defaults to your unit’s currency) |
| `max_amount` | The effective per-transaction cash maximum — a **number** here — after folding the merchant limit, fund capacity, default limit and the remaining monthly quota; `null` = no limit configured |
| `source` | Which component set the maximum: `merchant` \| `fund` \| `default` \| `none` |
| `merchant_limit` · `fund_limit` · `default_limit` | The individual components before folding, for display (`merchant_limit`, `fund_limit`, `default_limit`) |
| `cash_available` · `crypto_available` | Rail verdicts, same semantics as `payment_options` |
| `reasons` | Union of both rails’ reasons (see [the reason table](https://pos.lanapays.us/docs#sale-lifecycle)) |

Call it when the till boots and cache it for the shift, or before large baskets. It cannot see the *customer* (no card is scanned yet), so the [per-customer window](https://pos.lanapays.us/docs#pay-cash) still applies at pay time — treat `max_amount` as the ceiling, not a promise.

## API reference

Server-to-server REST under `https://pos.lanapays.us/api/v1`. Authenticate with `Authorization: Bearer sk_live_…`. There is deliberately **no CORS** on `/api/v1` — a secret key must never live in a browser. Every error uses the envelope `{ error: { code, message, param?, request_id } }`. Rate limits: 120 requests/min per key, `/pay` additionally 30/min.

### Create and pay — the full round trip

**curl**

```bash
curl -X POST https://pos.lanapays.us/api/v1/sales \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "19.90",
    "currency": "EUR",
    "order_id": "TILL1-0042",
    "description": "2x espresso, 1x croissant"
  }'
# → 201 with payment_options: which of cash / LANA this sale can take right now.
```

**C# / .NET**

```csharp
using var http = new HttpClient { BaseAddress = new Uri("https://pos.lanapays.us") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("LANAPAY_POS_SECRET_KEY"));

// 1. Ring up the order (idempotent on your own order_id):
var createRes = await http.PostAsJsonAsync("/api/v1/sales", new {
    amount = "19.90", currency = "EUR",
    order_id = order.Id, description = order.Summary,
});
var sale = await createRes.Content.ReadFromJsonAsync<JsonElement>();
var saleId = sale.GetProperty("id").GetString();

// 2. Scan the customer's card and pay. scannedQr is a PRIVATE KEY —
//    keep it in memory only; never log it or persist it.
var payRes = await http.PostAsJsonAsync($"/api/v1/sales/{saleId}/pay", new {
    method = order.Tender, // "cash" | "crypto"
    customer = new { qr = scannedQr },
});
var paid = await payRes.Content.ReadFromJsonAsync<JsonElement>();

if (payRes.StatusCode == HttpStatusCode.OK) {
    // amount_adjusted set? The charge was clamped — collect exactly this much.
    var charged = paid.GetProperty("payment").GetProperty("amount_charged");
    PrintReceipt(charged, paid.GetProperty("merchant_reward"));
} else if ((int)payRes.StatusCode == 402) {
    // Money did NOT move. Do not hand over the goods.
    ShowTillError(paid.GetProperty("error").GetProperty("code").GetString());
} else if ((int)payRes.StatusCode >= 500) {
    // Unknown outcome — recover with GET /api/v1/sales?order_id=... first.
}
```

**Node**

```js
const BASE = 'https://pos.lanapays.us';
const KEY = process.env.LANAPAY_POS_SECRET_KEY; // sk_live_...

async function api(path, body) {
  const res = await fetch(BASE + path, {
    method: body ? 'POST' : 'GET',
    headers: {
      'Authorization': `Bearer ${KEY}`,
      'Content-Type': 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  return { status: res.status, json };
}

// 1. Ring up the order (idempotent on your own order_id):
const { json: created } = await api('/api/v1/sales', {
  amount: '19.90', currency: 'EUR',
  order_id: order.id, description: order.summary,
});

// 2. Scan the customer's card and pay. scannedQr stays in memory only —
//    it is a private key. Never log it, never write it anywhere.
const { status, json: paid } = await api(`/api/v1/sales/${created.id}/pay`, {
  method: order.tender,                 // 'cash' | 'crypto'
  customer: { qr: scannedQr },
});

if (status === 200 && paid.status === 'paid') {
  // amount_adjusted set? The charge was clamped — collect exactly this much.
  const charge = paid.payment.amount_charged;
  printReceipt(charge, paid.merchant_reward, paid.customer_cashback);
} else if (status === 402) {
  // Money did NOT move. Do not hand over the goods.
  showTillError(paid.error.code);
} else if (status >= 500 || status === 429) {
  // Unknown outcome — recover by order_id before doing anything else.
  const { json: check } = await api(`/api/v1/sales?order_id=${order.id}`);
  // check.data[0].status === 'paid' → fulfil; 'pending' → repeat the SAME pay.
}
```

### POST /api/v1/sales/:id/pay — the money call

| Field | Required | Rules |
| --- | --- | --- |
| `method` | yes | `cash` or `crypto` |
| `customer.qr` | yes | The scanned customer card, **verbatim** — max 200 chars. Opaque: never parse or log it ([security](https://pos.lanapays.us/docs#security)) |
| `customer.name` | no | Optional display name for the purchase record, up to 120 chars |

Synchronous. `200` with the paid sale — the answer IS the outcome; there is nothing to poll. Paying an already-paid sale answers `200` with `replayed: true` (a double-tap is not an error). Refusals use the [error table](https://pos.lanapays.us/docs#errors).

Once paid, `payment` is populated:

Illustrative example — ids, hashes and timestamps are fake.

```json
{
  "id": "sale_9f2kq7pxw4ln8vrt3a1b5c6d7e8f90ab",
  "object": "sale",
  "status": "paid",
  "amount": "19.90",
  "currency": "EUR",
  "order_id": "TILL1-0042",
  "payment_options": null,
  "payment": {
    "method": "crypto",
    "currency_code": "LANA",
    "amount_charged": "19.90",
    "amount_adjusted": null,
    "tx_id": "9c41d2ab-7e60-4b1f-9f2a-3c5d8e0f1a2b",
    "tx_hash": "b7e2c4a6d8f0a1b3c5d7e9fb0d2f4a6c8e0a2c4e6f8a0b2c4d6e8f0a2b4c6d8e",
    "lana_lanoshis": 31093750000,
    "exchange_rate": 0.064,
    "customer_wallet": "LVWkm3rH8pQx2YbJ9sTn4cFd6gAe7uZq1w",
    "paid_at": "2026-08-20 09:02:41"
  },
  "merchant_reward": { "figures": "actual", "percent": 2, "amount": "0.40", "currency": "EUR" },
  "customer_cashback": { "figures": "actual", "percent": 5, "amount": "1.00", "currency": "EUR" },
  "created_at": "2026-08-20 09:00:00",
  "expires_at": "2026-08-20 09:30:00",
  "paid_at": "2026-08-20 09:02:41",
  "cancelled_at": null
}
```

| Field | Meaning |
| --- | --- |
| `method` | `cash` \| `crypto` |
| `currency_code` | `LANA` on the crypto rail, `null` for cash |
| `amount_charged` | The authoritative charged amount, 2-decimal string — print this |
| `amount_adjusted` | `null` normally; `{original, adjusted, currency}` when the cash amount was [clamped](https://pos.lanapays.us/docs#pay-cash) |
| `tx_id` | Settlement transaction id |
| `tx_hash` | LanaCoin on-chain transaction hash (crypto rail) |
| `lana_lanoshis` | LANA that settled, in lanoshis |
| `exchange_rate` | Fiat per LANA at pay time |
| `customer_wallet` | The paying wallet address |
| `paid_at` | UTC timestamp |

### GET /api/v1/sales/:id — retrieve

Returns the sale, with fresh `payment_options` while it is `pending`. `404 NOT_FOUND` for unknown ids *and* other units’ sales — the API is not an existence oracle.

### GET /api/v1/sales — list

```bash
curl "https://pos.lanapays.us/api/v1/sales?status=paid&limit=25" \
  -H "Authorization: Bearer sk_live_..."

# → { "object": "list", "data": [ ...sales, newest first... ], "has_more": true }
# filters: status, order_id (exact); page with ?starting_after=<last id of the previous page>
```

Filters: `status`, `order_id` (exact); `limit` up to 100 (default 25); cursor pagination with `starting_after` (the last id of the previous page). Newest first. `?order_id=…` is the [recovery query](https://pos.lanapays.us/docs#idempotency).

### POST /api/v1/sales/:id/cancel — cancel

Ends a `pending` sale (fires `sale.cancelled`); otherwise `409 NOT_CANCELLABLE`.

### GET /api/v1/limits — pre-flight

See [Pre-flight limits](https://pos.lanapays.us/docs#preflight).

### GET /api/v1/public/config — public discovery

No auth, open CORS — safe to call from any browser. Currencies, live rates and the reward percentages.

Illustrative example — rates change continuously; call the endpoint for live values.

```json
{
  "currencies": ["EUR", "GBP", "USD"],
  "reward_percent_base": 2,
  "reward_percent_lana8wonder": 5,
  "reward_percent_max": 20,
  "exchange_rates": { "EUR": 0.064, "GBP": 0.064, "USD": 0.064 },
  "rates_updated_at": "2026-08-20 06:40:00",
  "brain_reachable": true
}
```

`reward_percent_base` and `reward_percent_lana8wonder` are what the merchant **earns** on top of the invoice (`reward_percent_max` is the Abundance-model ceiling). The older `fee_percent_base` / `fee_percent_lana8wonder` fields are deprecated aliases of the same numbers — read the `reward_percent_*` ones.

## Webhooks

Add an endpoint in the dashboard → Webhooks (`https` URLs on public hosts only). Each endpoint gets its own `whsec_…` secret. We POST JSON with a 10 s timeout; any `2xx` counts as delivered.

> **Note:** The till relies on the synchronous response — webhooks are the back office
>
> Unlike an e-commerce checkout, the pay call *answers* with the outcome, so the till never waits for a webhook. Use webhooks to feed the ERP, accounting, or a back-office dashboard — asynchronously, after the counter has already moved on.

| Event | Fires when |
| --- | --- |
| `sale.paid` | A sale was paid — cash recorded or LANA settled on-chain |
| `sale.expired` | A pending sale ran out of time |
| `sale.cancelled` | A pending sale was cancelled via API or dashboard |
| `test.ping` | You pressed *Send test* in the dashboard (payload carries `"test": true`) |

Illustrative payload:

```json
{
  "id": "evt_2b91c4d8f7a35e604c1d9e8b0a7f6352",
  "object": "event",
  "type": "sale.paid",
  "created_at": "2026-08-20T09:02:41.000Z",
  "data": {
    "sale": { … full sale object, see API reference … }
  }
}
```

### Signature verification

Every delivery carries these headers (`LanaPay-Delivery-Id` equals the event `id` — your dedupe key):

```http
LanaPay-Signature: t=1755594312,v1=5f8a1c…d904
LanaPay-Event: sale.paid
LanaPay-Delivery-Id: evt_2b91c4d8f7a35e604c1d9e8b0a7f6352
```

Compute ``hex(HMAC-SHA256(whsec_secret, `${t}.${rawBody}`))`` and compare against the `v1` values — accept if **any** `v1` matches (secret rotation can briefly produce two). Reject when `|now − t| > 300 s`.

> **Warning:** Verify against the RAW request bytes
>
> The signature covers the exact bytes we sent. Any re-serialization — `JSON.parse` → `JSON.stringify`, body-parser middleware, framework "magic" — silently breaks it. Use `express.raw({ type: 'application/json' })` on the webhook route in Node, `file_get_contents('php://input')` in PHP, and only parse *after* the signature checks out.

**Node**

```js
const crypto = require('crypto');

function verifyLanaPay(rawBody, sigHeader, secret, toleranceSec = 300) {
  let t = null; const sigs = [];
  for (const part of sigHeader.split(',')) {
    const [k, v] = part.trim().split('=');
    if (k === 't') t = Number(v);
    if (k === 'v1') sigs.push(v);
  }
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret)
    .update(`${t}.${rawBody}`).digest('hex');
  return sigs.some(s => s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s, 'hex'), Buffer.from(expected, 'hex')));
}

// IMPORTANT: rawBody must be the EXACT bytes you received — use
// express.raw({ type: 'application/json' }) on the webhook route, never
// JSON.stringify(req.body) (re-serialization breaks the signature).
app.post('/lanapay-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyLanaPay(req.body.toString('utf8'), req.get('LanaPay-Signature') || '', process.env.LANAPAY_WEBHOOK_SECRET)) {
    return res.status(400).end();
  }
  const event = JSON.parse(req.body.toString('utf8'));
  if (event.type === 'sale.paid') {
    // The till already got the synchronous answer — webhooks are for the
    // back-office/ERP. Dedupe on event.id, check amount + currency:
    const s = event.data.sale;
    // if (s.amount_fiat === expectedAmount && s.currency === expectedCurrency) recordInErp(s.order_id);
  }
  res.status(200).end();
});
```

**PHP**

```php
<?php
function lanapay_verify_signature($rawBody, $sigHeader, $secret, $tolerance = 300) {
  $t = null; $sigs = [];
  foreach (explode(',', $sigHeader) as $part) {
    $kv = explode('=', trim($part), 2);
    if (count($kv) !== 2) continue;
    if ($kv[0] === 't') $t = (int)$kv[1];
    if ($kv[0] === 'v1') $sigs[] = $kv[1];
  }
  if (!$t || abs(time() - $t) > $tolerance) return false;
  $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
  foreach ($sigs as $s) { if (hash_equals($expected, $s)) return true; }
  return false;
}

$rawBody = file_get_contents('php://input'); // BEFORE any parsing
$sig = $_SERVER['HTTP_LANAPAY_SIGNATURE'] ?? '';
if (!lanapay_verify_signature($rawBody, $sig, $webhookSecret)) {
  http_response_code(400); exit;
}
$event = json_decode($rawBody, true);
if ($event['type'] === 'sale.paid') {
  $sale = $event['data']['sale'];
  // The till already got the synchronous answer — record for the back office.
}
http_response_code(200);
```

### Retries

| Attempt | When |
| --- | --- |
| 1 | Inline, at event time |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +30 minutes |
| 5 | +2 hours |
| 6 | +6 hours |
| 7 | +24 hours |
| — | Dead (~32 h total). Dead deliveries stay listed in the dashboard with a *Redeliver* button |

> **Note:** Write an idempotent handler
>
> Delivery is **at-least-once** and can arrive out of order after retries. Dedupe on the event `id` (`evt_…`): record processed ids and return `200` immediately for repeats. Respond fast — do slow work (ERP sync, accounting) after acknowledging.

### Testing an endpoint

Dashboard → Webhooks → *Send test* fires a signed `test.ping` (with `"test": true` and a synthetic sale) at your endpoint and shows the HTTP status and latency. You can also *Redeliver* any past delivery from the Deliveries list.

## Idempotency & recovery

`order_id` is the idempotency key of the whole system: at most one live-or-paid sale exists per (unit, order), and the settlement layer dedupes successful purchases on the same key. That is what makes every recovery below safe.

### Creating twice

| You POST… | Result |
| --- | --- |
| Same `order_id`, same amount + currency, while a sale is open | **200** replay with `replayed: true` — the *same* sale comes back; a till retry resumes it, never mints a competitor |
| Same `order_id`, *different* amount or currency, while a sale is open | **409** `ORDER_ID_CONFLICT` — `error.sale_id` points at the existing sale (cancel it first if the price really changed) |
| An `order_id` that is already `paid` | **409** `ORDER_ALREADY_PAID` — `error.sale_id` set; do not charge the customer twice |

Paying twice is equally safe: a pay call for an already-`paid` sale answers `200` with `replayed: true` and the original payment — a cashier’s double-tap is never a double charge.

### The recovery procedure (timeouts, 5xx, crashes)

A timeout or a `5xx` means the outcome is **unknown** — the payment may have gone through. The one forbidden move is re-tendering blindly. Instead:

1. **Stop.** Do not create a new sale, do not start a new tender, do not ask the customer to pay again.
2. Ask what happened: `GET /api/v1/sales?order_id=<your order>`.
3. `status: "paid"` → the payment went through. Hand over the goods and print the receipt from the returned `payment` block.
4. `status: "pending"` → nothing moved. Repeat the **same** `/pay` call — if the earlier attempt did land upstream, the settlement dedupe converts your retry into the original result (`replayed: true`).
5. No sale returned → creation itself never landed. Re-POST `/api/v1/sales` and continue normally.

```bash
# Timeout mid-payment? NEVER re-tender blindly — ask for the order first:
curl "https://pos.lanapays.us/api/v1/sales?order_id=TILL1-0042" \
  -H "Authorization: Bearer sk_live_..."
# paid    → the payment went through: hand over the goods, print the receipt
# pending → repeat the SAME /pay call — the sale is still open
# absent  → the sale was never created: re-POST /api/v1/sales
```

A `409 PAYMENT_IN_PROGRESS` means another attempt is mid-flight (or a crashed one holds the claim). Wait and re-poll — a stale claim is re-opened by a watchdog within 10 minutes, after which the same recovery applies.

## Errors

The full contract for the cashier screen. Everything is machine-typed: an HTTP status, a stable `code`, and — where the cashier can act — extra fields.

```json
{
  "error": {
    "code": "CUSTOMER_WINDOW_EXCEEDED",
    "message": "Customer reached the 120.00 EUR cash limit for the last 1 day(s) — already spent 95.50. Sell for LANA instead.",
    "spent": 95.5,
    "limit": 120,
    "days": 1,
    "currency": "EUR",
    "request_id": "req_5f2a9c1b3e7d4a08"
  }
}
```

Validation errors name the offending field in `param`. Order collisions carry `error.sale_id`. `CUSTOMER_WINDOW_EXCEEDED` carries `spent`, `limit`, `days`, `currency`; `FUND_CAPACITY_EXCEEDED` carries `max_amount`; `CUSTOMER_LANA_FAILED` carries `do_not_hand_over_goods: true`.

The `message` is developer-facing English — the till should translate the `code` into its own UI language for the cashier. In rare cases a LANA payment can surface a typed refusal from the settlement layer that is not in this table (an unshaped upstream 4xx becomes `UPSTREAM_REJECTED`) — treat any unknown code like `UPSTREAM_UNAVAILABLE`: nothing was charged, run the recovery procedure.

| Code | HTTP | Rail | What the cashier screen should show |
| --- | --- | --- | --- |
| `INVALID_API_KEY` | 401 | — | Configuration error — call the integrator. The key is missing, unknown or revoked. |
| `INVALID_AMOUNT` | 400 | — | Till bug: the request amount is malformed. Fix the request; nothing to tell the customer. |
| `INVALID_CURRENCY` | 400 | — | Till bug: currency does not match the shop’s configured currency. |
| `INVALID_ORDER_ID` | 400 | — | Till bug: order reference violates `[A-Za-z0-9._-]{1,64}`. |
| `INVALID_METADATA` | 400 | — | Till bug: metadata shape violates the limits (10 keys, 200-char string values). |
| `INVALID_EXPIRES_IN` | 400 | — | Till bug: expiry outside 300–86400 s. |
| `INVALID_METHOD` | 400 | both | Till bug: tender must be `cash` or `crypto`. |
| `CUSTOMER_QR_REQUIRED` | 400 | both | "Please scan the customer’s card." Both rails need the scan. |
| `UNSUPPORTED_QR_FORMAT` | 422 | both | "This is not a Lana card — rescan." The message names common wrong cards (Nostr keys, bare hex). |
| `WIF_REQUIRED_FOR_CRYPTO` | 422 | LANA | "For LANA payment, scan the card itself, not the address." Offer cash if only the address is at hand. |
| `CUSTOMER_IDENTITY_UNRESOLVED` | 422 | both | "Scan the customer card (QR), not the wallet address." The registry knows the wallet but holds no identity for it. |
| `MERCHANT_PENDING` | 403 | both | Shop blocked: awaiting approval. No payments until approved. |
| `MERCHANT_SUSPENDED` | 403 | both | Shop blocked: suspended. Contact LanaPays support. |
| `MERCHANT_REJECTED` | 403 | both | Shop blocked: application rejected. |
| `LANA_ONLY_UNIT` | 409 | cash | This shop takes LANA only — offer the LANA tender. |
| `MERCHANT_QUOTA_EXCEEDED` | 403 | cash | Monthly cash limit reached — offer LANA instead. Resets on the 1st. |
| `SPLIT_HAPPENING` | 403 | cash | Cash briefly paused network-wide (Split) — offer LANA instead. |
| `MERCHANT_PAYOUT_NOT_CONFIGURED` | 409 | LANA | Shop setup incomplete: payout details missing for LANA sales — finish setup in the dashboard. |
| `WALLET_FROZEN` | 403 | both | Hard stop: this card is frozen and cannot pay. Do not retry. |
| `WALLET_NOT_REGISTERED` | 403 | both | Hard stop: the card holds LANA but is not registered. The customer must resolve it with the registry. |
| `WALLET_CHECK_FAILED` | 403 | both | The registry could not be reached — try again in a moment (fail-closed: never assume the card is fine). |
| `SELF_PURCHASE` | 409 | both | A merchant cannot buy from their own shop. |
| `CUSTOMER_WINDOW_EXCEEDED` | 403 | cash | "Cash limit for this customer reached (`spent` of `limit` in `days` day(s)) — LANA still works." Offer the crypto rail. |
| `FUND_CAPACITY_EXCEEDED` | 403 | LANA | LANA amount too large right now — `max_amount` says what would fit. Never split silently; ask the customer. |
| `INSUFFICIENT_CUSTOMER_BALANCE` | 402 | LANA | "The card does not hold enough LANA." Offer another tender. |
| `ORDER_ID_CONFLICT` | 409 | — | Same order reference, different amount — cancel the old sale first (`error.sale_id`). |
| `ORDER_ALREADY_PAID` | 409 | — | This order is already paid — do NOT charge again; fetch it and print the receipt. |
| `SALE_NOT_PAYABLE` | 409 | both | The sale expired or was cancelled — ring it up again. |
| `PAYMENT_IN_PROGRESS` | 409 | both | Another attempt is mid-flight — wait a moment and check the sale, do not start a new tender. |
| `NOT_CANCELLABLE` | 409 | — | Too late to cancel — the sale is paid or already closed. |
| `TOO_MANY_UTXOS` | 409 | LANA | The customer wallet is too fragmented to spend in one go — it must be consolidated (outside the till). Offer another tender today. |
| `RATE_CHANGED` | 409 | LANA | The exchange rate moved during payment — simply retry the same pay call. |
| `CUSTOMER_LANA_FAILED` | 402 | LANA | **NO money moved. Do not hand over the goods.** Retry with a fresh scan or take another tender. See [Paying in LANA](https://pos.lanapays.us/docs#pay-lana). |
| `NOT_FOUND` | 404 | — | Unknown sale id (or another unit’s sale). Recover by `order_id` if this follows a timeout. |
| `UPSTREAM_UNAVAILABLE` | 502 | both | The payment service is unreachable — the sale was NOT charged. Run the [recovery procedure](https://pos.lanapays.us/docs#idempotency), then retry the same pay call. |

Rate limiting (`429`) uses the standard `RateLimit-*` headers instead of an error code: 120/min per key, 30/min on `/pay`, 1,500 per 15 min per IP. Back off and retry; a `429` on `/pay` never reached the money path.

## Security obligations

The customer’s scanned QR **is a private key**. Whoever holds it holds the wallet. The till is the custodian for the few hundred milliseconds between scanner and HTTPS socket — these obligations are on every POS vendor integrating this API.

> **Warning:** Scanner buffer → HTTPS request body. Nowhere else. Ever.
>
> The scan goes from the scanner buffer into the `customer.qr` field of one HTTPS request, and that is its entire life inside your system. It must never touch the receipt journal, an ESC/POS printer log, the ERP database, an analytics event, a crash dump, a debug log, or a screen.

### The vendor checklist

- **Never log request bodies** on the pay path — not at debug level, not in an HTTP-client interceptor, not in an APM trace. Redact `customer.qr` structurally if your framework logs by default.
- **Never persist the scan** — no retry queue on disk, no "offline mode" buffer, no audit table. If your till queues a retry, the queue must live in memory only and die with the process.
- **Never display it** — not on the cashier screen, not in a diagnostics view. The scan is not human-meaningful; there is nothing to show.
- **TLS only, direct** — HTTPS to the API with certificate verification on; no plaintext hop, no logging middlebox, no "temporary" proxy that stores bodies.
- **Crash safety** — exclude the variable holding the scan from crash reporters and core dumps where your platform allows it; at minimum, keep its scope to the one request function.
- **Keep the secret key server-side** — `sk_live_…` belongs in your backend or the till’s protected config, never in anything a customer-facing device exposes.

Our side of the same bargain: the value is interpreted server-side, used to sign **in memory**, and discarded. It is never stored, never logged (a redaction test suite pins this for every error path), and never forwarded — the settlement layer refuses raw keys and accepts only the signed transaction.

Rotate an API key by creating a new one in the dashboard and revoking the old — keys are independent, so a rollout can overlap.

## Going live

The go-live checklist — each item exists because skipping it eventually costs real money or a real wallet:

- **Prove the scan never leaks** — Grep your logs, receipt journal, DB and crash reports after a test payment — the scanned value must appear nowhere. See Security obligations.
- **Honor amount_adjusted** — Force a clamped cash sale in staging logic and verify the till displays and collects the adjusted amount, and prints amount_charged.
- **Treat 402 as a hard stop** — CUSTOMER_LANA_FAILED and INSUFFICIENT_CUSTOMER_BALANCE mean no money moved — the till must block the goods hand-over, visibly.
- **Implement recovery-by-order_id** — Kill the network mid-pay in a test and verify the till follows the numbered recovery procedure instead of re-tendering.
- **Verify webhook signatures (if you use them)** — Raw-body HMAC check, dedupe on event id, fast 200. The till itself never waits on a webhook.
- **Finish with the smallest real payment** — Create a small sale, pay it with a real customer card (not the owner’s — SELF_PURCHASE blocks it), check the receipt figures, then watch the sale.paid webhook arrive.

> **Warning:** There is no sandbox
>
> Plainly: no test mode, no test cards, no sandbox environment. Everything above the pay call is safe to exercise freely — creating, reading and cancelling sales moves no money and costs nothing. The pay call is always real; test it last, small, with a real card.

Enroll your shop and create keys in the [dashboard](https://pos.lanapays.us/login) — enrollment is self-serve and takes minutes.

---

LanaPay POS · pos.lanapays.us · the merchant earns 2% / 5% / up to 20% on top of every invoice
