keel

API · Версия v1

Keel API — для разработчиков

Подключите ресторан к CRM, бухгалтерии, AI-сервисам и своему приложению: меню, заказы, движение денег и вебхуки в реальном времени.

Обзор

Каждый ресторан в Keel работает на своём сервере и своём домене. API — тоже на домене ресторана:

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

Точный адрес владелец видит в панели: Настройки → Интеграции → API и вебхуки. Ключ одного ресторана открывает только этот ресторан.

API работает в две стороны: ваша программа запрашивает у нас данные (по API-ключу), а мы сами сообщаем вам, когда меняется заказ или деньги (вебхук).

/v1 — это обещание. Пути, поля, заголовки и коды ошибок здесь не меняются; если понадобится изменить — рядом появится /v2. В объекты могут добавляться новые поля — игнорируйте незнакомые.

Быстрый старт

  • Владелец ресторана создаёт ключ в панели и выбирает разрешения. Ключ начинается с keel_ и показывается только один раз.
  • Владелец передаёт вам две вещи: адрес API и ключ.
  • Проверьте ключ через /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"] }
}

Аутентификация и разрешения

В каждом запросе — заголовок 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.

Ошибки

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

В коде принимайте решения по code; message — для человека и может меняться.

Заказы

GET /orders — сначала новые. Фильтры:

Параметр
branchIdтолько этот филиал
statuspending, confirmed, preparing, on_the_way, delivered, cancelled
createdFrom, createdToRFC 3339; начало включительно, конец — нет
limit1–100, по умолчанию 50
cursornextCursor предыдущей страницы; на последней — ""
GET /orders?createdFrom=2026-09-01T00:00:00%2B05:00&limit=50

{ "orders": [ Order, … ], "nextCursor": "MTc1ODcw…" }

GET /orders/{ref} — один заказ, по номеру (QVUU-R97U) или id. Объект заказа:

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 (стол в зале), 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.

classpnlЧто это
revenue✅Оплаченная продажа, в день продажи. Детали — в sale.
refund✅Возврат денег, в день возврата.
cost✅Закупка у поставщика, расход (аренда, коммунальные, налоги), внешняя служба доставки.
payroll✅Выплаченная зарплата — сотрудникам и курьерам.
commission✅Что удержал агрегатор или эквайринг.
transfer❌Те же деньги в другом месте: касса → банк, агрегатор → банк, курьер → касса. Не выручка.
advance❌Наличные, выданные сотруднику на закупку (подотчёт). Расходом они становятся в закупке — а она уже `cost`.
manual❌Наличные, внесённые или изъятые из кассы или сейфа вручную, с category кассира. Часто это те же деньги, что уже в закупке или расходе.
variance❌Касса при закрытии смены оказалась с излишком (in) или недостачей (out). Пропавшие деньги, а не потраченные.

Финансовый отчёт в панели ресторана — это сумма записей с pnl: true, то есть pnlNet. Если ваша цифра за период отличается — что-то посчитано дважды.

Скидки и баллы — не отдельные записи: деньги не двигались. Они внутри продажи (sale.discountTotal, sale.pointsSpent); amount — фактически полученная сумма.

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 всегда положителен; знак задаёт 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владелец нажал «Проверить» в панели
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 — статус, о котором это событие. 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=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'] ?? '');
}

Если владелец сменит ключ подписи, доставки из очереди будут подписаны новым.

Keel API — для разработчиков | Keel