---
title: "LanaPay developer documentation"
description: "Accept LANA on your website with a hosted checkout — a payment link, one script tag, a signed redirect, or a full REST API. You price in fiat; the shopper pays LANA at the live rate."
canonical: "https://pay.lanapays.us/docs"
language: "en"
generated: "2026-09-10T09:47:09Z — generated from the site's content modules; do not edit by hand"
---

# LanaPay developer documentation

Accept LANA on your website with a hosted checkout — a payment link, one script tag, a signed redirect, or a full REST API. You price in fiat; the shopper pays LANA at the live rate.

## Overview

LanaPay is a **hosted LANA checkout**. You never touch coins, wallets, or exchange rates: you say *"19.90 EUR for order ORDER-1042"*, we render a pay page, the shopper signs a LanaCoin transaction in the browser, and you get a [webhook](https://pay.lanapays.us/docs#webhooks) (or poll the [API](https://pay.lanapays.us/docs#api-reference)) when the money is on-chain.

- **Fiat-denominated** — every session is priced in fiat (EUR, GBP and USD).
- **LANA computed at pay time** — the LANA amount is derived from the live exchange rate at the moment the shopper pays, not when the session was created.
- **Base URL** — `https://pay.lanapays.us`. All examples below use it.

### Authentication model

| Credential | Prefix | Used for | Where you get it |
| --- | --- | --- | --- |
| `secret key` | `sk_live_…` | REST API (`Authorization: Bearer`) — server-side only, never in a browser | Dashboard → API keys (shown **once** at creation) |
| `signing secret` | `rsig_…` | HMAC for [signed checkout URLs](https://pay.lanapays.us/docs#hosted-checkout) | Dashboard → Settings |
| `webhook secret` | `whsec_…` | Verifying the `LanaPay-Signature` header | Dashboard → Webhooks, per endpoint |
| none | — | Hosted pay pages, payment links, `GET /api/v1/public/config` | — |

### Currencies, limits & your reward

| What | Value |
| --- | --- |
| Currencies | EUR, GBP and USD — each merchant account is bound to one currency |
| API rate limit | 120 requests/min per API key |
| 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 sessions. Your reward is funded by the investor financing the purchase, never by you. |
| Session lifetime | Default 60 min; configurable per session 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, in fiat or in LANA, your choice. 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 gateway is reachable.

## Choose your integration

Four ways in, one hosted checkout at the end of each. Pick the row that matches what you have:

| You have… | Use | Effort |
| --- | --- | --- |
| No website backend at all | [Payment links](https://pay.lanapays.us/docs#payment-links) | Create in the dashboard, share a URL or QR — zero code |
| A site you can paste HTML into | [lanapay.js button](https://pay.lanapays.us/docs#lanapay-js) | One script tag + one div |
| Your own checkout flow | [Hosted checkout (redirect)](https://pay.lanapays.us/docs#hosted-checkout) | Your server builds a (signed) /checkout URL |
| Full control, server-to-server | [REST API](https://pay.lanapays.us/docs#api-reference) | Create sessions from your backend, consume webhooks |
| A WordPress site — with or without WooCommerce | [LanaPay for WordPress](https://pay.lanapays.us/docs#wordpress) | Install the plugin, paste three settings — no coding |

> **Note:** Whatever the entry point, the exit is the same
>
> Every method produces a *checkout session*. Fulfillment always follows the same rule: verify `status = paid` plus amount and currency **server-side** before shipping anything — see [Going live](https://pay.lanapays.us/docs#going-live).

## Hosted checkout (redirect)

The simplest server-less integration: your "Pay with LanaPay" button is a plain link to `GET /checkout`. On success the shopper is 302-redirected to the hosted pay page (`/pay/<token>`); on any validation failure to `/checkout-error?code=…` ([codes](https://pay.lanapays.us/docs#errors)).

**URL**

```text
https://pay.lanapays.us/checkout?merchant_id=mch_YOUR_MERCHANT_ID&amount=19.90&currency=EUR&order_id=ORDER-1042&description=Blue%20hoodie&return_url=https%3A%2F%2Fshop.example%2Fthanks&cancel_url=https%3A%2F%2Fshop.example%2Fcart
```

**HTML**

```html
<a href="https://pay.lanapays.us/checkout?merchant_id=mch_YOUR_MERCHANT_ID
   &amount=19.90&currency=EUR&order_id=ORDER-1042
   &return_url=https%3A%2F%2Fshop.example%2Fthanks">
  Pay with LanaPay
</a>
```

### Query parameters

| Param | Required | Rules |
| --- | --- | --- |
| `merchant_id` | yes | Your `mch_…` id |
| `amount` | yes | Fiat amount matching `^\d{1,6}(\.\d{1,2})?$` — e.g. `19.90` |
| `currency` | yes | Must match your merchant account currency |
| `order_id` | yes | `[A-Za-z0-9._-]{1,64}` — your reference; also the idempotency key |
| `description` | no | Shown to the shopper on the pay page |
| `return_url` | yes | `https` only (`http` allowed for localhost during development) |
| `cancel_url` | no | Where "cancel" takes the shopper |
| `ts` | signed mode | Unix seconds when the URL was generated |
| `exp` | signed mode, optional | Expiry, unix seconds. Default `ts+900`, max `ts+86400` |
| `sig` | signed mode | Hex HMAC-SHA256 over the canonical string (below) |

### Signed URLs

A plain `/checkout` URL carries the price in a query string the shopper can edit. Signing the URL with your `rsig_…` secret (dashboard → Settings) makes the parameters tamper-proof, and the *Require signed redirects* setting lets you reject unsigned URLs outright (`SIGNED_URLS_REQUIRED`). The scheme:

1. Take **all** params you will send except `sig` — including `ts` (and `exp` if you use it).
2. Sort the keys **bytewise** (plain ASCII sort).
3. Build the canonical string: `rfc3986(key)=rfc3986(value)` joined by `&`.
4. Prefix it: `"lanapay-checkout-v1\n"` + canonical string.
5. `sig` = lowercase-hex HMAC-SHA256 of that message with your signing secret. Clock skew tolerance is 300 s.

**worked example**

```text
# 1) the params you are about to send — sig excluded, ts included
amount      = 19.90
currency    = EUR
merchant_id = mch_YOUR_MERCHANT_ID
order_id    = ORDER-1042
return_url  = https://shop.example/thanks
ts          = 1755600000

# 2) canonical string: keys sorted bytewise, rfc3986(k)=rfc3986(v), joined by '&'
amount=19.90&currency=EUR&merchant_id=mch_YOUR_MERCHANT_ID&order_id=ORDER-1042&return_url=https%3A%2F%2Fshop.example%2Fthanks&ts=1755600000

# 3) message to sign = "lanapay-checkout-v1\n" + canonical string
lanapay-checkout-v1
amount=19.90&currency=EUR&merchant_id=mch_YOUR_MERCHANT_ID&order_id=ORDER-1042&return_url=https%3A%2F%2Fshop.example%2Fthanks&ts=1755600000

# 4) sig = lowercase hex HMAC-SHA256(rsig_… signing secret, message)
sig = 3f1a9c07b2…e0   # 64 hex chars

# 5) final URL: the same params (any order) + &sig=<sig>
https://pay.lanapays.us/checkout?merchant_id=mch_YOUR_MERCHANT_ID&amount=19.90&…&ts=1755600000&sig=3f1a9c07b2…e0
```

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

function rfc3986(s) {
  return encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}

function signedCheckoutUrl(params, signingSecret) {
  params.ts = String(Math.floor(Date.now() / 1000));
  const canonical = Object.keys(params).sort()
    .map(k => rfc3986(k) + '=' + rfc3986(params[k])).join('&');
  const sig = crypto.createHmac('sha256', signingSecret)
    .update('lanapay-checkout-v1\n' + canonical).digest('hex');
  const qs = Object.entries(params)
    .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
  return 'https://pay.lanapays.us/checkout?' + qs + '&sig=' + sig;
}

const url = signedCheckoutUrl({
  merchant_id: 'mch_YOUR_MERCHANT_ID',
  amount: '19.90', currency: 'EUR', order_id: 'ORDER-1042',
  return_url: 'https://shop.example/thanks',
}, process.env.LANAPAY_SIGNING_SECRET); // rsig_...
```

> **Warning:** Unsigned redirect ⇒ the amount rode in an editable URL
>
> **Anyone can change `amount=19.90` to `amount=0.01` before paying.** If you use unsigned redirects you MUST verify — server-side, via webhook or `GET /api/v1/sessions/:id` — that the session's `status` is `paid` *and* that `amount` + `currency` match your order before fulfilling. Sessions report `amount_source` and `tamper_protected` so you can always see how the amount arrived.

### The return redirect

After a successful payment the shopper is sent to your `return_url` with `?lanapay_session_id=cs_…&lanapay_order_id=…` appended. If your `return_url` contains the literal placeholder `{SESSION_ID}`, it is substituted instead of appending. On cancel, the shopper goes back to your `cancel_url`.

> **Warning:** The redirect proves nothing
>
> Anyone can type your `return_url` with any query string into a browser. Treat the redirect purely as navigation: use `lanapay_session_id` to look the session up server-side (`GET /api/v1/sessions/:id`) or wait for the `session.paid` webhook — and only then fulfill.

```bash
# Poll instead of (or as well as) webhooks — id from session creation
# or from the lanapay_session_id param on your return_url:
curl https://pay.lanapays.us/api/v1/sessions/cs_9f2Kq7... \
  -H "Authorization: Bearer sk_live_..."
# → fulfill ONLY when "status": "paid" and amount/currency match your order.
```

## Payment links

A payment link is a reusable URL you create once — in the dashboard (Payment links) or via `POST /api/v1/payment_links` — and share anywhere: an email, an invoice PDF, a social bio, a printed QR. No code, no backend.

```text
https://pay.lanapays.us/l/aB3xY9kL2m
```

| Type | Behavior | Typical use |
| --- | --- | --- |
| `fixed` | Carries `amount` + `currency`; opening `/l/<code>` goes straight to checkout | A product, a flat service charge, an invoice |
| `open` | The shopper picks the amount on `/link/<code>`, within your `min_amount`/`max_amount`, with optional `presets` | Donations, tips, pay-what-you-want |

Links can carry a `max_uses` cap and can be deactivated at any time; a deactivated or exhausted link shows the shopper a friendly error instead of a checkout.

> **Note:** QR codes: encode the payment link, never a signed URL
>
> A QR should encode `https://pay.lanapays.us/l/<code>`. Do **not** put signed `/checkout` URLs in a QR: signatures expire (default 15 minutes) while a printed QR lives for years. Payment links are the durable, revocable equivalent — and you can deactivate one if the poster leaves the wall.

## lanapay.js

One script tag renders a "Pay with LanaPay" button anywhere you can paste HTML. Declarative (a `div` with data attributes) or programmatic (mount into a slot):

**HTML**

```html
<script src="https://pay.lanapays.us/js/v1/lanapay.js" defer></script>
<div class="lanapay-button"
     data-merchant-id="mch_YOUR_MERCHANT_ID"
     data-amount="19.90" data-currency="EUR"
     data-order-id="ORDER-1042"
     data-description="Blue hoodie"
     data-return-url="https://shop.example/thanks"
     data-cancel-url="https://shop.example/cart"
     data-theme="dark"></div>
```

**JavaScript**

```js
<script src="https://pay.lanapays.us/js/v1/lanapay.js"></script>
<script>
  const lp = LanaPay('mch_YOUR_MERCHANT_ID');
  lp.mount('#pay-slot', {
    amount: '19.90', currency: 'EUR', orderId: 'ORDER-1042',
    returnUrl: 'https://shop.example/thanks', theme: 'light',
  });
</script>
```

### Options

| Data attribute | Programmatic | Required | Meaning |
| --- | --- | --- | --- |
| `data-merchant-id` | `LanaPay(id)` | yes | Your `mch_…` id |
| `data-amount` | `amount` | yes | Fiat amount string, e.g. `"19.90"` |
| `data-currency` | `currency` | yes | Must match your merchant currency |
| `data-order-id` | `orderId` | yes | Your order reference (idempotency key) |
| `data-description` | `description` | no | Shown on the pay page |
| `data-return-url` | `returnUrl` | yes | Where the shopper lands after paying (also the fallback flow target) |
| `data-cancel-url` | `cancelUrl` | no | Where "cancel" leads |
| `data-theme` | `theme` | no | `light` (default) or `dark` |

### Popup + fallback

Clicking the button opens the hosted checkout in a **popup window** — never an iframe. If the browser blocks the popup, the widget falls back to a full-page redirect to the same checkout URL, and the normal [return-redirect contract](https://pay.lanapays.us/docs#hosted-checkout) applies.

In the popup flow, the checkout posts a result message back to your page when it finishes:

```js
// lanapay.js handles the popup handshake for you and hands the result
// to your onComplete callback (or the checkout() promise):
LanaPay('mch_YOUR_MERCHANT_ID').checkout({
  amount: '19.90', currency: 'EUR', orderId: 'ORDER-1042',
  returnUrl: 'https://shop.example/thanks',
  onComplete: (result) => {
    // result = { status: 'paid' | 'cancelled' | 'expired' | 'closed' | 'error',
    //            sessionId?: 'cs_…' }
    // UI convenience only (close a modal, show a check mark) —
    // fulfill exclusively on server-verified state (webhook or GET session).
    // Return false to keep the shopper here instead of the default redirect.
  },
}).then((result) => { /* same result if you prefer a promise */ });
```

> **Warning:** postMessage is UX, not truth
>
> Like the return redirect, the message can be forged by anything running in the shopper's browser. Use it to update your UI — fulfill only on server-verified state (webhook or API).

### CSP & versioning

> **Note:** Content-Security-Policy
>
> If your site sets a CSP, the only directive the widget needs is `script-src https://pay.lanapays.us`. It creates no iframes and makes no fetch/XHR calls from your page — the checkout itself runs in a popup or redirect on our origin.

`/js/v1/lanapay.js` is a stable major version: we patch it in place (bug fixes, never breaking changes), so **SRI (`integrity` attributes) is unsupported** — a pinned hash would break on the next patch. Breaking changes will ship as `/js/v2/`.

## API reference

Server-to-server REST under `https://pay.lanapays.us/api/v1`. Authenticate with `Authorization: Bearer sk_live_…` (dashboard → API keys — the secret is shown once at creation). 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 } }` — quote `request_id` when contacting support. Rate limit: 120 requests/min per key.

### POST /api/v1/sessions — create a checkout session

**curl**

```bash
curl -X POST https://pay.lanapays.us/api/v1/sessions \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "19.90",
    "currency": "EUR",
    "order_id": "ORDER-1042",
    "description": "Blue hoodie",
    "return_url": "https://shop.example/thanks?o=1042",
    "cancel_url": "https://shop.example/cart"
  }'
```

**Node**

```js
const res = await fetch('https://pay.lanapays.us/api/v1/sessions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.LANAPAY_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: '19.90',
    currency: 'EUR',
    order_id: order.id,
    return_url: 'https://shop.example/thanks',
  }),
});
const session = await res.json();
// redirect the shopper:
res.redirect(session.url);
```

| Field | Required | Rules |
| --- | --- | --- |
| `amount` | yes | String like `"19.90"` preferred (numbers accepted); up to 2 decimals |
| `currency` | yes | Must match your merchant currency |
| `order_id` | yes | `[A-Za-z0-9._-]{1,64}`; idempotency key (below) |
| `unit_id` | platform key | Only with a `pk_platform_…` key, and then required: the enrolled unit to sell for, 32 lowercase hex (`400 INVALID_UNIT_ID`). Ignored for `sk_live_…` keys |
| `description` | no | Shown to the shopper |
| `metadata` | no | Object — up to 10 keys, string values up to 200 chars; echoed back on the session |
| `return_url` | yes | `https` (http for localhost); `{SESSION_ID}` placeholder supported |
| `cancel_url` | no |  |
| `expires_in` | no | 300–86400 seconds; defaults to your merchant expiry setting (60 min unless changed) |

Returns `201` with the session object. Send the shopper to the session's `url`.

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

```json
{
  "id": "cs_9f2kq7pxw4ln8vrt3a1b5c6d",
  "object": "checkout_session",
  "url": "https://pay.lanapays.us/pay/pk3XualNQuBv7Ge9wS2ZdT4c8mYhJfRw",
  "status": "pending",
  "amount": "19.90",
  "currency": "EUR",
  "order_id": "ORDER-1042",
  "description": "Blue hoodie",
  "created_via": "api",
  "amount_source": "api",
  "tamper_protected": true,
  "payment_link_id": null,
  "return_url": "https://shop.example/thanks?o=1042",
  "cancel_url": "https://shop.example/cart",
  "payment": null,
  "metadata": { "cart_id": "c_812" },
  "created_at": "2026-08-19 09:00:00",
  "expires_at": "2026-08-19 09:30:00",
  "paid_at": null,
  "cancelled_at": null
}
```

Once paid, `payment` is populated:

```json
"payment": {
  "tx_id": "9c41d2ab-…",          // gateway transaction id
  "tx_hash": "b7e2…9a04",         // LanaCoin on-chain transaction hash
  "lana_lanoshis": 31093750000,    // LANA actually paid, in lanoshis
  "exchange_rate": 0.064           // fiat per LANA at pay time
}
```

### The session object

| Field | Meaning |
| --- | --- |
| `id` | Session id, `cs_…` |
| `object` | Always `checkout_session` |
| `url` | The hosted pay page — send the shopper here |
| `status` | `pending` \| `paid` \| `expired` \| `cancelled` (below) |
| `amount` | Fiat amount as a fixed 2-decimal **string** — never a float |
| `currency` | Session currency |
| `order_id` | Your reference; unique among open/paid sessions |
| `description` | Optional shopper-visible text |
| `created_via` | How the session was born — e.g. `api`, checkout redirect, payment link, dashboard |
| `amount_source` | `api` \| `url_signed` \| `url_unsigned` \| `payment_link` \| `dashboard` — how the price arrived |
| `tamper_protected` | `true` unless the amount rode in an unsigned URL (`amount_source = url_unsigned`) |
| `payment_link_id` | Set when the session came from a payment link |
| `return_url` · `cancel_url` | As provided |
| `payment` | `null` until paid, then `{tx_id, tx_hash, lana_lanoshis, exchange_rate}`. The payer wallet is deliberately not included — use `tx_hash` to identify the payment on-chain |
| `metadata` | Your metadata object, echoed back |
| `created_at` · `expires_at` · `paid_at` · `cancelled_at` | UTC timestamps |

| Status | Meaning |
| --- | --- |
| `pending` | Payable until `expires_at` |
| `paid` | Terminal. `payment` set; `session.paid` webhook fired |
| `expired` | Terminal. Never paid before `expires_at`; no money moved |
| `cancelled` | Terminal. Shopper cancelled, you cancelled in the dashboard, or `POST …/expire` |

### order_id idempotency

`order_id` is the idempotency key — one live session per order:

| You POST… | Result |
| --- | --- |
| Same `order_id`, same amount + currency, while a session is open | **200** replay — the *same* session comes back (a shop retry or a shopper's double-click resumes it, never mints a competitor) |
| Same `order_id`, *different* amount or currency, while a session is open | **409** `ORDER_ID_CONFLICT` — `error.session_id` points at the existing session (expire it first if the price really changed) |
| An `order_id` that is already `paid` | **409** `ORDER_ALREADY_PAID` — `error.session_id` set; do not bill the customer twice |

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

```bash
curl https://pay.lanapays.us/api/v1/sessions/cs_9f2Kq7... \
  -H "Authorization: Bearer sk_live_..."
```

`404 NOT_FOUND` for unknown ids *and* other merchants' sessions — the API is not an existence oracle.

### GET /api/v1/sessions — list

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

# → { "object": "list", "data": [ …sessions, newest first… ], "has_more": true }
# page with ?starting_after=<last id of the previous page>
```

Filters: `status`, `order_id`; `limit` up to 100 (default 25); cursor pagination with `starting_after` (the last id of the previous page). Newest first.

### POST /api/v1/sessions/:id/expire — end early

```bash
curl -X POST https://pay.lanapays.us/api/v1/sessions/cs_9f2kq7.../expire \
  -H "Authorization: Bearer sk_live_..."
```

Ends a `pending` session so it can no longer be paid (it ends as `cancelled` and fires `session.cancelled`). Returns the session, or `409 NOT_EXPIRABLE` when it is already terminal or a payment is in flight.

### Platform scope — the pk_platform_ key

Besides per-merchant `sk_live_…` keys there is **one platform key** (`pk_platform_…`), held only by the Lana Online Shop broker (shop.lanapays.us) and configured on the gateway as SHA-256 hashes — the key itself is never stored. It creates, retrieves, lists and closes sessions for **any** enrolled unit, naming the unit per request: `POST /api/v1/sessions` requires `unit_id` (`400 INVALID_UNIT_ID`; `404 MERCHANT_NOT_ENROLLED` when that unit has no active merchant account; suspension answers the usual `403`), `GET /api/v1/sessions` requires `?unit_id=` (`400 UNIT_ID_REQUIRED`), and sessions it creates carry `api_key_id = platform`. It is not a key on `/api/v1/payment_links`. Rate limit: 600 requests/min. Merchant keys are unchanged — a `unit_id` they send is ignored.

### Payment links API

```bash
curl -X POST https://pay.lanapays.us/api/v1/payment_links \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "open",
    "currency": "EUR",
    "description": "Support our work",
    "min_amount": "1.00",
    "max_amount": "500.00",
    "presets": ["5.00", "10.00", "25.00"]
  }'

# → 201 { "id": "…", "code": "aB3xY9kL2m", "url": "https://pay.lanapays.us/l/aB3xY9kL2m", … }
# GET  https://pay.lanapays.us/api/v1/payment_links            — list (newest first)
# POST https://pay.lanapays.us/api/v1/payment_links/:id/deactivate
```

`type` is `fixed` (requires `amount`) or `open` (optional `min_amount`/`max_amount`/`presets`); both accept `description` and `max_uses`. Deactivating twice yields `409 ALREADY_INACTIVE`.

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

No auth, open CORS — safe to call from any browser. Powers the live badge at the top of this page.

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-19 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 still returned as 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.

| Event | Fires when |
| --- | --- |
| `session.paid` | The payment is on-chain — **the one to fulfill on** |
| `session.expired` | A pending session ran out of time |
| `session.cancelled` | Shopper, dashboard or API ended the session |
| `test.ping` | You pressed *Send test* in the dashboard (payload carries `"test": true`) |

Illustrative payload:

```json
{
  "id": "evt_2b91c4d8f7a35e60",
  "object": "event",
  "type": "session.paid",
  "created_at": "2026-08-19 09:05:12",
  "data": {
    "session": { … full session object, see API reference … }
  }
}
```

### Signature verification

Every delivery carries these headers:

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

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 === 'session.paid') {
    // Dedupe on event.id, then fulfill AFTER checking amount + currency:
    const s = event.data.session;
    // if (s.amount === expectedAmount && s.currency === expectedCurrency) fulfill(s.order_id);
  }
  res.status(200).end();
});
```

**PHP**

```php
<?php
// Deliberately written for old PHP: no short array syntax, no null coalescing,
// and a hash_equals polyfill. Magento 1 and other long-lived shops still run
// PHP 5.x, where the modern spellings are a fatal parse error.

if (!function_exists('lanapay_hash_equals')) {
  function lanapay_hash_equals($known, $given) {
    if (function_exists('hash_equals')) {      // PHP 5.6+
      return hash_equals($known, $given);
    }
    if (!is_string($known) || !is_string($given)) return false;
    if (strlen($known) !== strlen($given)) return false;
    $diff = 0;
    for ($i = 0, $n = strlen($known); $i < $n; $i++) {
      $diff |= ord($known[$i]) ^ ord($given[$i]);   // constant time
    }
    return $diff === 0;
  }
}

function lanapay_verify_signature($rawBody, $sigHeader, $secret, $tolerance = 300) {
  $t = null;
  $sigs = array();
  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) {                      // accept ANY matching v1
    if (lanapay_hash_equals($expected, $s)) return true;
  }
  return false;
}

$rawBody = file_get_contents('php://input');   // BEFORE any parsing
$sig = isset($_SERVER['HTTP_LANAPAY_SIGNATURE']) ? $_SERVER['HTTP_LANAPAY_SIGNATURE'] : '';
if (!lanapay_verify_signature($rawBody, $sig, $webhookSecret)) {
  http_response_code(400); exit;
}
$event = json_decode($rawBody, true);
if ($event['type'] === 'session.paid') {
  $session = $event['data']['session'];
  // Dedupe on $event['id'], then verify amount + currency against your order
  // BEFORE fulfilling.
}
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 (emails, ERP sync) after acknowledging.

### Testing an endpoint

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

## WordPress

One plugin, two modes. On **any** WordPress site it gives you the ` [lanapay_button] ` shortcode and a matching **LanaPay Button** block (`lanapay/button`) — no shop plugin, no cart, no coding. If WooCommerce happens to be active, the *same* install also adds LanaPay as a payment method at checkout, sharing one set of credentials and one webhook.

### The two modes

| Your site | What you get | What you do |
| --- | --- | --- |
| Any WordPress site — no shop plugin at all | The ` [lanapay_button] ` shortcode and the **LanaPay Button** block: donations, services, deposits, tickets, memberships, a single product | Drop the block or the shortcode into a page, post or widget |
| A WordPress site with WooCommerce active | Everything above **plus** LanaPay as a payment method at checkout — on the classic ` [woocommerce_checkout] ` shortcode *and* on the Checkout block | Enable it under **WooCommerce → Settings → Payments**; it reuses the credentials you already pasted |

### Install

**No coding is required.** From a standing start this takes about five minutes.

**Download the WordPress plugin:** https://pay.lanapays.us/downloads/lanapay-for-wordpress.zip — One ZIP, always the current release. The plugin is not in the wordpress.org directory — you upload it yourself, the same way any paid or bespoke plugin is installed.

1. Download the plugin (button above), then in WP admin go to Plugins → Add New → *Upload Plugin* → choose the `lanapay-for-wordpress.zip` you just downloaded → *Install Now* → *Activate*.
2. Go to **Settings → LanaPay** and leave the tab open — three things get pasted into it.
3. Copy your **merchant ID** (`mch_…`), shown on the LanaPay dashboard overview, into the settings page.
4. Create an API **secret key** (`sk_live_…`) at `https://pay.lanapays.us/app/keys` — it is shown **once** — and paste it straight in.
5. Add an endpoint at `https://pay.lanapays.us/app/webhooks`, using the webhook URL the settings page shows for *your* site (below); paste that endpoint’s **webhook secret** (`whsec_…`) back into the settings page.
6. Pick your currency and your button label, click **Save**, and put a button on a page.

That is the whole setup. If WooCommerce is active, LanaPay is now also available at checkout — go to **WooCommerce → Settings → Payments** and enable it. It uses the credentials you just entered; there is nothing more to fill in.

### Settings → LanaPay

| Setting | Looks like | Where it comes from |
| --- | --- | --- |
| Merchant ID | `mch_…` | LanaPay dashboard — shown on the overview and the Integration tab |
| API secret key | `sk_live_…` | `https://pay.lanapays.us/app/keys` → *Create key* — copy it at creation, it is shown **once** |
| Webhook secret | `whsec_…` | `https://pay.lanapays.us/app/webhooks` — add your site as an endpoint (URL below), then copy that endpoint’s secret |
| Currency | `EUR` | The currency new buttons default to. It must match the one your merchant account is bound to. |
| Button label | `Pay with LanaPay` | The default text on the button, e.g. *Pay with LanaPay*; a single button can override it with `label` |

Your API secret key stays on the site’s own server. It is never printed into a page, never sent to a browser and never written to the debug log, so a visitor can never see it.

### Webhook URL

The settings page prints the exact URL for your site — copy it from there and paste it into the LanaPay dashboard as the endpoint URL (here with your host in place of `SITE`):

```text
https://SITE/wp-json/lanapay/v1/webhook
```

On a site without pretty permalinks the REST endpoint lives at the query-string fallback instead. Paste whatever the settings page displays, exactly, including the `?rest_route=` part:

```text
https://SITE/?rest_route=/lanapay/v1/webhook
```

The plugin marks a payment — or a WooCommerce order — paid only on a signature-verified `session.paid` whose amount and currency match; the visitor’s return redirect alone never completes anything.

### The shortcode and the block

Paste ` [lanapay_button] ` into any page, post or widget. Every attribute is optional — leave `amount` out and the visitor enters their own.

| Attribute | Default | What it does |
| --- | --- | --- |
| `amount` | empty — the visitor chooses | The amount to charge, a decimal with at most two places (`amount="19.90"`). Left out, the button shows an open amount field. |
| `currency` | the settings page | `EUR`, `GBP` or `USD` — it must match the currency your merchant account is bound to |
| `description` | Payment to *your site name* | The line the visitor sees on the LanaPay checkout page, and that is stored with the payment record |
| `label` | the settings page | The button text, for this button only |
| `min` | none | The smallest amount accepted when the visitor types their own. Ignored when `amount` is set. |
| `max` | none | The largest amount accepted when the visitor types their own. Ignored when `amount` is set. |
| `presets` | none | Comma-separated quick-pick amounts (`presets="10,25,50,100"`); up to six are shown and anything outside `min`/`max` is dropped. In decimal-comma locales separate them with semicolons instead (`presets="10,50;25;50"`). Ignored when `amount` is set. |
| `return_url` | the page the button is on | Where the visitor lands after paying. Must be an `https://` URL. |
| `align` | left | `left`, `center` or `right` — how the button sits in its column |
| `theme` | gold | `gold`, `dark` or `light` — the button’s look |

### Three worked examples

**A fixed price.** One product, one price, no choices:

```text
[lanapay_button amount="19.90" currency="EUR" description="Handbound notebook"]
```

**A donation button.** Four suggested amounts, plus an open field with a floor and a ceiling:

```text
[lanapay_button presets="10,25,50,100" min="5" max="1000" description="Support our work" label="Donate with LANA"]
```

**A custom label and a thank-you page.** Send the visitor somewhere specific once they have paid:

```text
[lanapay_button amount="120.00" description="Consultation, 60 min" label="Book and pay" return_url="https://example.com/thank-you/"]
```

The **LanaPay Button** block offers the same options in the editor sidebar, so you never have to type a shortcode if you prefer not to.

> **Warning:** The amount cannot be edited in the browser
>
> When a button is rendered, your server signs its configuration — amount, currency, description, limits — with a site-local key (HMAC). On click, that signed configuration is sent back and re-verified before a session is ever created, so editing the page source only breaks the signature.

### With WooCommerce

When WooCommerce is active the same install registers LanaPay as a payment method automatically. It reads the merchant ID, secret key and webhook secret you already entered and uses the same webhook endpoint, so there is nothing extra to fill in on the WooCommerce screen — enable it under **WooCommerce → Settings → Payments** and it is one payment method among however many you already offer.

**Both checkouts are covered.** LanaPay registers with the classic checkout (the page built on the ` [woocommerce_checkout] ` shortcode) *and* with the newer Checkout block, WooCommerce’s default since 8.3. You do not have to choose: both decide whether LanaPay can be used by asking the same server-side rule, so they can never disagree. On a WooCommerce too old to provide the block payment API, LanaPay keeps working on the classic checkout and says so in an admin notice.

### Refunds

Manual and off-chain. LanaPay has **no reverse rail**: there is no refund button, and the plugin deliberately does not implement one. Send the LANA back from your own wallet, then mark the payment — or the WooCommerce order — refunded in WordPress by hand.

### Local testing

There is **no test mode** and no sandbox — every session is a real one. Two things stand in for it:

1. Walk through `https://pay.lanapays.us/demo` to see the whole checkout flow. It is client-side only, so nothing is charged and no session is created.
2. To prove your own site end to end, run it locally and expose it, so the webhook can actually reach WordPress:

```bash
# expose your local WordPress site so LanaPay's webhooks can reach it
cloudflared tunnel --url http://localhost:8888
# → https://random-words.trycloudflare.com
# use that host in the dashboard webhook URL:
# https://random-words.trycloudflare.com/wp-json/lanapay/v1/webhook
```

3. Point the dashboard webhook endpoint at the tunnel host, using the same `/wp-json/lanapay/v1/webhook` path.
4. Place one small live payment of your own (half a euro is enough) and pay it for real — nothing is deducted from it, and a session that is never paid simply expires and leaves nothing behind.

## Errors

### REST API — the /api/v1 envelope

```json
{
  "error": {
    "code": "INVALID_AMOUNT",
    "message": "amount must be a positive number with at most 2 decimals",
    "param": "amount",
    "request_id": "req_5f2a9c1b3e7d4a08"
  }
}
```

| Code | HTTP | Meaning |
| --- | --- | --- |
| `INVALID_API_KEY` | 401 | Missing, unknown or revoked key |
| `INVALID_AMOUNT` | 400 | Not a positive number with ≤ 2 decimals (`param: amount`) |
| `INVALID_CURRENCY` | 400 | Unknown, or does not match your merchant currency |
| `INVALID_ORDER_ID` | 400 | Fails `[A-Za-z0-9._-]{1,64}` |
| `INVALID_RETURN_URL` | 400 | `https` required (http only for localhost) |
| `INVALID_CANCEL_URL` | 400 | Same rules as return_url |
| `INVALID_METADATA` | 400 | Not an object / more than 10 keys / values over 200 chars / too large |
| `INVALID_EXPIRES_IN` | 400 | Outside 300–86400 seconds |
| `ORDER_ID_CONFLICT` | 409 | Open session with this `order_id` but different amount/currency; `error.session_id` set |
| `ORDER_ALREADY_PAID` | 409 | This `order_id` was already paid; `error.session_id` set |
| `TOO_MANY_OPEN_SESSIONS` | 429 | Too many pending sessions for this shop — retry later |
| `NOT_FOUND` | 404 | Unknown id (or not yours — same answer on purpose) |
| `NOT_EXPIRABLE` | 409 | Session is already terminal, or a payment is in flight |
| `ALREADY_INACTIVE` | 409 | Payment link was already deactivated |
| `INVALID_UNIT_ID` | 400 | Platform key: `unit_id` missing or not 32 lowercase hex (`param: unit_id`) |
| `UNIT_ID_REQUIRED` | 400 | Platform key: `GET /api/v1/sessions` needs `?unit_id=` — it never lists everything |
| `MERCHANT_NOT_ENROLLED` | 404 | Platform key: that unit has no active LanaPay merchant account |

### Checkout redirect — /checkout-error codes

When `GET /checkout` rejects a request, the shopper is 302-redirected to `/checkout-error?code=…`:

| Code | Meaning | Fix |
| --- | --- | --- |
| `UNKNOWN_MERCHANT` | No such `merchant_id` | Check the `mch_…` id against your dashboard |
| `MERCHANT_PENDING` | Onboarding not approved yet | Wait for approval; check the dashboard |
| `MERCHANT_SUSPENDED` | The merchant account is suspended | Resolve the suspension in the dashboard |
| `MERCHANT_REJECTED` | Onboarding was rejected | Contact support via the dashboard |
| `INVALID_SIGNATURE` | `sig` does not verify | Wrong rsig_ secret, unsorted keys, or signing the URL-encoded query instead of the canonical string — see the worked example above |
| `SIGNATURE_EXPIRED` | `exp` passed (default `ts+900`) or `ts` beyond the 300 s skew | Generate the URL at click time, not at page-render time |
| `SIGNED_URLS_REQUIRED` | Your settings require signed URLs but `sig` was missing | Sign the URL, or relax the setting |
| `INVALID_AMOUNT` | Fails `^\d{1,6}(\.\d{1,2})?$` | Send e.g. `19.90` — no thousands separators, dot decimal |
| `INVALID_CURRENCY` | Unknown or mismatched currency | Use your merchant account currency |
| `INVALID_ORDER_ID` | Fails `[A-Za-z0-9._-]{1,64}` | Strip spaces and exotic characters |
| `INVALID_RETURN_URL` | Not `https` (http only for localhost) | Use an https URL |
| `ORDER_ID_CONFLICT` | An open session exists for this order with a different amount/currency | Expire the old session or use a fresh order_id |
| `TOO_MANY_OPEN_SESSIONS` | The shop has too many pending sessions | Retry shortly |

## Going live

Five checks before you flip the switch:

- **Verify webhook signatures** — Raw request bytes, HMAC-SHA256 with your whsec_ secret, 300 s timestamp tolerance. Unsigned or stale deliveries get a 400.
- **Fulfill only on server-verified paid + amount/currency match** — Never on the return redirect or a postMessage. The session object tells you the amount, currency, amount_source and tamper_protected — check them against your order.
- **Handle duplicate events** — Delivery is at-least-once. Dedupe on the evt_… id and make your handler safe to run twice.
- **One merchant id per store / environment** — Never share sk_live_ keys or webhook endpoints between staging and production — order_id idempotency and webhooks would tangle the two.
- **Do a small live test** — There is no test mode. Pay a real 0.50 EUR order end-to-end and watch it land: checkout → paid → webhook → fulfillment.

That's it. Create your keys in the [dashboard](https://pay.lanapays.us/login) and take your first payment.

---

LanaPay · pay.lanapays.us · you earn 2% on every payment, 5% enrolled in Lana8Wonder — paid on top of your invoice, never deducted from it
