keel

API · Version v1

Keel API for developers

Connect a restaurant to a CRM, accounting, AI services or your own app: the menu, orders, every movement of money, and real-time webhooks.

Overview

Every restaurant on Keel runs on its own server and its own domain. So does its API:

https://<restoran-domeni>/api/open/v1

The 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
curl https://<restoran-domeni>/api/open/v1/ping \
  -H "Authorization: Bearer keel_7fd73b99c1cd8ed9…"
200 OK
{
  "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:

ScopeAllows
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 day field — the restaurant's own calendar day. Cutting a date off occurredAt puts 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

4xx / 5xx
{
  "error": {
    "code": "insufficient_scope",
    "message": "this API key does not have the orders:read scope"
  }
}
HTTPcode
400invalid_request
401unauthorized
403insufficient_scope
404not_found
500internal_error

Branch on code; message is for humans and may change.

Orders

GET /orders — newest first. Filters:

Query
branchIdonly this branch
statuspending, confirmed, preparing, on_the_way, delivered, cancelled
createdFrom, createdToRFC 3339; from inclusive, to exclusive
limit1–100, default 50
cursorthe 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:

Order
{
  "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).
  • address is present only when the order has one; cancelReason, tableNumber, scheduledAt are omitted when empty.
  • price is 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, torequired; the restaurant's days (2026-09-01), both inclusive, or RFC 3339. At most 31 days.
branchIdonly 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.

classpnlWhat 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.

MoneyEntry
{
  "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" }
}
  • amount is 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; paidAt once 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_changed webhook.
  • id is 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, check at. 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.
  • name is the restaurant's own wording; decide by kind: 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.

EventWhen
order.createdan order is placed — site, app, phone operator, till, Uzum Tezkor
order.status_changedits status changes
money.day_changeda day's money changed — see below
pingthe owner pressed "Test" in the panel
HTTP
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.status is the status this event is about. data.order is a snapshot taken when the event was queued, so for quick successive changes data.order.status may already be the later one.
  • createdAt is when the change happened. A till that was offline sends its sales later, dated when they were made.
  • Answer with any 2xx within 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.

Test vector
secret = "secret"
t      = 1
body   = {}
→ t=1,v1=1122767b193110cfec322b6f199b599edbf608ed087f2d27afb0b97d99523908
Node.js
import 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));
}
Python
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)
PHP
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.

Keel API for developers | Keel