Home›Deliora›API
Deliora · API

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

MethodPathNotes
GET/api/pingVerify 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/customersBuyers with order counts
GET/api/licenses`?status=active\revoked · ?sku=<slug>`
GET/api/redeem-codesBatch summary: [{batch, status, n}]
POST/api/redeem-codesMint codes for a deal site (below)
GET/api/statsStore totals in one call
GET/api/openapi.jsonOpenAPI 3.0.3 — imports as a Power Automate / Zapier custom connector
GET/healthzUnauthenticated {"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:

EventFires when
order.paidA payment is confirmed (any provider)
order.refundedA refund is processed
code.redeemedA deal code is exchanged for a license
license.activatedA license key is activated on an install
license.deactivatedA buyer's app frees a domain's slot (3.2.0)
order.flaggedSeveral 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

StatusMeaning
400Body is not a JSON object
401Missing, malformed or revoked key
404No such order / license
422A 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.

EndpointSendAnswers
POST /license/checkkey, skuvalid, tier, activations_left
POST /license/activatekey, sku, domainthe 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, domainactivations_left, deactivations_left
POST /license/offline (3.2.0)key, sku, domaindocument, signature, public_key, expires_at
GET /license/public-key (3.2.0)nothingalg (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":{}}}'
ToolWrites?What it does
list_ordersnoOrders newest first (status/provider filters)
get_ordernoOne order by reference, items included
list_customersnoCustomers with paid-order counts
list_licensesnoLicenses (optional sku filter)
revenue_statsnoRevenue, orders, customers, active licenses, unredeemed codes
mint_redeem_codesyesMints 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 addresshttps://store.example.com/mcp
The keyheader Authorization: Bearer apk_xxxx
The transportMCP 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)

EndpointGuardWhat 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 sessionAccountant-ready monthly statement PDF
GET /admin/orders/{id}/receipt.pdfstaff sessionOrder receipt PDF
GET /portal/me/receipt/{id}buyer portal sessionThe customer's own receipt
GET /admin/customers/{id}/gdpr.jsonadmin sessionSubject-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.
EventFires when
order.paidA payment is confirmed (any provider)
order.refundedA refund is processed
code.redeemedA deal code is exchanged for a license
license.activatedA license key is activated on an install
license.deactivatedA buyer's app frees a domain's slot (3.2.0)
order.flaggedSeveral 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.

← Back to Deliora · Manual · Quickstart · Test run