Overview
Every restaurant on Keel runs on its own server and its own domain. So does its API:
https://<restoran-domeni>/api/open/v1The owner finds the exact address in the panel: Settings → Integrations → API and webhooks. A key opens its own restaurant and no other.
It works both ways: your program asks us (with an API key), and we tell you when an order or the money changes (webhooks).
/v1 is a promise. Paths, fields, headers and error codes here do not change; if one has to, /v2 appears beside it. New fields may be added to any object — ignore what you do not know.
Quick start
- The restaurant owner creates a key in the panel and picks its permissions. It starts with
keel_and is shown once. - The owner gives you two things: the API address and the key.
- Check the key with
/ping:
curl https://<restoran-domeni>/api/open/v1/ping \
-H "Authorization: Bearer keel_7fd73b99c1cd8ed9…"{
"restaurant": "Rayhon",
"apiVersion": "v1",
"key": { "name": "Finze", "prefix": "keel_7fd73b", "scopes": ["finance:read"] }
}Authentication and scopes
Every request carries Authorization: Bearer keel_…. Keys carry scopes:
| Scope | Allows |
|---|---|
menu:read | /branches/{id}/menu — the menu and what is on sale now |
orders:read | /orders, /orders/{ref} — orders, with the customer's name and phone |
finance:read | /money, /money/daily, /balances, /fiscal — money, no customer data |
/ping and /branches need any live key. A revoked key stops working on the next request.
Server to server only. Never put a key in a browser or a mobile app — anyone can read it there. Cross-origin browser requests are not allowed.
Rate limit: 120 requests a minute per IP address (429 beyond that).
Conventions
- Money is an integer number of so'm (UZS). No tiyin, no decimals.
- Times are RFC 3339 in UTC (
2026-09-24T12:30:15.69Z). - For the day, use the
dayfield — the restaurant's own calendar day. Cutting a date offoccurredAtputs everything after 19:00 Tashkent time on the previous day. - Ids are 24 hex characters. An absent id is an empty string or a missing field, never
"000000000000000000000000". - Lists are always arrays, never
null.
Errors
{
"error": {
"code": "insufficient_scope",
"message": "this API key does not have the orders:read scope"
}
}| HTTP | code |
|---|---|
| 400 | invalid_request |
| 401 | unauthorized |
| 403 | insufficient_scope |
| 404 | not_found |
| 500 | internal_error |
Branch on code; message is for humans and may change.
Orders
GET /orders — newest first. Filters:
| Query | |
|---|---|
branchId | only this branch |
status | pending, confirmed, preparing, on_the_way, delivered, cancelled |
createdFrom, createdTo | RFC 3339; from inclusive, to exclusive |
limit | 1–100, default 50 |
cursor | the previous page's nextCursor; "" on the last page |
GET /orders?createdFrom=2026-09-01T00:00:00%2B05:00&limit=50
{ "orders": [ Order, … ], "nextCursor": "MTc1ODcw…" }GET /orders/{ref} — one order, by its number (QVUU-R97U) or id. The Order object:
{
"id": "6ab517d7c71997fe78741fed",
"number": "QVUU-R97U",
"branchId": "6ab51777ac108b6304170794",
"type": "pickup",
"channel": "web",
"status": "confirmed",
"customer": { "name": "Aziz", "phone": "+998901112233" },
"items": [
{ "menuItemId": "…", "name": "Osh (palov)", "price": 45000, "qty": 2,
"options": [ { "group": "Hajmi", "choice": "Katta", "priceDelta": 10000 } ] }
],
"subtotal": 90000, "discountTotal": 0, "deliveryFee": 0,
"serviceCharge": 0, "total": 90000,
"paymentMethod": "cash", "paymentStatus": "unpaid",
"statusHistory": [
{ "status": "pending", "at": "2026-09-24T12:30:15.69Z" },
{ "status": "confirmed", "at": "2026-09-24T12:30:15.727Z" }
],
"createdAt": "2026-09-24T12:30:15.69Z"
}type:delivery,pickup,dinein(a table in the room),uzum_tezkor(carried by Uzum's courier).addressis present only when the order has one;cancelReason,tableNumber,scheduledAtare omitted when empty.priceis per unit and includes the options.
The money ledger
GET /money (finance:read) — every movement of money in the restaurant, one shape, already classified. Built for accounting services: amounts, methods, suppliers and staff — never a guest's name or phone.
| Query | |
|---|---|
from, to | required; the restaurant's days (2026-09-01), both inclusive, or RFC 3339. At most 31 days. |
branchId | only this branch |
GET /money?from=2026-09-01&to=2026-09-30
{
"from": "2026-08-31T19:00:00Z", "to": "2026-09-30T19:00:00Z", "currency": "UZS",
"entries": [ MoneyEntry, … ],
"totals": {
"byClass": {
"revenue": { "in": 90000, "out": 0, "count": 1 },
"cost": { "in": 0, "out": 5000000, "count": 1 }
},
"pnlNet": -4910000
}
}Not every movement of money is income or an expense. Money moves from the till to the bank, a buyer is handed cash for the market, an aggregator passes on money the restaurant already earned. Adding those up as income or costs counts the same money twice. So every entry says what it is: class and pnl.
class | pnl | What it is |
|---|---|---|
revenue | ✅ | A paid sale, on the day it was made. Breakdown in sale. |
refund | ✅ | A sale's money handed back, on the day it was handed back. |
cost | ✅ | A purchase from a supplier, an expense (rent, utilities, tax), an outside delivery service. |
payroll | ✅ | Wages paid — staff and couriers. |
commission | ✅ | What an aggregator or acquirer kept. |
transfer | ❌ | The same money in another place: till → bank, aggregator → bank, courier → till. Not income. |
advance | ❌ | Cash handed to an employee to spend (the market run). It becomes a cost when it buys something — and that purchase is already a `cost`. |
manual | ❌ | Cash put into or taken out of a drawer or the safe by hand, with the cashier's own category. Often the same money a purchase or expense already records. |
variance | ❌ | A drawer counted over (in) or under (out) at closing. Missing money, not money spent. |
The restaurant's own money report is the sum of the pnl: true entries: pnlNet. If your figure for a period differs, something is counted twice.
Discounts and points are not entries: no money moved. They sit inside a sale (sale.discountTotal, sale.pointsSpent); amount is what was actually charged.
{
"id": "expense_6ab5235b666e66387034a534",
"source": "expense",
"class": "cost",
"pnl": true,
"direction": "out",
"amount": 5000000,
"occurredAt": "2026-09-23T19:00:00Z",
"day": "2026-09-24",
"branchId": "6ab52356666e66387034a523",
"method": "transfer",
"category": "ijara",
"ref": { "type": "expense", "id": "6ab5235b666e66387034a534" }
}amountis always positive;direction(in/out, from the restaurant's side) gives the sign.source:sale,refund,delivery_service,purchase,expense,salary,courier_pay,payout,collection,advance,cash_entry,safe_entry,courier_settlement,shift_variance. New ones may be added — always decide by `class`.- Optional:
method,methodName,category,counterparty,from/to(for transfers: till, safe, bank, aggregator, courier, staff),note. sale(revenue only):type,channel,subtotal,discountTotal,pointsSpent,deliveryFee,serviceCharge.paid(purchases only): booked when the goods arrived;paidAtonce the supplier was paid.
A period is never final — re-read it
The ledger is computed from the restaurant's documents when you ask. Documents get corrected: an expense typed twice is deleted, a purchase price is fixed, a debt is repaid, a refund is given a week later. So:
- Fetch a period again and replace what you stored — match by
id, delete the ids that are gone. Do not append. - Re-read at least the last 7 days every day, and the whole previous month once it has closed — or use the
money.day_changedwebhook. idis stable: the same document always gives the same id.
Daily totals
GET /money/daily (finance:read) — the ledger summed per day and branch. The same entries as /money, so the two never disagree. Same query as /money, up to 93 days (a quarter).
GET /money/daily?from=2026-07-01&to=2026-09-30
{
"currency": "UZS",
"days": [
{ "day": "2026-09-24", "branchId": "6ab5…",
"byClass": {
"revenue": { "in": 4200000, "out": 0, "count": 61 },
"cost": { "in": 0, "out": 5000000, "count": 1 }
},
"revenueByMethod": { "cash": 2600000, "payme": 1600000 },
"pnlNet": -800000 }
]
}Where the money is
GET /balances (finance:read, optional branchId) — the position right now. The same figures as the owner's "Where the money is" screen.
GET /balances
{
"asOf": "2026-09-24T13:29:00Z", "currency": "UZS", "branchId": "",
"cash": [
{ "kind": "safe", "name": "Seyf", "amount": 1200000, "counted": false },
{ "kind": "drawer", "name": "Kassa yashigi", "amount": 350000, "counted": false }
],
"cashTotal": 1550000,
"bank": [
{ "kind": "bank", "name": "Kapitalbank", "amount": 48000000,
"counted": true, "at": "2026-09-23T09:00:00Z" }
],
"bankTotal": 48000000,
"inTransit": [ { "kind": "rail", "name": "Uzum", "amount": 3100000, "counted": false } ],
"inTransitTotal": 3100000,
"cashLimit": 5000000, "overCashLimit": false,
"payables": { "suppliers": { "amount": 7400000, "count": 5 } },
"receivables": { "guestDebt": { "amount": 260000, "count": 3 } }
}There is deliberately no grand total. Cash can be spent tonight, the bank this week, and money an aggregator holds when they decide. Adding them gives a number that is true of nothing.
counted: true— somebody counted it (a balance read off the bank's app); it goes stale, checkat.counted: false— added up from documents; wrong when a document is missing.- The bank is the last counted balance, never derived from movements: money reaches the account from places we do not see.
nameis the restaurant's own wording; decide bykind:safe,drawer,courier,advance,bank,rail.payables.suppliers— deliveries not yet paid for;receivables.guestDebt— sales on credit not yet paid.
Fiscal receipts
GET /fiscal (finance:read, ≤ 31 days) — receipts filed with the tax committee for the period's sales, in every status (the pending and failed ones are what gets chased before the month closes), and the Z-reports of shifts closed in the period.
GET /fiscal?from=2026-09-01&to=2026-09-30
{
"receipts": [
{ "orderId": "…", "orderNumber": "QVUU-R97U", "branchId": "…",
"kind": "sale", "total": 90000, "method": "cash",
"status": "filed", "provider": "multikassa",
"fiscalSign": "…", "receiptId": "…", "qrText": "…",
"filedAt": "…", "soldAt": "…" }
],
"zReports": [
{ "shiftId": "…", "branchId": "…", "number": "142",
"saleCash": 2600000, "saleCard": 1600000, "saleTotal": 4200000,
"saleCount": 61, "refundTotal": 0, "closedAt": "…" }
]
}kind is sale or refund; status is pending, filed or failed; error says why a filing failed.
Webhooks
The owner registers your https:// address in the panel and picks events. They receive a one-time signing secret (whsec_…) to give to you.
| Event | When |
|---|---|
order.created | an order is placed — site, app, phone operator, till, Uzum Tezkor |
order.status_changed | its status changes |
money.day_changed | a day's money changed — see below |
ping | the owner pressed "Test" in the panel |
POST https://your-server.example/keel
Content-Type: application/json
User-Agent: Keel-Webhooks/1
Keel-Event: order.status_changed
Keel-Event-Id: evt_6ab517d7c71997fe78741fed_1
Keel-Delivery-Id: 6ab517d8c71997fe78741ff1
Keel-Signature: t=1758717015,v1=5f1c…
{
"id": "evt_6ab517d7c71997fe78741fed_1",
"type": "order.status_changed",
"createdAt": "2026-09-24T12:30:15.727Z",
"data": { "status": "confirmed", "previousStatus": "pending", "order": Order }
}data.statusis the status this event is about.data.orderis a snapshot taken when the event was queued, so for quick successive changesdata.order.statusmay already be the later one.createdAtis when the change happened. A till that was offline sends its sales later, dated when they were made.- Answer with any
2xxwithin 10 seconds, then do the work. Anything else — another status, a timeout, a redirect — is a failure; redirects are not followed. - Retries after a failure: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h (8 attempts, about two days).
- The same event can arrive more than once — deduplicate by
id(Keel-Event-Id). - Events are sent one at a time, but a retry can arrive after a newer event — order by `createdAt`, not by arrival.
money.day_changed
{
"id": "evt_money_2026-09-24_6ab5…_1f3a9c0e2b7d",
"type": "money.day_changed",
"createdAt": "2026-09-24T13:40:00Z",
"data": { "day": "2026-09-24", "branchId": "6ab5…", "pnlNet": -800000, "entries": 62 }
}A hint, not the money: re-read that day (GET /money?from=<day>&to=<day>) and replace what you stored. Raised by anything that changes the day's figures — a new sale, an edited purchase, a deleted expense — within the last 35 days, checked every 10 minutes. entries: 0 means every entry of the day was deleted.
Nothing is sent for the days before the address was registered — read those periods yourself once.
Verifying the signature
Keel-Signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( secret, t + "." + raw_request_body ) )Use the raw body bytes, before any JSON parsing. Reject the request if the signature does not match or t is more than 5 minutes from your clock. The time is inside the signature, so an old delivery cannot be replayed.
secret = "secret"
t = 1
body = {}
→ t=1,v1=1122767b193110cfec322b6f199b599edbf608ed087f2d27afb0b97d99523908import crypto from "node:crypto";
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return parts.v1?.length === want.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(want));
}import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > 300:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), want)function verify(string $secret, string $header, string $rawBody): bool {
parse_str(str_replace(',', '&', $header), $p);
$t = (int)($p['t'] ?? 0);
if (!$t || abs(time() - $t) > 300) return false;
$want = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($want, $p['v1'] ?? '');
}When the owner replaces the secret, deliveries still in the queue are signed with the new one.