# Deliora API (v2.0)

Deliora's REST API is the store's own read model: the orders, customers and licenses it has
already fulfilled, plus the two write operations a shop actually automates — minting redemption
codes for a deal site, and receiving signed webhooks when money moves. Every number it returns is
computed by the same code the admin pages use, so a nightly report and the dashboard cannot
disagree.

## Authentication

Create a key in **Admin → API & webhooks** (`/admin/api-settings`). It is shown once.

```
Authorization: Bearer apk_<40 hex chars>
```

A key acts as the user it belongs to. `/api/*` is CSRF-exempt by design: the key is the
credential, and browsers never send it implicitly.

```bash
curl -H "Authorization: Bearer apk_xxxx" https://store.example.com/api/ping
# {"ok":true,"user":"Admin","app":"deliora","version":"2.0.0"}
```

An absent or revoked key returns `401 {"error":"unauthorized"}`. Nothing else is exposed
unauthenticated except `/healthz`.

## Endpoints

| Method | Path | Notes |
|---|---|---|
| GET | `/api/ping` | Verify a key |
| GET | `/api/orders` | `?status=paid\|refunded\|pending` · `?provider=lemonsqueezy\|stripe\|demo\|redeem` |
| GET | `/api/orders/{ref}` | One order by its reference, with customer + items |
| GET | `/api/customers` | Buyers with order counts |
| GET | `/api/licenses` | `?status=active\|revoked` · `?sku=<slug>` |
| GET | `/api/redeem-codes` | Batch summary: `[{batch, status, n}]` |
| POST | `/api/redeem-codes` | Mint codes for a deal site (below) |
| GET | `/api/stats` | Store totals in one call |
| GET | `/api/openapi.json` | OpenAPI 3.0.3 — imports as a Power Automate / Zapier custom connector |
| GET | `/healthz` | Unauthenticated `{"ok":true,"app":"deliora","version":"2.0.0"}` |

### Orders

```bash
curl -H "Authorization: Bearer apk_xxxx" 'https://store.example.com/api/orders?status=paid'
```

Each order carries `order_ref, provider, status, amount_cents, currency, created_at, customer,
items`. Money is always in **cents as an integer** — never a float, so nothing rounds in transit.

### Stats

```json
{
  "revenue_cents": 99400,
  "orders_paid": 10,
  "orders_refunded": 1,
  "customers": 10,
  "licenses_active": 13,
  "codes_unredeemed": 0,
  "notify_confirmed": 0
}
```

`revenue_cents` counts paid orders and excludes refunds — the same rule the dashboard applies.

### Minting redemption codes

For AppSumo-style deals: mint a batch, upload the CSV to the deal site, and let buyers redeem at
`/redeem`. Codes are `AS-XXXX-XXXX-XXXX` from an alphabet with no `0/O/1/I/L`, so a code read
aloud over the phone cannot be mistyped.

```bash
curl -X POST https://store.example.com/api/redeem-codes \
  -H "Authorization: Bearer apk_xxxx" -H 'Content-Type: application/json' \
  -d '{"sku":"bookingkit","edition":"single","count":2}'
```

```json
{ "batch": "api-20260806", "count": 2,
  "codes": ["AS-YNTJ-85CA-XV4A", "AS-XZ93-HACM-GNQ3"] }
```

The edition must exist for that SKU or the request is refused — a code that cannot be fulfilled is
worse than no code. Redemption itself is race-proof: the claim is a single
`UPDATE … WHERE status='new'`, so two people submitting the same code at the same instant cannot
both receive a license, and a buyer re-submitting their own code gets the same order back rather
than a second one.

## Webhooks

Add a receiver in **Admin → API & webhooks**. Events:

| Event | Fires when |
|---|---|
| `order.paid` | A payment is confirmed (any provider) |
| `order.refunded` | A refund is processed |
| `code.redeemed` | A deal code is exchanged for a license |
| `license.activated` | A license key is activated on an install |
| `license.deactivated` | A buyer's app frees a domain's slot (3.2.0) |
| `order.flagged` | Several different buyers placed cheap orders in a short time (3.2.0): nothing is refunded or blocked |

Each delivery carries the event name and an HMAC of the raw body:

```
X-Deliora-Event: order.paid
X-Deliora-Signature: <hex hmac-sha256 of the exact body, keyed with your webhook secret>
```

Verify it before trusting the payload — recompute over the **raw** body, not a re-encoded copy:

```php
$raw  = file_get_contents('php://input');
$mine = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($mine, $_SERVER['HTTP_X_DELIORA_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
```

