Обзор
Каждый ресторан в Keel работает на своём сервере и своём домене. API — тоже на домене ресторана:
https://<restoran-domeni>/api/open/v1Точный адрес владелец видит в панели: Настройки → Интеграции → API и вебхуки. Ключ одного ресторана открывает только этот ресторан.
API работает в две стороны: ваша программа запрашивает у нас данные (по API-ключу), а мы сами сообщаем вам, когда меняется заказ или деньги (вебхук).
/v1 — это обещание. Пути, поля, заголовки и коды ошибок здесь не меняются; если понадобится изменить — рядом появится /v2. В объекты могут добавляться новые поля — игнорируйте незнакомые.
Быстрый старт
- Владелец ресторана создаёт ключ в панели и выбирает разрешения. Ключ начинается с
keel_и показывается только один раз. - Владелец передаёт вам две вещи: адрес API и ключ.
- Проверьте ключ через
/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"] }
}Аутентификация и разрешения
В каждом запросе — заголовок Authorization: Bearer keel_…. У ключа есть разрешения:
| Разрешение | Что даёт |
|---|---|
menu:read | /branches/{id}/menu — меню и что сейчас в продаже |
orders:read | /orders, /orders/{ref} — заказы, с именем и телефоном клиента |
finance:read | /money, /money/daily, /balances, /fiscal — деньги, без данных клиентов |
Для /ping и /branches подходит любой действующий ключ. Отозванный ключ перестаёт работать со следующего запроса.
Ключ используется только на сервере. Не кладите его в браузер или мобильное приложение — там его прочитает кто угодно. Cross-origin запросы из браузера не разрешены.
Лимит: 120 запросов в минуту с одного IP-адреса (дальше — 429).
Соглашения
- Деньги — целое число сумов (UZS). Без тийинов и дробей.
- Время — RFC 3339 в UTC (
2026-09-24T12:30:15.69Z). - Для дня используйте поле
day— это календарный день ресторана. Если отрезать дату отoccurredAt, всё после 19:00 по Ташкенту уедет на предыдущий день. - Id — 24 hex-символа. Отсутствующий id — пустая строка или поля нет, никогда не
"000000000000000000000000". - Списки — всегда массив, никогда не
null.
Ошибки
{
"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 |
В коде принимайте решения по code; message — для человека и может меняться.
Заказы
GET /orders — сначала новые. Фильтры:
| Параметр | |
|---|---|
branchId | только этот филиал |
status | pending, confirmed, preparing, on_the_way, delivered, cancelled |
createdFrom, createdTo | RFC 3339; начало включительно, конец — нет |
limit | 1–100, по умолчанию 50 |
cursor | nextCursor предыдущей страницы; на последней — "" |
GET /orders?createdFrom=2026-09-01T00:00:00%2B05:00&limit=50
{ "orders": [ Order, … ], "nextCursor": "MTc1ODcw…" }GET /orders/{ref} — один заказ, по номеру (QVUU-R97U) или id. Объект заказа:
{
"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(стол в зале),uzum_tezkor(забирает курьер Uzum).addressесть, только если у заказа есть адрес;cancelReason,tableNumber,scheduledAtопускаются, если пусты.price— цена за единицу, уже с опциями.
Денежная книга
GET /money (finance:read) — каждое движение денег в ресторане, в одном виде и уже классифицированное. Для бухгалтерских сервисов: суммы, способы оплаты, поставщики и сотрудники — никогда имя или телефон гостя.
| Параметр | |
|---|---|
from, to | обязательны; дни ресторана (2026-09-01), оба включительно, или RFC 3339. Не больше 31 дня. |
branchId | только этот филиал |
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
}
}Не каждое движение денег — доход или расход. Деньги переходят из кассы в банк, закупщику выдают подотчёт, агрегатор перечисляет деньги, которые ресторан уже заработал. Если сложить их как выручку или расходы, одни и те же деньги посчитаются дважды. Поэтому каждая запись говорит, что она такое: class и pnl.
class | pnl | Что это |
|---|---|---|
revenue | ✅ | Оплаченная продажа, в день продажи. Детали — в sale. |
refund | ✅ | Возврат денег, в день возврата. |
cost | ✅ | Закупка у поставщика, расход (аренда, коммунальные, налоги), внешняя служба доставки. |
payroll | ✅ | Выплаченная зарплата — сотрудникам и курьерам. |
commission | ✅ | Что удержал агрегатор или эквайринг. |
transfer | ❌ | Те же деньги в другом месте: касса → банк, агрегатор → банк, курьер → касса. Не выручка. |
advance | ❌ | Наличные, выданные сотруднику на закупку (подотчёт). Расходом они становятся в закупке — а она уже `cost`. |
manual | ❌ | Наличные, внесённые или изъятые из кассы или сейфа вручную, с category кассира. Часто это те же деньги, что уже в закупке или расходе. |
variance | ❌ | Касса при закрытии смены оказалась с излишком (in) или недостачей (out). Пропавшие деньги, а не потраченные. |
Финансовый отчёт в панели ресторана — это сумма записей с pnl: true, то есть pnlNet. Если ваша цифра за период отличается — что-то посчитано дважды.
Скидки и баллы — не отдельные записи: деньги не двигались. Они внутри продажи (sale.discountTotal, sale.pointsSpent); amount — фактически полученная сумма.
{
"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всегда положителен; знак задаётdirection(in/out, со стороны ресторана).source:sale,refund,delivery_service,purchase,expense,salary,courier_pay,payout,collection,advance,cash_entry,safe_entry,courier_settlement,shift_variance. Могут появиться новые — решайте по `class`.- Необязательные:
method,methodName,category,counterparty,from/to(для переводов: till, safe, bank, aggregator, courier, staff),note. sale(толькоrevenue):type,channel,subtotal,discountTotal,pointsSpent,deliveryFee,serviceCharge.paid(только закупки): записывается в день прихода товара;paidAt— когда оплатили поставщику.
Период никогда не закрыт — перечитывайте
Книга вычисляется из документов ресторана в момент запроса. Документы исправляют: удаляют дважды введённый расход, правят цену закупки, гасят долг, возвращают деньги через неделю. Поэтому:
- Запрашивайте период заново и заменяйте сохранённое — сопоставляйте по
id, удаляйте исчезнувшие id. Не дописывайте. - Каждый день перечитывайте минимум последние 7 дней, а после закрытия месяца — весь прошлый месяц. Или используйте вебхук
money.day_changed. idстабилен: один документ всегда даёт один id.
Итоги по дням
GET /money/daily (finance:read) — книга, сложенная по дням и филиалам. Это те же записи /money, поэтому цифры никогда не расходятся. Параметры как у /money, период до 93 дней (квартал).
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 }
]
}Где деньги — остатки
GET /balances (finance:read, необязательный branchId) — положение на сейчас. Те же цифры, что на экране «Где деньги» в панели владельца.
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 } }
}Общего итога нет намеренно. Наличные можно потратить сегодня вечером, деньги в банке — на этой неделе, а деньги у агрегатора — когда решит агрегатор. Их сумма — число, которое ни о чём не правда.
counted: true— кто-то посчитал (остаток из банковского приложения); устаревает, смотритеat.counted: false— сложено из документов; неверно, если документа не хватает.- Банк — последний посчитанный остаток, не выводится из движений: на счёт приходят деньги, которых мы не видим.
name— слова самого ресторана; решайте поkind:safe,drawer,courier,advance,bank,rail.payables.suppliers— неоплаченные закупки;receivables.guestDebt— неоплаченные продажи в долг.
Фискальные чеки
GET /fiscal (finance:read, ≤31 дня) — чеки, отправленные в налоговую по продажам периода, в любом статусе (перед закрытием месяца догоняют как раз ожидающие и ошибочные), и Z-отчёты смен, закрытых в периоде.
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 — sale или refund; status — pending, filed или failed; error объясняет, почему не отправлено.
Вебхуки
Владелец добавляет ваш адрес https:// в панели и выбирает события. Он получает одноразовый ключ подписи (whsec_…) и передаёт его вам.
| Событие | Когда |
|---|---|
order.created | заказ оформлен — сайт, приложение, оператор, касса, Uzum Tezkor |
order.status_changed | изменился статус заказа |
money.day_changed | изменились деньги за день — см. ниже |
ping | владелец нажал «Проверить» в панели |
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— статус, о котором это событие.data.order— снимок на момент постановки в очередь, поэтому при быстрых измененияхdata.order.statusможет быть уже следующим.createdAt— когда произошло изменение. Касса, работавшая офлайн, отправит продажи позже — со временем, когда они были.- Ответьте любым
2xxза 10 секунд, а работу делайте потом. Другой статус, таймаут или редирект — ошибка; редиректы не выполняются. - Повторы после ошибки: 1 мин, 5 мин, 30 мин, 2 ч, 6 ч, 12 ч, 24 ч (8 попыток, около двух суток).
- Одно событие может прийти несколько раз — отбрасывайте повторы по
id(Keel-Event-Id). - События отправляются по очереди, но повтор может прийти после более нового события — упорядочивайте по `createdAt`, а не по времени прихода.
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 }
}Это подсказка, а не деньги: перечитайте этот день (GET /money?from=<day>&to=<day>) и замените сохранённое. Приходит на всё, что меняет цифры дня — новая продажа, исправленная закупка, удалённый расход — за последние 35 дней, проверка раз в 10 минут. entries: 0 — все записи дня удалены.
За дни до регистрации адреса ничего не отправляется — эти периоды прочитайте один раз сами.
Проверка подписи
Keel-Signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( secret, t + "." + raw_request_body ) )Используются сырые байты тела — до разбора JSON. Отклоняйте запрос, если подпись не совпала или t отличается от ваших часов больше чем на 5 минут. Время входит в подпись, поэтому старую доставку нельзя переотправить.
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'] ?? '');
}Если владелец сменит ключ подписи, доставки из очереди будут подписаны новым.