Aexir owner data API
Read-only access to your own restaurant data: orders, daily totals, locations and menu. Version 1.
Getting started
Base URL: https://aexir.com/api (also https://europe-west2-aexir-website.cloudfunctions.net/api). Every endpoint lives under /v1.
Create an API key yourself from the POS settings (Settings, Data & API, API access; admins with access to all locations), or ask Aexir support to create one for you. A key looks like axk_ followed by 48 characters. It is shown once; keep it somewhere safe. Then:
curl -H "Authorization: Bearer axk_YOUR_KEY" \
"https://aexir.com/api/v1/orders?from=2026-10-01&to=2026-10-07"
Add &format=csv to /v1/orders or /v1/summary/daily to get a spreadsheet-ready file instead of JSON. A CSV call returns the whole date range in one file, with no paging.
Authentication
Send the key in the Authorization header on every request:
Authorization: Bearer axk_YOUR_KEY
- A key belongs to one restaurant and can only ever read that restaurant.
- Keys created with customer data off never return names, phone numbers, email addresses, delivery addresses or order notes. Use one of those for dashboards and shared spreadsheets.
- Keys can be revoked at any time. A revoked key stops working within five minutes.
- Do not put the key in a URL. Browsers, proxies and servers keep URLs in their history and logs, so the key only travels in the header.
Endpoints
| Method and path | What it returns |
|---|---|
GET /v1/me | Which restaurant the key belongs to, its label, whether it includes customer data, and the limits. |
GET /v1/orders | Orders placed between two dates, newest last. JSON comes one page at a time; CSV returns the whole range in one file. See parameters below. |
GET /v1/orders/{id} | One order by its id, or 404. |
GET /v1/summary/daily | One row per day: order count, gross, discounts, delivery fees, service charges, tips, VAT, refunds, net, and breakdowns by payment method, order type and location. |
GET /v1/locations | Your branches: id, name, postcode, address, phone, opening state, which order types are on, delivery mode. |
GET /v1/menu | Menu items as the POS stores them: id, name, category, price, sizes with prices, inStock, the branches that list the item, description, VAT amount, meal deal flag. |
GET /v1/docs | This page. |
Parameters for /v1/orders
| Name | Meaning |
|---|---|
from, to | Inclusive local days, YYYY-MM-DD. Both default to the last 7 days. Give one and the other is set to the same day. Maximum 366 days. |
tz | Time zone the days are measured in, default Europe/London. Any IANA name works, for example Europe/Paris. Timestamps in the response carry this offset. |
status | One of pending, Accepted, KitchenCompleted, Completed, Declined, pickedUp, awaiting_payment. Not case sensitive. Completed also matches the legacy pickedUp. Without a status filter, awaiting_payment orders (app checkouts that never paid) are left out. |
orderType | Delivery, Collection or DineIn. |
location | Branch name exactly as it appears in /v1/locations (not case sensitive). |
limit | JSON only: orders per page, 1 to 500, default 100. Ignored with format=csv. |
cursor | JSON only: copy nextCursor from the previous page to get the next one. nextCursor is null on the last page. Ignored with format=csv. |
format | json (default) or csv. A CSV call returns every matching order in the range in one file. |
/v1/summary/daily takes from, to, tz and format. It reads every order in the range, so keep ranges to a few months at most.
The order object
{
"id": "Qm3kZ9...",
"placedAt": "2026-10-08T11:50:37+01:00",
"status": "Completed",
"effectiveStatus": "Completed",
"orderType": "Delivery",
"channel": "ios-7.1.1",
"location": "Main",
"customer": {
"name": "Sam Example",
"phone": "07700 900000",
"email": "sam@example.com",
"address": "1 High Street, M1 1AA"
},
"note": "Ring the bell",
"redacted": false,
"lines": [
{
"name": "Chicken Burger",
"size": "Large",
"unitPrice": 8.5,
"qty": 2,
"extras": [
{
"name": "Cheese",
"price": 0.5
}
],
"mealDeal": {
"drink": "Coke",
"side": "Fries",
"price": 2
},
"notes": "no mayo",
"lineTotal": 22
}
],
"rawItems": {
"items": [
"Chicken Burger: (8.5), Extras: Cheese(0.5),"
],
"size": [
"Large"
],
"mealDeal": [
"[\"Coke\"] [\"Fries\"]"
],
"mdPrice": [
2
],
"itemNotes": [
"no mayo"
]
},
"totals": {
"subtotal": 22,
"discount": 0,
"discountRaw": 0,
"discountType": "amount",
"deliveryFee": 2.5,
"serviceCharge": 0,
"tip": 0,
"vat": 3.67,
"total": 24.5,
"refund": 0
},
"payment": {
"method": "Card",
"reference": "pi_3Q..."
}
}
| Field | Meaning |
|---|---|
placedAt | When the order was placed, ISO 8601 with the offset of the requested tz. For app orders this is the moment payment was confirmed. |
status / effectiveStatus | status is exactly what is stored. effectiveStatus applies the POS rule that a pending, accepted or kitchen-completed order older than 12 hours counts as Completed, and maps the legacy pickedUp to Completed. |
orderType | Normalised to Delivery, Collection or DineIn whatever the app that wrote it used. |
channel | Where the order came from: ios-x, android-x, web-x (customer apps), webpos-x or pos-x (your till). |
customer, note | Only present on keys with customer data on. address is filled for Delivery orders; Dine-in orders carry table instead. Till orders have no phone or email. Email is only recorded by the web ordering site and some app versions, so it is often empty. |
redacted | true when the restaurant's data retention has already blanked the personal details on this order (by default after 180 days where retention is enabled). |
lines | Parsed from the stored item strings the same way the POS screens read them. Identical units are merged into qty. unitPrice already includes the size. lineTotal = (unitPrice + meal deal price + extras) x qty. mealDeal is null when there is none. |
rawItems | The untouched stored arrays, in case you want to parse them yourself. |
totals | total is what the customer paid (after any refund, see below). On app orders subtotal = total - deliveryFee - serviceCharge - tip + discount; on till orders subtotal is the sum of the line totals before the discount. discount is always a money amount; discountRaw and discountType show what the till stored: the POS till stores a percentage (discountType: "percent") and the customer apps store an amount. serviceCharge is only present on a few orders and is 0 otherwise. vat is the VAT amount included in the total. |
refund | The amount of the most recent refund on this order. The POS reduces total by the refund (a full refund leaves 0 and marks the order Declined). If an order was refunded twice only the last amount is kept. |
payment | method is Card, Apple Pay, Google Pay, Pay in person, Cash and so on, as written by the app or till. reference is the Stripe payment id when there is one. Till orders also carry status (Paid or Not Paid). |
CSV output
format=csv returns UTF-8 with a byte order mark, so Excel opens accented names correctly. /v1/orders gives one row per order line (an order with three different items is three rows; the order-level columns repeat). /v1/summary/daily gives one row per day with the breakdowns flattened as Card=120.50; Cash=40.00.
curl -H "Authorization: Bearer axk_YOUR_KEY" \
"https://aexir.com/api/v1/orders?from=2026-10-01&to=2026-10-31&format=csv" -o october.csv
A CSV call returns every order in the requested range in one file: there is no paging and limit and cursor are ignored. The X-Order-Count response header says how many orders the file holds. If the range holds more than 20,000 orders the call fails with 400 too_many_orders instead of returning a shortened file; ask for fewer days at a time (one month per call is plenty for most restaurants).
Excel (Power Query)
Data tab, Get Data, From Other Sources, Blank Query, then open the Advanced Editor and paste:
let
Key = "axk_YOUR_KEY",
Url = "https://aexir.com/api/v1/orders?from=2026-10-01&to=2026-10-31&format=csv",
Raw = Web.Contents(Url, [Headers = [Authorization = "Bearer " & Key]]),
Table = Csv.Document(Raw, [Delimiter = ",", Encoding = 65001, QuoteStyle = QuoteStyle.Csv]),
Promoted = Table.PromoteHeaders(Table, [PromoteAllScalars = true])
in
Promoted
The query pulls every order in the date range in one go (no paging needed). When Excel asks about privacy levels choose Anonymous for the connection (the key is already in the header). Refresh the query to pull fresh data. For the daily summary swap the URL for /v1/summary/daily?from=...&to=...&format=csv.
Google Sheets
Sheets' built-in IMPORTDATA cannot send an Authorization header, and this API never accepts a key in the URL (it would end up in logs and in the sheet's formula history). Use a short Apps Script function instead: Extensions, Apps Script, paste this, save, then use =AEXIR("orders", "2026-10-01", "2026-10-31") in a cell. Each call returns the whole date range (no paging).
const AEXIR_KEY = 'axk_YOUR_KEY';
const AEXIR_BASE = 'https://aexir.com/api/v1/';
/**
* Pull Aexir data into the sheet.
* @param {string} what "orders", "summary", "locations" or "menu"
* @param {string} from start day YYYY-MM-DD (orders and summary)
* @param {string} to end day YYYY-MM-DD
* @customfunction
*/
function AEXIR(what, from, to) {
const path = what === 'summary' ? 'summary/daily' : what;
let url = AEXIR_BASE + path + '?format=csv';
if (from) url += '&from=' + from;
if (to) url += '&to=' + to;
const res = UrlFetchApp.fetch(url, { headers: { Authorization: 'Bearer ' + AEXIR_KEY }, muteHttpExceptions: true });
if (res.getResponseCode() !== 200) throw new Error(res.getContentText());
return Utilities.parseCsv(res.getContentText().replace(/^\uFEFF/, ''));
}
Sheets gives a custom function only a short time to run, so if a very busy month is slow to load, ask for a week at a time or use the daily summary. locations and menu are JSON only; for those return JSON.parse(...) and map the fields you need, or ask Aexir support for a template sheet. Scripts in a shared sheet can be read by anyone who can edit it, so use a key with customer data off for shared sheets.
Managing keys
Keys are created and revoked in the POS settings by admins with access to all locations, or by Aexir support on request. The POS also asks for the owner PIN before it opens the screen, but the server only accepts key changes from an admin account with access to all locations. The same operations are available over HTTP for those apps with a signed-in Firebase ID token rather than an API key:
| Call | Body / result |
|---|---|
GET /v1/keys | Lists this restaurant's keys: id, prefix, label, customerData, createdAt, createdBy, lastUsedAt, revokedAt. Never the key itself. |
POST /v1/keys | Body {"label": "Head office spreadsheet", "customerData": true}. Returns {id, key, prefix}. The full key is returned once and never again. Up to 20 active keys. |
DELETE /v1/keys/{id} | Revokes the key. |
A POS token must belong to an admin user of that restaurant. Any admin can list keys; only an admin with access to all locations can create or revoke one (a branch-only admin gets 403 owner_only). Keys cannot be created for a restaurant that is switched off on Aexir (403 tenant_disabled), and its existing keys stop working with the same error. A few accounts share their data with another brand; keys for them are set up through Aexir support (403 shared_project). An Aexir staff token adds ?tenant=slug. lastUsedAt is updated at most once an hour.
Errors and limits
Every error is JSON in the shape {"error": {"code": "...", "message": "..."}}.
| HTTP | code | When |
|---|---|---|
| 400 | bad_date, bad_tz, bad_limit, bad_cursor, bad_status, bad_order_type, bad_format, range_too_large, too_many_orders, bad_label, bad_json, too_many_keys | A parameter or request body did not validate. The message says which. too_many_orders means a CSV range holds more than 20,000 orders: narrow the dates. |
| 401 | unauthorized | Missing, unknown or revoked key (or token). |
| 403 | forbidden, owner_only, tenant_disabled, shared_project | A valid sign-in that is not allowed to manage keys for that restaurant, an admin limited to one branch trying to create or revoke a key, a restaurant that is switched off (its keys stop working too), or an account that shares its data with another brand (contact Aexir support). |
| 404 | not_found, unknown_tenant | No such order, key, tenant or endpoint. |
| 405 | method_not_allowed | Wrong HTTP method. |
| 429 | rate_limited | More than 120 requests in a minute on one key. The Retry-After header says how many seconds to wait. |
| 503 | tenant_not_ready | The API has not yet been switched on for this restaurant. Contact Aexir support. |
- 120 requests per minute per key.
- JSON: up to 500 orders per page. CSV: the whole range, up to 20,000 orders. 366 days per request.
- Responses are never cached (
Cache-Control: private, no-store). - Browser calls work from any origin for GET requests (CORS), so the key can be used from a browser-based tool. Keep it off public pages.
Data notes
- Days are local.
fromandtoare whole days intz; the POS dashboard uses the device's own local day, so a day boundary can differ by an hour when a device is in another zone. - Daily summary maths.
grossadds each order'stotalandrefundback together (the value before the refund);refundsis the sum of refund amounts;net= gross - refunds, which equals the sum of what was actually kept. Orders inawaiting_paymentare ignored. Declined orders with no refund are counted indeclinedand excluded from money totals.byPayment,byOrderTypeandbyLocationsplitnet. - Discounts. The till stores a percentage and the customer apps store an amount. The API always reports
discountas money; on till orders it is the percentage of the line totals (the delivery fee is never discounted), rounded to the penny, so it stays right on refunded and fully comped orders. - Item names. Menu names containing a comma or a colon cannot be separated from their extras by the stored format; the API reads them exactly as the POS does and
rawItemsis there for checking. - Locations.
orders.locationis the branch name, matchingnamein/v1/locations. Older or till orders can carry an empty location. - Menu inStock. The POS has no single in-stock flag: an item is available where its branch list includes the branch, and a stock count of 0 at a branch means out of stock there.
inStockis true when the item is listed at at least one branch and is not at 0 everywhere it is listed;stockCountis included when the restaurant keeps counts. - Payment method on till orders. The till records the method (Cash or Card) separately from whether the order was paid; the API returns the method in
payment.methodand the paid state inpayment.status. - Accounts that share data with another brand. A few accounts keep two brands in one shared data store, so a key could not be limited to one brand's orders and customers. These accounts get API access through Aexir support rather than the POS (key creation returns
403 shared_project). - Nothing is invented. Fields the apps do not write (service charge, email on till orders, Stripe reference on cash orders) come back as 0 or an empty string.
Questions: support@aexir.com