One attempt per delivery with a 5-second timeout: Deliora will not retry, because a store that
retries a payment notification into a slow endpoint eventually sends it twice. Failed deliveries
are listed in **Admin → Outbox** for you to replay deliberately.

## Errors

| Status | Meaning |
|---|---|
| 400 | Body is not a JSON object |
| 401 | Missing, malformed or revoked key |
| 404 | No such order / license |
| 422 | A field is wrong — the message names it |

A field given as a list or an object where a single value belongs is refused with
`422 {"error":"validation","detail":"<field> must be a single text or number value, not a list or
object."}` rather than being coerced. This is deliberate: silently storing the string `Array`
would corrupt your records in a way no error message would ever reveal.

## What the API deliberately does not do

- **No order creation.** Orders come from a payment provider's webhook or a redeemed code —
  never from a bare API call, so revenue always traces back to a payment or a deal code.
- **No secrets.** SMTP passwords, provider webhook secrets, password hashes and key hashes are
  never serialized, including in the admin JSON backup.

# License API — for the apps you sell

Your own apps call these to check the key a buyer typed in. They are separate from the REST API
above: no bearer key, because the **license key is the credential**. Send JSON
(`Content-Type: application/json`) or a form post. All of them share one limit of 60 calls per
10 minutes per IP address (then `429 {"valid":false,"reason":"rate_limited"}`), and every answer
carries a `reason`, which is `""` on success.

| Endpoint | Send | Answers |
|---|---|---|
| `POST /license/check` | `key`, `sku` | `valid`, `tier`, `activations_left` |
| `POST /license/activate` | `key`, `sku`, `domain` | the same; activating a domain twice uses one slot |
| `POST /license/update` *(3.2.0)* | `key`, `sku`, `version` (optional), `domain` (optional) | `latest_version`, `update_available`, `download_url`, `expires_at` |
| `POST /license/deactivate` *(3.2.0)* | `key`, `sku`, `domain` | `activations_left`, `deactivations_left` |
| `POST /license/offline` *(3.2.0)* | `key`, `sku`, `domain` | `document`, `signature`, `public_key`, `expires_at` |
| `GET /license/public-key` *(3.2.0)* | nothing | `alg` (`Ed25519`), `public_key` (base64) |

Reasons a call is refused: `invalid_key`, `sku_mismatch`, `revoked` (a refund revokes the license),
`invalid_domain`, `activation_limit`, `not_activated`, `deactivation_limit`, `deactivation_off`,
`offline_off`, `offline_unavailable`.

**Updates.** Send the version the app runs. When the edition in the store is newer,
`update_available` is `true` and `download_url` is a download link for the new build (the order's
current link while it still has uses, otherwise a fresh one with the store's usual expiry and use
cap). Leave `version` out to get the latest build whatever is installed. Send `domain` too and the
key only works from a domain it is activated on. An edition with no file answers
`reason: "no_download"` and no link.

```bash
curl -s -X POST https://store.example.com/license/update -H 'Content-Type: application/json' \
  -d '{"key":"OWX-…","sku":"my-app","version":"1.4.0","domain":"customer-site.example"}'
# {"valid":true,"latest_version":"1.5.0","update_available":true,
#  "download_url":"https://store.example.com/d/9f2c…","expires_at":"2026-10-01 09:00:00","reason":""}
```

**Moving a site.** `POST /license/deactivate` removes one domain's activation and gives the slot
back, so a buyer can move without writing to you. Each license can do this a set number of times in
any 30 days (**Admin → Selling tools**, 3 by default; 0 turns self-service off). Removing an
activation yourself in **Admin → Licenses** does not count toward it. Fires `license.deactivated`.

**Working offline.** `POST /license/offline` returns a license document for a domain the key is
already activated on, signed with the store's Ed25519 key and valid for a set number of days
(30 by default). Your app keeps it and checks it with the public key alone, so it keeps working
when it cannot reach the store, and fetches a fresh one before it expires. `document` is the exact
string that was signed; parse it only after the signature checks out.

```php
// Ship the store's public key with your app (GET /license/public-key, once).
$ok  = sodium_crypto_sign_verify_detached(base64_decode($r['signature']), $r['document'], base64_decode($publicKey));
$doc = $ok ? json_decode($r['document'], true) : null;
if (!$doc || $doc['domain'] !== $thisDomain || strtotime($doc['expires_at']) < time()) { /* not licensed */ }
// $doc: {"v":1,"key":"OWX-…","sku":"my-app","tier":"single","domain":"…","issued_at":"…Z","expires_at":"…Z"}
```

