Loyalora API, as shipped in the download
Loyalora REST API, Webhooks & MCP (1.0)
Loyalora ships a JSON REST API, signed webhooks and an MCP endpoint, so the register can answer to whatever else you run — a till, a booking system, a workflow tool, or an assistant.
Everything below reads the same engine the screens read. **A balance is the sum of a customer's ledger entries and is stored nowhere**, so no response here can disagree with the card the customer is looking at.
Authentication
Authorization: Bearer apk_xxxx
Mint keys in Settings → API & Webhooks. A key belongs to a user and can do exactly what that user can do in the browser — the same four-rung ladder applies (viewer · clerk · manager · admin). Every write checks the key holder's role and answers 403 forbidden when the role does not allow it (1.1.0; before, any valid key could write). Keys are stored as SHA-256 hashes and shown once.
Access: a key is created Read only or Read and write. A read-only key is refused on every non-GET request at the door, before any handler runs, with 403 forbidden.
Endpoints
| Method | Path | What it does | |
|---|---|---|---|
GET | /api/ping | Verify the key; returns the user and the key's scope | |
GET | /api/customers | List customers with balances. ?q= (name, email, phone or card code), `?active=0\ | 1` |
POST | /api/customers | Add a customer (a manager's or admin's key) | |
GET | /api/customers/{id} | One customer: balance, per-program split, recent ledger | |
POST | /api/customers/{id}/earn | Record activity and earn what it is worth (clerk and above) | |
POST | /api/customers/{id}/redeem | Redeem a reward (clerk and above) | |
POST | /api/sales | A till's sale by card code: card (or customer_id), amount or amount_cents, ref (the till's sale id, required), optional program_id. The program's rate decides the points; the same ref again records nothing and answers 200 with "duplicate": true (clerk and above) | |
POST | /api/sales/redeem | Redeem at the till (1.2): card (or customer_id), reward_id, ref (the till's reference, required), optional location. 201 with the reward, cost and balance; the same ref again → 200 "duplicate": true, nothing spent; a short balance → 409 with short (points missing); an unknown card → 404 (clerk and above, entry.redeem) | |
POST | /api/sales/refund | A refund: ref (the sale's reference). Takes back what that sale earned, once; never below zero. 201 with reversed and already_spent; again → 200 "duplicate": true; an unknown sale → 404 (clerk and above) | |
GET | /api/rewards | Rewards and what they cost | |
GET | /api/openapi.json | OpenAPI 3.0 spec (imports as a custom connector) | |
GET | /healthz | Unauthenticated liveness probe |
Earning: send what HAPPENED, not points
curl -X POST https://loyalty.example.com/api/customers/12/earn \
-H "Authorization: Bearer apk_xxxx" -H 'Content-Type: application/json' \
-d '{"program_id": 1, "amount": "24.99"}'
{ "ok": true, "entry_id": 4471, "points": 24, "program_id": 1, "balance": 962 }
Send amount (a decimal string) or amount_cents for a points-per-spend program, or visits for a stamps program. Optional (1.1): items, such as {"Coffee": 2}, adds the program's bonus per item bought; location records the shop (an id from Settings → Shops; an unknown or closed one is refused with 422). /api/sales and /redeem take location too. A VIP tier multiplies the total, and points in the reply is what was recorded. There is deliberately no field that sets a points figure. The program's rate decides, and it rounds down. A spend too small to earn anything comes back 422 with the rate quoted — nothing is recorded, because a ledger full of zero-point rows teaches nobody anything.
Redeeming
curl -X POST https://loyalty.example.com/api/customers/12/redeem \
-H "Authorization: Bearer apk_xxxx" -H 'Content-Type: application/json' \
-d '{"reward_id": 3}'
A short balance comes back 409 conflict, not 422: the request was well-formed and the register said no. Retrying after the customer earns more is the right thing to do, and the status says so. The balance check and the write happen inside one lock, so it is safe to call this while somebody is redeeming at the counter — one of you wins, and it is never both.
Webhooks
POSTed as JSON on each subscribed event, signed:
X-Loyalora-Event: reward.redeemed
X-Loyalora-Signature: sha256=<hmac_sha256(body, secret)>
| Event | Fires when |
|---|---|
customer.created | a customer is added, from any door |
points.earned | activity is recorded and points are credited |
reward.redeemed | a reward is redeemed |
One delivery attempt with a short timeout, logged either way. Build idempotent receivers.
Limits, honestly
- The API acts at user level; scope is read-only or full, on top of the app's roles.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
GET /api/customerscaps at 200 rows.- There is no endpoint that emails anybody, and no bulk anything. Loyalora has no campaign surface; see the README.
- Nothing can set a balance, over the API or anywhere else. The only ways it moves are earn, redeem and adjust — and adjust is manager-only and requires a reason.
MCP — the agent endpoint
Loyalora speaks the Model Context Protocol at POST /mcp, so Claude, ChatGPT agents, n8n's AI nodes or your own code can use the register instead of merely reading it. Same bearer key, same roles, same engine. An agent cannot do anything its key's user could not do in the browser — and it cannot invent points, because nothing can.
Transport is streamable HTTP, stateless: one JSON-RPC 2.0 request in, one JSON response out.
curl -s -X POST https://loyalty.example.com/mcp \
-H "Authorization: Bearer apk_xxxx" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"my-agent","version":"1"}}}'
Then tools/list to discover, tools/call to act:
curl -s -X POST https://loyalty.example.com/mcp \
-H "Authorization: Bearer apk_xxxx" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"customer_card","arguments":{"email":"mira@example.com"}}}'
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://loyalty.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 loyalora https://loyalty.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": "loyalora",
"server_url": "https://loyalty.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://loyalty.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: Loyalora implements the protocol, not an integration with a particular vendor.
Tools
| Tool | Writes? | What it does |
|---|---|---|
find_customer | no | Find people by name, email or phone, with balances |
customer_card | no | One card in full: balance per program, what they can afford, what the next reward still needs, and the ledger |
list_rewards | no | Rewards and what they cost |
list_programs | no | The programs, how each earns, and what each owes |
outstanding_liability | no | Points issued, redeemed and outstanding, overall and per program |
record_activity | yes | Record a spend or a visit; the program's rate decides the points. Optional items (bonus per item, 1.1) and location (the shop, 1.1); a VIP tier applies |
redeem_reward | yes | Redeem a reward; refused when the balance is short |
There is deliberately no adjust tool, no delete tool and no send-mail tool. Creating points from nothing, destroying a record and writing to a customer all stay human clicks in the app. An agent can tell you who is one coffee away and record what happened at the counter — it cannot mint points or email your customers.
What an agent must not assume
- You cannot set a balance. There is no field for it anywhere. Record what happened instead.
- A refusal is the register working. "Not enough points" is the correct answer to a redemption the customer cannot afford; report it, do not retry with a different reward unless asked.
- The ledger is the truth. If a balance looks wrong,
customer_cardreturns the history that produced it — read that rather than assuming the number is stale.
Connect it to Zapier, Make or n8n
Loyalora 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 Loyalora, open Settings → 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://loyalora.yourcompany.com.
When something happens in Loyalora: 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 Loyalora under Settings → API & Webhooks, and choose the event or events below.
- Make the event happen once in Loyalora, so the other tool receives a sample and you can map its fields.
| Event | Fires when |
|---|---|
customer.created | A customer is added, from any door |
points.earned | Activity is recorded and points are credited |
reward.redeemed | A reward is redeemed |
Every delivery carries X-Loyalora-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 Loyalora
- 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/customers: Add a customer (a manager's or admin's key)POST /api/customers/{id}/earn: Record activity and earn what it is worth (clerk and above)POST /api/customers/{id}/redeem: Redeem a reward (clerk and above)POST /api/sales: A till's sale by card code:card(orcustomer_id),amountoramount_cents,ref(the till's sale id, required), optionalprogram_id. The program's rate decides the points; the samerefagain records nothing and answers200with"duplicate": true(clerk and above)POST /api/sales/redeem: Redeem at the till (1.2):card(orcustomer_id),reward_id,ref(the till's reference, required), optionallocation.201with the reward, cost and balance; the samerefagain →200"duplicate": true, nothing spent; a short balance →409withshort(points missing); an unknown card →404(clerk and above,entry.redeem)POST /api/sales/refund: A refund:ref(the sale's reference). Takes back what that sale earned, once; never below zero.201withreversedandalready_spent; again →200"duplicate": true; an unknown sale →404(clerk and above)
Example recipes
- customer.created → a row in Google Sheets: webhook trigger on
customer.created(A customer is added, from any door), then the Google Sheets "Create row" action with the fields you want. - points.earned → a message in Slack or Teams: webhook trigger on
points.earned(Activity is recorded and points are credited), then the Slack or Microsoft Teams "Send message" action. - A new form entry or row elsewhere → Loyalora: the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling
POST /api/customers(Add a customer (a manager's or admin's key)).
These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Loyalora's own automated tests check its API.