# Fluense Merchant API — full documentation

# Fluense Merchant API

Fluense is social discovery with real cashback: shoppers pay at your store, recommend what they bought, and earn cashback when friends buy through them. The Merchant API gives you everything needed to accept payments, track sales, and see the cashback your store funds.

**Base URL** `https://api.fluense.social` — one API serves sandbox and live. Your credentials decide which environment a request touches.

## What you can do

- Organizations, team members and roles
- API keys scoped to sandbox or live
- Terminals identified by a terminal id (QR, NFC, link — encode it your way)
- Checkouts and charge capture (authorize -> capture)
- Voids and full or partial refunds
- Sales list and detail, cashback summary
- Signed webhooks for checkout and charge events
- Payouts to your bank account

## Credentials at a glance

| Credential | Header | Used for |
| --- | --- | --- |
| API key | `x-api-key: fl_test_…` or `fl_live_…` | Server-to-server: checkouts, capture, void, returns, terminal scans, payouts |
| Session token | `authorization: Bearer sess_…` | Merchant console endpoints: team, keys, sales, webhooks, payout list |

Keys are **environment-scoped**: a `fl_test_` key only reads and writes sandbox data, `fl_live_` only live data. See [Environments](/environments).

## The payment flow

1. Create a **checkout** for a cart (your key).
2. The shopper **authorizes** it in the Fluense app (their session) — a 7-day hold.
3. You **capture** the charge (your key) — money moves, fees and cashback post.
4. Refund later with a **return** if needed; receive **webhooks** for every step.

## For LLMs and agents

These docs are built to be read by machines:

- [llms.txt](/llms.txt) — an index of every page with descriptions.
- [llms-full.txt](/llms-full.txt) — every page concatenated in one markdown file.
- Append `.md` to any page URL for raw markdown, e.g. `/checkouts.md`.
- Or send `Accept: text/markdown` to the same URL and get markdown instead of HTML.

## Links

- Merchant console: https://business.fluense.social
- Storefront (demo): https://store.fluense.social
- API health: `GET https://api.fluense.social/health`

---

# Quickstart

Create an account in the console, then take a payment end to end with the API. Only the money is test funds.

## 1. Create a business account

Sign up in the Fluense console — registration is email-code based (no password):