The signing key is derived from `license_secret` in `config.php`: the public key stays the same as
long as that secret does. An install without a `license_secret` answers `offline_unavailable`.

# MCP — the agent door (new in 3.0)

Deliora speaks the **Model Context Protocol** at `POST /mcp`, so Claude, ChatGPT agents, n8n's
AI nodes or your own code can *use* your store instead of merely reading it. Same bearer key,
same roles: a support key sees; only an admin key can mint codes.

```bash
curl -s -X POST https://store.example.com/mcp \
  -H "Authorization: Bearer apk_xxxx" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"revenue_stats","arguments":{}}}'
```

| Tool | Writes? | What it does |
|---|---|---|
| `list_orders` | no | Orders newest first (status/provider filters) |
| `get_order` | no | One order by reference, items included |
| `list_customers` | no | Customers with paid-order counts |
| `list_licenses` | no | Licenses (optional sku filter) |
| `revenue_stats` | no | Revenue, orders, customers, active licenses, unredeemed codes |
| `mint_redeem_codes` | **yes** | Mints deal codes — **admin keys only; codes are worth money** |

Deliberately absent: refunds (they live at the payment processor), product/price editing (the
catalog stays a human decision), and any tool that emails a customer.

## Connect it to an assistant

Mint the key in the app first: **Settings → API keys**. Choose **Read only** when the assistant should
answer questions but never change anything — the endpoint then lists only the read tools and refuses the
rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

| | |
|---|---|
| **The address** | `https://store.example.com/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

**Claude** — one command, or the same URL and header as a custom connector in the desktop and web apps:

```sh
claude mcp add --transport http deliora https://store.example.com/mcp \
  --header "Authorization: Bearer apk_xxxx"
```

**ChatGPT and the OpenAI API** — one entry in the Responses API's `tools` array (in ChatGPT itself, the
same URL and key go in as a connector):

```json
{
  "type": "mcp",
  "server_label": "deliora",
  "server_url": "https://store.example.com/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**Own Your AI** reads a list of servers in this shape:

```json
{
  "mcpServers": [
    { "id": "deliora", "name": "Deliora", "url": "https://store.example.com/mcp",
      "token": "apk_xxxx", "enabled": true }
  ]
}
```

**Every other client spells the same three facts differently — copy the shape from its own
documentation, not from here.** VS Code is the clearest example of why: its
[configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"* — not an `mcpServers` array. Pasted as-is,
the block above will not load there. The id, the URL and the token are what travel; the JSON around
them belongs to whichever client you are configuring.

**A local model, n8n, or your own code** — n8n's **MCP Client** node takes the URL and the same
`Authorization: Bearer` header; a model running on your own machine reaches it through any MCP client, so
nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

```sh
curl -X POST https://store.example.com/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet:
Deliora implements the protocol, not an integration with a particular vendor.

# Operational endpoints (new in 3.0)

| Endpoint | Guard | What it does |
|---|---|---|
| `GET /backup/scheduled?t=<token>` | ops token (Admin → Security) | Nightly redacted JSON into `data/backups/`, keeps 14 |
| `GET /admin/statement/{YYYY-MM}` | admin session | Accountant-ready monthly statement PDF |
| `GET /admin/orders/{id}/receipt.pdf` | staff session | Order receipt PDF |
| `GET /portal/me/receipt/{id}` | buyer portal session | The customer's own receipt |
| `GET /admin/customers/{id}/gdpr.json` | admin session | Subject-access export |


## Connect it to Zapier, Make or n8n

Deliora talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Deliora, open **Admin → API & webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://deliora.yourcompany.com`.

### When something happens in Deliora: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Deliora under **Admin → API & webhooks**, and choose the event or events below.
3. Make the event happen once in Deliora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `order.paid` | A payment is confirmed (any provider) |
| `order.refunded` | A refund is processed |
| `code.redeemed` | A deal code is exchanged for a license |
| `license.activated` | A license key is activated on an install |
| `license.deactivated` | A buyer's app frees a domain's slot (3.2.0) |
| `order.flagged` | Several different buyers placed cheap orders in a short time (3.2.0): nothing is refunded or blocked |

Every delivery carries `X-Deliora-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Deliora

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/redeem-codes`: Mint codes for a deal site (below)

### Example recipes

- **order.paid → a row in Google Sheets:** webhook trigger on `order.paid` (A payment is confirmed (any provider)), then the Google Sheets "Create row" action with the fields you want.
- **order.refunded → a message in Slack or Teams:** webhook trigger on `order.refunded` (A refund is processed), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Deliora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/redeem-codes` (Mint codes for a deal site (below)).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Deliora's own automated tests check its API.
