Deliora API, as shipped in the download
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.
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
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
{
"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.
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}'
{ "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:
$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.
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.
// 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.
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:
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):
{
"type": "mcp",
"server_label": "deliora",
"server_url": "https://store.example.com/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
**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 (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"*. Pasted as-is, a block written for another client 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:
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
- 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.
- Copy that URL into Deliora under Admin → API & webhooks, and choose the event or events below.
- 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.