**→ [business.fluense.social](https://business.fluense.social)**

You choose your business name and country; the country sets your default currency and payout destinations. New businesses start in **sandbox** and move to live once KYB is approved — see [Sandbox & live](/environments).

## 2. Create an API key

Open **API keys** in the console and create one for the environment you want (owner or admin). The raw key is shown **once**.

Prefer to script it? The same call, with a console session token and org id:

```bash
curl -s https://api.fluense.social/apikeys \
  -H "authorization: Bearer $SESSION" \
  -H 'content-type: application/json' \
  -d '{"org_id":"'$ORG'","env":"sandbox","name":"server"}'
```

```json
{ "key": "fl_test_…", "id": "019a…", "prefix": "fl_test_ab12" }
```

## 3. Create a terminal

A terminal is a point of sale identified by its **terminal id** — encode it however your shoppers can read it: a QR code, an NFC tag, a deep link.

```bash
curl -s https://api.fluense.social/terminals \
  -H "authorization: Bearer $SESSION" \
  -H 'content-type: application/json' \
  -d '{"title":"Front counter"}'
```

```json
{ "id": "019a…" }
```

## 4. Create a checkout

```bash
curl -s https://api.fluense.social/checkouts \
  -H "x-api-key: $KEY" \
  -H 'content-type: application/json' \
  -d '{
    "terminal_id": "'$TERMINAL'",
    "currency": "USD",
    "items": [{"product_id":"sku-1","display_name":"Desk lamp","unit_price":8900,"qty":1,"cashback_rate":500}],
    "tax_amount": 800,
    "total": 9700,
    "order_id": "order-1001"
  }'
```

```json
{ "id": "019a…", "terminal_id": "019a…" }
```

`total` must equal `sum(unit_price x qty) + shipping_amount + tax_amount`. Creating a checkout on a reusable terminal supersedes its previous pending checkout.

## 5. The shopper authorizes

The shopper reads the terminal (QR scan, NFC tap or pay link) and confirms in the Fluense app. This is a **consumer session** call, not yours:

```
POST /checkouts/{id}/authorize      # consumer session
```

```json
{ "auth_id": "019a…" }
```

This places a 7-day hold. No money has moved yet.

## 6. Capture the charge

```bash
curl -s -X POST https://api.fluense.social/charges/$AUTH/capture \
  -H "x-api-key: $KEY"
```

```json
{
  "journal_id": "019a…",
  "mdr_fee": 154,
  "buyer_amount": 0,
  "deferred_buyer_amount": 225,
  "recommender_amount": 75,
  "recommender_id": null,
  "recommenders": []
}
```

Capture is **idempotent**: retrying the same charge returns the same transaction reference and cashback. See [Charges](/charges).

## 7. Receive webhooks

```bash
curl -s -X PUT https://api.fluense.social/orgs/$ORG/webhook \
  -H "authorization: Bearer $SESSION" \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com/fluense/webhook"}'
```

```json
{ "url": "https://example.com/fluense/webhook", "secret": "whsec_…" }
```

Verify `x-fluense-signature` on every delivery. See [Webhooks](/webhooks).

---

# Authentication

Two credential types exist. Both hit the same API; they differ in what they can do.

## API keys (server-to-server)

Send the key in the `x-api-key` header. Keys are prefixed by environment: `fl_test_…` for sandbox, `fl_live_…` for live. The prefix decides which environment the request touches — there is no env parameter.

| Action | Endpoint | Who |
| --- | --- | --- |
| Create | `POST /apikeys` `{org_id, env, name}` | Session, owner or admin |
| Verify | `GET /apikeys/verify` (with `x-api-key`) | Any holder |
| List | `GET /orgs/{id}/keys` | Session, org member |
| Revoke | `DELETE /orgs/{id}/keys/{key_id}` | Session, owner or admin |

```bash
curl -s https://api.fluense.social/apikeys/verify -H "x-api-key: $KEY"
```

```json
{ "key_id": "019a…", "org_id": "019a…", "env": "sandbox" }
```

Notes:

- The raw key is returned **once** at creation; the server keeps only a one-way digest, never the key itself.
- A key created for `live` cannot be used while the server resolves `sandbox` and vice versa — mismatches return `403 key env mismatch`.
- Keys can be revoked at any time; revocation is immediate.

## Sessions (merchant console)

Email OTP, no passwords.

```bash
curl -s https://api.fluense.social/auth/otp/request \
  -H 'content-type: application/json' -d '{"email":"owner@acme.test"}'

curl -s https://api.fluense.social/auth/otp/verify \
  -H 'content-type: application/json' -d '{"email":"owner@acme.test","code":"123456"}'
```

```json
{
  "session_token": "sess_…",
  "user_id": "019a…",
  "env": "sandbox",
  "live_eligible": false,
  "live_blockers": ["business verification required"]
}
```

Sessions last 30 days. Switch environments with `POST /auth/mode {env, org_id?}` — sandbox always, live only when eligible. `GET /auth/live-eligibility?org_id=` reports `{eligible, blockers}`.

Business registration happens in the console at [business.fluense.social](https://business.fluense.social) — sign up with an email code and the API picks up from your first key; see the [Quickstart](/quickstart).

## Roles

Roles are per organization, ranked: **owner > admin > finance > support > developer**.

| Capability | owner | admin | finance | support | developer |
| --- | --- | --- | --- | --- | --- |
| Manage members and invites | yes | yes | no | no | no |
| Issue and revoke API keys | yes | yes | no | no | no |
| View sales and cashback | yes | yes | yes | yes | yes |
| Create payouts | yes | yes | yes | no | no |
| Configure webhooks | yes | yes | no | no | no |

Use `GET /orgs` to list the organizations a session belongs to, with its role in each.

## Errors

- `401` — missing or invalid credentials.
- `403` — valid credentials, insufficient role or environment mismatch.

---

# Sandbox & live

One API, two environments. `https://api.fluense.social` serves both; the credential decides which environment a request touches.

| | Sandbox | Live |
| --- | --- | --- |
| API keys | `fl_test_…` | `fl_live_…` |
| Money | test funds only | real money |
| Merchant gate | none | KYB approved |
| Consumer gate | none | KYC approved + a banking partner for the country |

## How the environment is resolved

- **API key requests**: from the key prefix — `fl_test_` routes to sandbox, `fl_live_` to live.
- **Session requests**: resolved at sign-in. A session becomes live only when the identity is verified (KYC for consumers, KYB for businesses) **and** a live banking partner is connected for the jurisdiction. Otherwise it is sandbox.
- `GET /auth/live-eligibility?org_id=` returns `{eligible, blockers}` so the console can explain what is missing.
- `POST /auth/mode {env, org_id?}` re-evaluates the rule and returns a fresh session token.

## Isolation

Environment-scoped data includes checkouts, charges, cashback, wallets, sales and social content. Identity (users, orgs, members, follows) is global. A key or session can never cross the boundary: a sandbox key calling live data gets `403 key env mismatch`.

## Going live

1. Complete **KYB** for the organization: `POST /compliance/kyb/start {org_id, country_code}` returns a hosted verification URL.
2. Wait for approval — capture in live requires an approved KYB.
3. Issue a `fl_live_` API key from the console.
4. Consumers must complete KYC and their country needs a live banking partner; the US uses ACH today, and more countries follow as partners come online.

---

# Terminals

A terminal is a point of sale your shoppers pay through. It is identified by its **terminal id**, and that id can be encoded in anything the shopper can read — a **QR code**, an **NFC tag**, a deep link, a printed number. When the Fluense app reads it, it resolves the terminal's latest checkout and the shopper authorizes it.

| Type | Behaviour |
| --- | --- |
| `REUSABLE` | A permanent terminal id for a counter or register — present it as a QR code, write it to an NFC tag, or link to it. One active checkout at a time: creating a new one supersedes the previous pending checkout. |
| `USE_ONCE` | Created automatically when a checkout is made without a `terminal_id` — useful for one-off payment links. |

## Create

Session or API key (the key's org must own the terminal).

```bash
curl -s https://api.fluense.social/terminals \
  -H "authorization: Bearer $SESSION" \
  -H 'content-type: application/json' \
  -d '{"title":"Front counter"}'
```

```json
{ "id": "019a…" }
```

## List

```bash
curl -s https://api.fluense.social/terminals -H "authorization: Bearer $SESSION"
```

```json
{
  "terminals": [
    { "id": "019a…", "title": "Front counter", "terminal_type": "REUSABLE", "status": "ACTIVE", "created_at": "2026-10-08T09:12:00Z" }
  ]
}
```

## Update

`PATCH /terminals/{id}` with `title` and/or `status` (`ACTIVE` or `INACTIVE`).

```json
{ "ok": true }
```

## Resolve the active checkout

`GET /terminals/{id}/checkout` returns the latest non-expired checkout for the terminal — the endpoint behind the terminal id, whether the shopper scanned a QR, tapped an NFC tag or opened a link. API keys see their own terminals; consumer sessions can read any terminal (they are the shopper paying).

```json
{
  "id": "019a…",
  "merchant_id": "019a…",
  "terminal_id": "019a…",
  "items": [ { "product_id": "sku-1", "display_name": "Desk lamp", "unit_price": 8900, "qty": 1, "cashback_rate": 500 } ],
  "currency": "USD",
  "total": 9700,
  "status": "PENDING",
  "expires_at": "2026-10-09T09:12:00Z"
}
```

`404 no active checkout on this terminal` means the previous checkout expired, was paid, or was superseded — create a new one.

## POS flow

1. `POST /checkouts` with `terminal_id` (your key).
2. Poll `GET /terminals/{id}/checkout` or let the shopper read the terminal id (QR, NFC, link).
3. The shopper authorizes; you capture with `POST /charges/{auth_id}/capture`.

---

# Checkouts

A checkout is a cart that becomes a charge once the shopper authorizes it. Create it with an API key; the shopper authorizes it in the Fluense app.

## Create

```bash
curl -s https://api.fluense.social/checkouts \
  -H "x-api-key: $KEY" \
  -H 'content-type: application/json' \
  -d '{
    "terminal_id": "019a…",
    "currency": "USD",
    "items": [
      { "product_id": "sku-1", "display_name": "Desk lamp", "unit_price": 8900, "qty": 1, "cashback_rate": 500 }
    ],
    "shipping_amount": 0,
    "tax_amount": 800,
    "total": 9700,
    "order_id": "order-1001",
    "expires_in_secs": 3600
  }'
```

```json
{ "id": "019a…", "terminal_id": "019a…" }
```

### Body

| Field | Type | Notes |
| --- | --- | --- |
| `items` | array | Required, non-empty. Each line: `product_id`, `display_name`, `unit_price` (minor units), `qty` > 0, `cashback_rate` (bps, 0–10000), optional `item_image_url`, `item_url` |
| `total` | integer | Required. Must equal `sum(unit_price x qty) + shipping_amount + tax_amount` |
| `currency` | string | ISO-4217, default `USD`. Must match the shopper's account currency |
| `shipping_amount` | integer | Minor units, default 0 |
| `tax_amount` | integer | Minor units, default 0 |
| `terminal_id` | uuid | Optional. Omit to auto-create a `USE_ONCE` terminal |
| `order_id` | string | Your reference; echoed in sale responses. Not used for idempotency |
| `store` / `shipping` | object | Optional JSON blobs returned with the sale |
| `user_confirmation_url` / `user_cancel_url` | string | Optional redirects for hosted flows |
| `expires_in_secs` | integer | 60 s – 7 days; default 24 h |

## Statuses

`PENDING` → `AUTHORIZED` → `CAPTURED`

- `VOIDED` — the authorization was cancelled.
- `EXPIRED` — the checkout expired before payment.
- `SUPERSEDED` — a newer checkout replaced it on the same reusable terminal.

## Authorize (shopper side)

The Fluense app calls this with the shopper's session — it is not a merchant call, but it gates the flow:

```
POST /checkouts/{id}/authorize      # consumer session
```

```json
{ "auth_id": "019a…" }
```

Gates: KYC approved, transaction monitoring, wallet balance >= total. The authorization holds funds for **7 days**; capture inside that window.

## Errors

- `400 empty items` / `bad line` / `bad cashback_rate` — invalid cart.
- `400 total mismatch: server-computed N` — recompute `total`.
- `400 terminal belongs to another merchant` / `only REUSABLE terminals can be reused` / `terminal inactive`.

---

# Charges

A charge is an authorized checkout that you capture. Capture moves money, posts fees and cashback, and is idempotent.

## Capture

```bash
curl -s -X POST https://api.fluense.social/charges/$AUTH/capture \
  -H "x-api-key: $KEY"
```

```json
{
  "journal_id": "019a…",
  "mdr_fee": 154,
  "buyer_amount": 0,
  "deferred_buyer_amount": 225,
  "recommender_amount": 75,
  "recommender_id": "019a…",
  "recommenders": [ { "user_id": "019a…", "amount": 75 } ]
}
```

| Field | Meaning |
| --- | --- |
| `mdr_fee` | Merchant discount rate: `(total - tax - cashback) x mdr_bps / 10000`. Default 200 bps (2%), overridable per merchant |
| `recommender_amount` | Cashback earned by the recommender at capture (0 when the purchase did not come from a recommendation) |
| `buyer_amount` | Always 0 at capture — the buyer's share is deferred |
| `deferred_buyer_amount` | The buyer's cashback share, earned when they recommend the product they bought |

Capture is **idempotent on the charge id**: retrying returns the same transaction reference and cashback, and never charges twice. Capture works with an API key or the owning consumer session; live capture requires an approved KYB for the org.

When a line's product matches an active buy-list entry, the recommender earns their share automatically. See [Cashback](/cashback).

## Void

`POST /charges/{id}/void` cancels an `AUTHORIZED` charge and releases the hold. Only `AUTHORIZED` charges can be voided.

```json
{ "ok": true }
```

## Return (refund)

`POST /charges/{id}/return` refunds a captured charge. Omit `items` for a full refund, or pass quantities for a partial one.

```bash
curl -s -X POST https://api.fluense.social/charges/$AUTH/return \
  -H "x-api-key: $KEY" \
  -H 'content-type: application/json' \
  -d '{"reason":"damaged on arrival","items":[{"product_id":"sku-1","qty":1}]}'
```

```json
{
  "return_id": "019a…",
  "refund": 9700,
  "buyer_cut": 225,
  "rec_cut": 75,
  "award_adjusted": true
}
```

| Field | Meaning |
| --- | --- |
| `refund` | Principal returned: goods + pro-rata shipping and tax |
| `buyer_cut` / `rec_cut` | Cashback shares removed from the return |
| `award_adjusted` | True when this return reduced or voided pending cashback |

Rules:

- Refund quantity is validated against captured quantity minus previous returns — over-returning is a `400`.
- Pending cashback shrinks with the return; cashback that has already landed is never clawed back.
- Returns are not idempotent: retry only if the previous attempt produced no response.

---

# Cashback

Every product line carries a cashback rate you set (`cashback_rate` in basis points). The pot for a line is:

```
pot = unit_price x qty x cashback_rate / 10000
```

Cashback is **funded by the merchant** — it comes out of your side of the transaction, and the MDR is computed on the total minus tax and cashback.

## Who earns what

- **Recommender — at capture.** When a line matches a shopper's buy-list entry, the recommender earns their share the moment you capture.
- **Buyer — when they recommend.** The buyer's share is deferred. It is paid when the buyer recommends the product they bought:
  - purchase was attributed: they earn their share of the pot (the rest of the split),
  - purchase had no recommendation: they earn **100% of the pot**.

The split is a platform default (50/50 recommender/buyer) with per-country bounds, overridable per merchant by the platform team.

## Cashback lifecycle

- Cashback stays **pending** for 30 days, then lands in the user's wallet.
- Refunds reduce pending cashback; cashback that has already landed is never clawed back.

## Summary

`GET /orgs/{id}/cashback/summary` (session or API key) returns one entry per currency — never summed across currencies.

```json
{
  "currencies": [
    {
      "currency": "USD",
      "captured_count": 128,
      "captured_minor": 1240000,
      "funded_minor": 62000,
      "social_count": 41,
      "to_buyers_minor": 21000,
      "to_recommenders_minor": 10000
    }
  ]
}
```

| Field | Meaning |
| --- | --- |
| `captured_minor` | Gross volume captured |
| `funded_minor` | Cashback funded by the merchant |
| `social_count` | Captures with at least one recommender |
| `to_buyers_minor` | Cashback earned by buyers (recommendation bounties) |
| `to_recommenders_minor` | Cashback earned by recommenders |

---

# Sales

Read your checkouts and charges. Sales endpoints accept an API key (own org) or an org member session.

## List

```bash
curl -s "https://api.fluense.social/orgs/$ORG/sales?statuses=CAPTURED,AUTHORIZED&limit=50" \
  -H "x-api-key: $KEY"
```

```json
{
  "sales": [
    {
      "id": "019a…",
      "terminal_id": "019a…",
      "terminal_title": "Front counter",
      "total_minor": 9700,
      "currency": "USD",
      "cashback_minor": 445,
      "status": "CAPTURED",
      "created_at": "2026-10-08T09:12:00Z"
    }
  ],
  "next_cursor": "019a…"
}
```

| Query | Notes |
| --- | --- |
| `status` | Single status filter |
| `statuses` | Comma-separated list (`PENDING`, `AUTHORIZED`, `CAPTURED`, `VOIDED`, `EXPIRED`, `SUPERSEDED`) |
| `limit` | 1–200, default 50 |
| `cursor` | From `next_cursor`; null ends the stream |

## Detail

`GET /sales/{id}` (session or key with org access) adds the cart and authorization.

```json
{
  "id": "019a…",
  "terminal_title": "Front counter",
  "items": "…",
  "currency": "USD",
  "total_minor": 9700,
  "cashback_minor": 445,
  "status": "CAPTURED",
  "auth": { "id": "019a…", "status": "CAPTURED" }
}
```

`cashback_minor` is the total pot for the cart (`total_cashback` at checkout time).

---

# Payouts

Move your merchant balance to your bank account.

## Create

```bash
curl -s -X POST https://api.fluense.social/payouts \
  -H "x-api-key: $KEY" \
  -H 'content-type: application/json' \
  -d '{"amount_minor":250000,"currency":"USD","country_code":"US"}'
```

```json
{ "instruction_id": "019a…" }
```

Callable with an API key (the org's balance) or a session whose role is **owner, admin or finance**. Live payouts require an approved KYB.

Gates applied before money moves:

- transaction monitoring (velocity, structuring, amount rules),
- payee sanctions screening on the destination account,
- available merchant balance.

Blocked payouts return `403` with a reason (`blocked by monitoring`, `payee screening blocked`, …).

## List

`GET /payouts` (session) returns the org's payout instructions:

```json
{
  "payouts": [
    { "id": "019a…", "provider": "column_us", "amount_minor": 250000, "currency": "USD", "status": "PENDING" }
  ]
}
```

Statuses move from `PENDING` to settled as the banking partner confirms; returned payouts are returned to the wallet.

## Destinations

Register the bank destination before your first payout.

```bash
# IBAN destinations (UK/EU)
curl -s -X POST https://api.fluense.social/bank/payout-iban \
  -H "authorization: Bearer $SESSION" \
  -H 'content-type: application/json' \
  -d '{"iban":"GB33BUKB20201555555555","name":"Acme Goods Ltd","org_id":"'$ORG'"}'

# Registered counterparties
curl -s https://api.fluense.social/bank/counterparties -H "authorization: Bearer $SESSION"
```

`force` + `force_reason` override a name-match mismatch (recorded for compliance); without it a mismatch is rejected with the registered name.

---

# Webhooks

Fluense sends signed webhooks for checkout and charge events. Configure one URL per organization.

## Configure

```bash
# Set or replace the URL (owner/admin). The signing secret is generated once and reused.
curl -s -X PUT https://api.fluense.social/orgs/$ORG/webhook \
  -H "authorization: Bearer $SESSION" \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com/fluense/webhook"}'

# Inspect
curl -s https://api.fluense.social/orgs/$ORG/webhook -H "authorization: Bearer $SESSION"

# Clear
curl -s -X DELETE https://api.fluense.social/orgs/$ORG/webhook -H "authorization: Bearer $SESSION"
```

`PUT` returns the secret (`whsec_…`); `GET` returns `{url, has_secret}` without exposing it.

## Events

| Event | Fires when | Payload |
| --- | --- | --- |
| `checkout.authorized` | A shopper authorizes a checkout | `checkout_id`, `auth_id`, `total_minor`, `currency`, `consumer_id` |
| `charge.captured` | You capture a charge | `checkout_id`, `auth_id`, `mdr_fee`, `buyer_amount`, `deferred_buyer_amount`, `recommender_amount` |
| `charge.voided` | An authorization is voided | `auth_id` |
| `charge.returned` | A refund is processed | `auth_id`, `return_id`, `refund`, `buyer_cut`, `rec_cut`, `award_adjusted` |

## Delivery

Every delivery carries:

| Header | Value |
| --- | --- |
| `x-fluense-event` | Event name, e.g. `charge.captured` |
| `x-fluense-delivery` | Unique delivery id — dedupe on this across retries |
| `x-fluense-signature` | `sha256=<hex HMAC-SHA256 of the raw body with your secret>` |

Any `2xx` marks the delivery delivered. Failures retry with backoff: **60 s, 5 min, 15 min, 1 h, 6 h**, then the delivery is marked `FAILED` (5 attempts).

## Verify a signature

```js
// Node.js
import crypto from 'node:crypto'

const expected = 'sha256=' + crypto
  .createHmac('sha256', process.env.FLUENSE_WEBHOOK_SECRET)
  .update(rawBody) // the exact bytes received, before JSON parsing
  .digest('hex')

if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
  // trusted
}
```

```python
# Python
import hashlib, hmac

expected = 'sha256=' + hmac.new(
    secret.encode(), raw_body, hashlib.sha256
).hexdigest()

if hmac.compare_digest(expected, signature):
    pass  # trusted
```

Always verify against the **raw request body**; re-serializing JSON changes the bytes.

---

# Team & roles

Organizations hold your team, keys, terminals and money. Roles are ranked **owner > admin > finance > support > developer** — see [Authentication](/authentication) for the capability matrix.

## Organizations

```bash
# Organizations you belong to, with your role
curl -s https://api.fluense.social/orgs -H "authorization: Bearer $SESSION"

# Create another org
curl -s https://api.fluense.social/orgs \
  -H "authorization: Bearer $SESSION" -H 'content-type: application/json' \
  -d '{"name":"Acme EU","country_code":"IE"}'

# Read / rename
curl -s https://api.fluense.social/orgs/$ORG -H "authorization: Bearer $SESSION"
curl -s -X PATCH https://api.fluense.social/orgs/$ORG \
  -H "authorization: Bearer $SESSION" -H 'content-type: application/json' \
  -d '{"name":"Acme Goods Ltd"}'
```

## Members

```bash
curl -s https://api.fluense.social/orgs/$ORG/members -H "authorization: Bearer $SESSION"

# Add an existing user
curl -s -X POST https://api.fluense.social/orgs/$ORG/members \
  -H "authorization: Bearer $SESSION" -H 'content-type: application/json' \
  -d '{"user_id":"019a…","role":"finance"}'

# Change role / remove
curl -s -X PATCH https://api.fluense.social/orgs/$ORG/members/$USER \
  -H "authorization: Bearer $SESSION" -H 'content-type: application/json' -d '{"role":"support"}'
curl -s -X DELETE https://api.fluense.social/orgs/$ORG/members/$USER -H "authorization: Bearer $SESSION"
```

Member management requires owner or admin.

## Invites

Invite by email; the invite is claimed automatically when that email signs in with an OTP (email ownership is proven by the code).

```bash
curl -s https://api.fluense.social/orgs/$ORG/invites \
  -H "authorization: Bearer $SESSION" -H 'content-type: application/json' \
  -d '{"email":"dev@acme.test","role":"developer"}'

curl -s https://api.fluense.social/orgs/$ORG/invites -H "authorization: Bearer $SESSION"
curl -s -X DELETE https://api.fluense.social/orgs/$ORG/invites/$INVITE -H "authorization: Bearer $SESSION"
```

Invites are `PENDING` until claimed or revoked. Re-inviting the same email updates the pending invite's role.

---

# Data models

Every object the API returns, with field names, types and notes. Conventions used throughout:

| Convention | Description |
| --- | --- |
| Amounts | Integer **minor units** with an ISO-4217 `currency` (9700 with `USD` = $97.00). Amounts never mix currencies in one operation |
| Ids | Opaque UUID strings — treat them as case-sensitive and stable |
| Timestamps | RFC 3339 UTC strings (`2026-10-08T09:12:00Z`) |
| Pagination | `limit` + `cursor` request fields; responses carry `next_cursor` (null when there is no next page) |
| Errors | `{"error": "reason"}` with the HTTP status carrying the class — see [Errors & idempotency](/errors) |

## Organization

Returned by `GET /orgs`, `GET /orgs/{id}` and org creation.

| Field | Type | Notes |
| --- | --- | --- |
| `org_id` | string | Organization id |
| `name` | string | Business name |
| `country_code` | string | ISO-3166 alpha-2; sets the default currency and payout rails |
| `status` | string | `ACTIVE` for a usable organization |
| `role` | string | The caller's role in this org (`GET /orgs` only) |

## Member

Returned by `GET /orgs/{id}/members`.

| Field | Type | Notes |
| --- | --- | --- |
| `user_id` | string | User id |
| `email` | string | Sign-in email |
| `first_name`, `last_name` | string | Display name |
| `role` | string | `owner`, `admin`, `finance`, `support` or `developer` |
| `status` | string | `ACTIVE` once the user has joined |

## Invite

Returned by `GET /orgs/{id}/invites`.

| Field | Type | Notes |
| --- | --- | --- |
| `invite_id` | string | Invite id, used to revoke |
| `email` | string | Invited address |
| `role` | string | Role granted on join |
| `status` | string | `PENDING` until claimed or revoked |

## API key

Created by `POST /apikeys` (raw key returned once), listed by `GET /orgs/{id}/keys`.

| Field | Type | Notes |
| --- | --- | --- |
| `key` | string | The raw key (`fl_test_…` / `fl_live_…`) — **create response only**, shown once |
| `id` / `key_id` | string | Key id, used to revoke |
| `prefix` | string | First characters of the key, safe to display |
| `name` | string | Label you gave the key |
| `env` | string | `sandbox` or `live` |
| `status` | string | `ACTIVE` or revoked |

## Terminal

Returned by `GET /terminals` and terminal creation.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Terminal id |
| `title` | string | Display name |
| `terminal_type` | string | `REUSABLE` (permanent terminal id — present as QR, NFC or link) or `USE_ONCE` (auto-created per checkout) |
| `status` | string | `ACTIVE` or `INACTIVE` |
| `created_at` | timestamp | Creation time |

## Checkout item

One line in a checkout or sale.

| Field | Type | Notes |
| --- | --- | --- |
| `product_id` | string | Your SKU or product id |
| `display_name` | string | Human-readable name |
| `unit_price` | amount | Price per unit in minor units |
| `qty` | integer | Quantity, greater than 0 |
| `cashback_rate` | integer | Cashback in basis points (500 = 5%, max 10000) |
| `item_image_url` | string | Optional image shown to the shopper |
| `item_url` | string | Optional product link shown to the shopper |

## Checkout

Returned by checkout creation (`{id, terminal_id}`), by `GET /terminals/{id}/checkout` and inside sale details.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Checkout id — the value used for authorize and capture |
| `terminal_id` | string | Terminal this checkout belongs to |
| `merchant_id` | string | Your organization id |
| `items` | array | [Checkout items](#checkout-item) |
| `currency` | string | ISO-4217 |
| `shipping_amount`, `tax_amount` | amount | As sent at creation |
| `total` | amount | `sum(unit_price x qty) + shipping_amount + tax_amount` |
| `total_cashback` | amount | The cashback pot for this cart (see [Cashback](/cashback)) |
| `order_id` | string | Your reference, echoed back |
| `status` | string | `PENDING`, `AUTHORIZED`, `CAPTURED`, `VOIDED`, `EXPIRED` or `SUPERSEDED` |
| `expires_at` | timestamp | When an unpaid checkout stops being payable |
| `created_at` | timestamp | Creation time |

## Sale

List rows come from `GET /orgs/{id}/sales`; `GET /sales/{id}` adds the cart and authorization.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Checkout id |
| `terminal_id`, `terminal_title` | string | Where the sale was made |
| `total_minor` | amount | Gross amount |
| `currency` | string | ISO-4217 |
| `cashback_minor` | amount | Cashback pot funded by this sale |
| `status` | string | Checkout status (see above) |
| `created_at` | timestamp | Creation time |
| `auth` | object | Detail only: `{id, status}` of the charge authorization |
| `items` | array | Detail only: [Checkout items](#checkout-item) |

## Charge capture result

Returned by `POST /charges/{id}/capture`.

| Field | Type | Notes |
| --- | --- | --- |
| `journal_id` | string | Transaction reference for this capture — stable across retries |
| `mdr_fee` | amount | Your processing fee for this charge |
| `recommender_amount` | amount | Cashback earned by the recommender (0 when the purchase did not come from a recommendation) |
| `buyer_amount` | amount | Always 0 at capture — the buyer's share is deferred |
| `deferred_buyer_amount` | amount | Cashback the buyer earns by recommending what they bought |
| `recommender_id` | string | Primary recommender's user id, when present |
| `recommenders` | array | One entry per recommender: `{user_id, amount}` |

## Return result

Returned by `POST /charges/{id}/return`.

| Field | Type | Notes |
| --- | --- | --- |
| `return_id` | string | Refund reference |
| `refund` | amount | Principal returned to the shopper (goods + pro-rata shipping and tax) |
| `buyer_cut` | amount | Pending buyer cashback removed by this return |
| `rec_cut` | amount | Pending recommender cashback removed by this return |
| `award_adjusted` | boolean | True when pending cashback was reduced or voided |

## Cashback summary

Returned by `GET /orgs/{id}/cashback/summary` — one entry per currency.

| Field | Type | Notes |
| --- | --- | --- |
| `currency` | string | ISO-4217 |
| `captured_count` | integer | Number of captured charges |
| `captured_minor` | amount | Gross volume captured |
| `funded_minor` | amount | Cashback funded by you |
| `social_count` | integer | Captures that came from a recommendation |
| `to_buyers_minor` | amount | Cashback earned by buyers |
| `to_recommenders_minor` | amount | Cashback earned by recommenders |

## Webhook payload

Every delivery carries the event name, a unique delivery id and an HMAC signature in headers (see [Webhooks](/webhooks)). The JSON body depends on the event:

| Event | Body fields |
| --- | --- |
| `checkout.authorized` | `checkout_id`, `auth_id`, `total_minor`, `currency`, `consumer_id` |
| `charge.captured` | `checkout_id`, `auth_id`, `mdr_fee`, `buyer_amount`, `deferred_buyer_amount`, `recommender_amount` |
| `charge.voided` | `auth_id` |
| `charge.returned` | `auth_id`, `return_id`, `refund`, `buyer_cut`, `rec_cut`, `award_adjusted` |

## Payout

Created by `POST /payouts` (returns `instruction_id`); listed by `GET /payouts`.

| Field | Type | Notes |
| --- | --- | --- |
| `instruction_id` / `id` | string | Payout reference |
| `provider` | string | Banking partner handling the transfer |
| `amount_minor` | amount | Amount sent |
| `currency` | string | ISO-4217 |
| `status` | string | `PENDING` while the banking partner processes, then settled |

---

# Errors & idempotency

Errors are JSON with a human-readable reason and the HTTP status carries the class:

```json
{ "error": "total mismatch: server-computed 9700" }
```

| Status | Meaning | Examples |
| --- | --- | --- |
| 400 | Validation or state error | `total mismatch`, `bad cashback_rate`, `auth not capturable`, `auth expired`, `return qty exceeds captured` |
| 401 | Missing or invalid credentials | `missing x-api-key`, `invalid or expired session` |
| 403 | Permission, environment or gate | `owner/admin required`, `key env mismatch`, `merchant KYB not approved`, `blocked by monitoring` |
| 404 | Unknown resource | `charge not found`, `no active checkout on this terminal` |
| 500 | Unexpected server error | Retry later; capture retries are safe |

## Idempotency

- **Capture is idempotent** on the charge id. Retrying `POST /charges/{id}/capture` returns the same transaction reference and cashback — it never charges twice. Safe to retry on network failure.
- **Void is state-guarded**: only `AUTHORIZED` charges can be voided; a repeat is a `400`.
- **Returns are not idempotent.** Each call records a new return. Retry only when the previous attempt produced no response, and check the charge's captured/returned quantities before retrying.
- **Checkout creation is not idempotent.** A retry creates a new checkout and supersedes the previous `PENDING` checkout on the same reusable terminal. Carry your own `order_id` for reconciliation.
- **Webhooks**: dedupe on the `x-fluense-delivery` header (unique per delivery, stable across retries).

## Conventions

- Money is always integer **minor units** (cents) with an ISO-4217 `currency` field.
- Amounts never cross currencies in one operation.
- Timestamps are RFC 3339 UTC strings.
- Pagination uses `limit` plus a `cursor`; responses carry `next_cursor` (null when done).
