PUBLIC API V1 · ДЛЯ РАЗРАБОТЧИКОВ
Бронирование столов
Свободные столы, создание, перенос и отмена брони, статусы и webhook.
Содержание
Что умеет API бронирования
Через Public API ваш сайт, мобильное приложение, агрегатор или внешняя CRM может:
- получить столы и зоны зала ресторана;
- узнать, какие столы свободны на нужные дату, время и длительность визита;
- создать бронь от имени гостя;
- изменить время, количество гостей, стол, контакты или комментарий;
- отменить бронь;
- получать webhook, когда ресторан подтверждает бронь, сажает гостей, отмечает неявку или меняет бронь сам.
Бронь из API — обычная бронь MenuLab. Она сразу появляется у администратора в кассе и в приложении броней, проверяется на пересечения так же, как бронь, внесённая сотрудником, привязывается к карточке гостя по телефону, а персонал получает уведомление о новой заявке.
Подключение по шагам
- Ресторан создаёт API-клиент типа «Сайт» или «Приложение» с вашим Webhook URL и передаёт вам ключи.
GET /auth/me— вscopesестьreservations:write.GET /tables— столы и зоны.POST /reservations/availability— свободные столы на время гостя.POST /reservationsс ключом{заявка}-{попытка}— бронь в статусеpending.- Webhook: проверка подписи, сохранение по
event_id, ответ2xx. - Перенос —
PATCH /reservations/{id}, отмена —POST /reservations/{id}/cancel. - Сверка по
GET /reservations/{id}— если webhook не дошёл.
Ниже — каждый шаг подробно.
Перед началом
- Администратор ресторана открывает в бэк-офисе Настройки → Интеграции → API-клиенты и нажимает «Добавить API-клиент».
- Тип интеграции — «Сайт» или «Приложение». Эти типы включают права
tables:read,reservations:readиreservations:write, а также событияreservation.*. - В поле Webhook URL указывается HTTPS-адрес вашего обработчика.
- После сохранения MenuLab один раз показывает API Key, API Secret и — если указан Webhook URL — Webhook Secret. Если адрес webhook добавили позже, Webhook Secret покажут при его сохранении. Секреты нужно сразу передать разработчику защищённым каналом.
Проверить ключи и права до написания кода можно запросом GET /auth/me: в ответе — ресторан, тип клиента и список scopes, где должны быть reservations:read и reservations:write.
Все запросы выполняются с вашего сервера. API Secret нельзя размещать в JavaScript сайта или внутри мобильного приложения: браузер и приложение обращаются к вашему backend, а он — к MenuLab.
Адрес и заголовки
https://menulab.ru/api/v1
Ресторан определяется по API-ключу, передавать его ID не нужно.
X-API-Key: <api-key>
X-API-Secret: <api-secret>
Accept: application/json
Content-Type: application/json
X-Idempotency-Key: <уникальный-ключ-операции>
X-Idempotency-Key необязателен, но для создания, изменения и отмены брони используйте его всегда: при обрыве соединения повтор запроса с тем же ключом и тем же телом не создаст вторую бронь. Ключ — от 1 до 64 латинских букв, цифр, дефисов и подчёркиваний.
MenuLab хранит ответ на ключ 24 часа — включая ошибки 4xx. Один ключ — одна попытка с конкретным телом:
- при обрыве связи,
5xxили429повторяйте с тем же ключом и тем же телом; - после
409или422, а также если гость поменял время или стол, создайте новый ключ — иначе вернётся прежний ответ илиIDEMPOTENCY_KEY_REUSED.
Удобная схема: {ID заявки}-{номер попытки}, например booking-8f2c1a-1, затем booking-8f2c1a-2. Тело сравнивается побайтно, поэтому при повторе отправляйте ровно те же байты JSON.
Время и часовой пояс
Дата и время в запросах — местное время ресторана:
| Поле | Формат | Пример |
|---|---|---|
date |
YYYY-MM-DD |
2026-11-07 |
time |
HH:MM, 24 часа |
19:30 |
duration_minutes |
целое число, от 30 до 720 | 120 |
Если duration_minutes не передан, визит длится 120 минут. Визит может переходить через полночь: бронь на 23:00 длительностью 180 минут закончится в 02:00 следующего дня.
В ответах время приходит в двух видах: местные date, time_from, time_to и полные starts_at, ends_at в ISO 8601 со смещением пояса ресторана, например 2026-11-07T19:30:00.000000+05:00. Пояс ресторана указан в поле timezone.
Шаг 1. Получите столы
GET /tables — права tables:read.
curl "https://menulab.ru/api/v1/tables" \
-H "X-API-Key: $MENULAB_API_KEY" \
-H "X-API-Secret: $MENULAB_API_SECRET" \
-H "Accept: application/json"
{
"success": true,
"data": [
{
"id": 12,
"number": "5",
"name": "Стол 5",
"seats": 4,
"is_active": true,
"zone": {"id": 3, "name": "Основной зал", "is_active": true}
}
]
}
По умолчанию возвращаются только столы, которые можно бронировать: активные, в активных зонах. Подробнее — в статье «Столы, зоны и свободное время».
Шаг 2. Найдите свободный стол
POST /reservations/availability — права reservations:read.
curl -X POST "https://menulab.ru/api/v1/reservations/availability" \
-H "X-API-Key: $MENULAB_API_KEY" \
-H "X-API-Secret: $MENULAB_API_SECRET" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"date": "2026-11-07", "time": "19:30", "duration_minutes": 120, "guests_count": 3}'
{
"success": true,
"data": {
"date": "2026-11-07",
"time_from": "19:30",
"time_to": "21:30",
"starts_at": "2026-11-07T19:30:00+05:00",
"ends_at": "2026-11-07T21:30:00+05:00",
"duration_minutes": 120,
"timezone": "Asia/Yekaterinburg",
"guests_count": 3,
"available_tables": [
{
"id": 12,
"number": "5",
"name": "Стол 5",
"seats": 4,
"fits_guests": true,
"zone": {"id": 3, "name": "Основной зал"}
},
{
"id": 14,
"number": "6",
"name": "Стол 6",
"seats": 2,
"fits_guests": false,
"zone": {"id": 3, "name": "Основной зал"}
}
],
"total_available": 2,
"busy_table_ids": [11, 13]
}
}
В available_tables — столы, свободные на весь интервал визита. Сначала идут столы, где мест хватает (fits_guests: true), затем остальные. Вместимость не запрещает бронь: так же, как в кассе, администратор может посадить компанию за стол меньшего размера или объединить столы. Показывать гостю только столы с fits_guests: true или все — решаете вы.
Этот запрос ничего не резервирует. Пока гость заполняет форму, стол может занять другой гость, поэтому окончательную проверку выполняет создание брони.
Шаг 3. Создайте бронь
POST /reservations — права reservations:write.
curl -X POST "https://menulab.ru/api/v1/reservations" \
-H "X-API-Key: $MENULAB_API_KEY" \
-H "X-API-Secret: $MENULAB_API_SECRET" \
-H "X-Idempotency-Key: booking-8f2c1a-create" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"table_id": 12,
"date": "2026-11-07",
"time": "19:30",
"duration_minutes": 120,
"guests_count": 3,
"guest_name": "Анна Петрова",
"guest_phone": "+7 999 123-45-67",
"guest_email": "anna@example.com",
"comment": "У окна, будет ребёнок"
}'
| Поле | Обязательно | Описание |
|---|---|---|
table_id |
да | ID стола из /tables или /reservations/availability |
additional_table_ids |
нет | Дополнительные столы, если компанию сажают за несколько столов (до 10). Свободны должны быть все |
date |
да | Дата визита, местная, YYYY-MM-DD |
time |
да | Время начала, местное, HH:MM |
duration_minutes |
нет | Длительность визита, 30–720 минут, по умолчанию 120 |
guests_count |
да | Количество гостей, 1–50 |
guest_name |
да | Имя гостя, до 100 символов. MenuLab приводит регистр: «анна петрова» → «Анна Петрова» |
guest_phone |
да | Телефон гостя в любом формате: не меньше 10 цифр и не длиннее 20 символов вместе с пробелами и скобками |
guest_email |
нет | Email гостя |
comment |
нет | Пожелания гостя, до 500 символов. Видны администратору |
customer_id |
нет | ID клиента MenuLab, если ваша система уже знает его (например, из /customers/phone/{phone}) |
Ответ 201 Created:
{
"success": true,
"message": "Reservation created",
"data": {
"id": 845,
"status": "pending",
"status_label": "Ожидает подтверждения",
"source": "api",
"date": "2026-11-07",
"time_from": "19:30",
"time_to": "21:30",
"starts_at": "2026-11-07T19:30:00.000000+05:00",
"ends_at": "2026-11-07T21:30:00.000000+05:00",
"duration_minutes": 120,
"timezone": "Asia/Yekaterinburg",
"guests_count": 3,
"table": {"id": 12, "number": "5", "name": "Стол 5", "seats": 4, "zone": {"id": 3, "name": "Основной зал"}},
"tables": [
{"id": 12, "number": "5", "name": "Стол 5", "seats": 4, "zone": {"id": 3, "name": "Основной зал"}}
],
"guest": {"name": "Анна Петрова", "phone": "+7 999 123-45-67", "email": "anna@example.com"},
"customer_id": 2290,
"comment": "У окна, будет ребёнок",
"deposit": null,
"created_at": "2026-11-01T12:04:51.000000+05:00",
"updated_at": "2026-11-01T12:04:51.000000+05:00",
"confirmed_at": null,
"seated_at": null,
"completed_at": null,
"cancelled_at": null,
"cancellation_reason": null
}
}
Сохраните data.id — это ID брони в MenuLab. По нему бронь читают, изменяют и отменяют, и он же приходит в webhook.
Новая бронь всегда создаётся в статусе pending — заявка ждёт подтверждения ресторана. Сообщите гостю, что заявка принята, а когда придёт webhook со статусом confirmed, — что бронь подтверждена.
Если телефон уже есть в базе гостей ресторана, бронь привязывается к этому клиенту, иначе MenuLab создаёт новую карточку. ID клиента — в customer_id.
Если время занято
Если стол заняли, пока гость оформлял бронь, MenuLab ответит 409:
{
"success": false,
"error": {
"code": "TIME_SLOT_UNAVAILABLE",
"message": "The table is already booked for this time"
}
}
Повторять тот же запрос бесполезно. Снова запросите /reservations/availability и предложите гостю другой стол или время.
| HTTP | Код | Причина | Что делать |
|---|---|---|---|
| 409 | TIME_SLOT_UNAVAILABLE |
Стол уже забронирован на пересекающееся время | Предложить другой стол или время |
| 409 | TABLE_UNAVAILABLE |
Стол не найден, архивирован, его зона выключена или стол принадлежит другому ресторану | Обновить список столов |
| 422 | RESERVATION_IN_PAST |
Время начала уже прошло по часам ресторана | Проверить дату, время и пояс |
| 500 | INTERNAL_ERROR |
Бронь не сохранилась по внутренней причине | Повторить позже с тем же ключом; если повторяется — сообщить ресторану |
| 422 | — | Ошибка заполнения полей, см. ниже | Исправить данные формы |
| 403 | INSUFFICIENT_SCOPE |
У API-клиента нет нужного права | См. «Перед началом» |
Ошибки заполнения приходят с перечнем полей:
{
"success": false,
"message": "time is required (and 3 more errors)",
"errors": {
"time": ["time is required"],
"guests_count": ["guests count is required"],
"guest_name": ["guest name is required"],
"guest_phone": ["guest phone is required"]
}
}
Статусы брони
| Статус | Значение | Кто переводит |
|---|---|---|
pending |
Заявка ждёт подтверждения | Создаётся при POST /reservations |
confirmed |
Ресторан подтвердил бронь | Администратор ресторана |
seated |
Гости пришли и сидят за столом | Администратор или официант |
completed |
Визит завершён | Ресторан |
cancelled |
Бронь отменена | Ваша система через API или ресторан |
no_show |
Гость не пришёл | Ресторан вручную или MenuLab автоматически |
Переходы между статусами выполняет ресторан. Через API бронь можно создать, изменить, пока она в статусе pending или confirmed, и отменить. Статусы completed, cancelled и no_show — окончательные.
Человекочитаемое название статуса на русском приходит в status_label. Список статусов может расшириться: неизвестный статус показывайте по status_label, а не считайте ошибкой.
Изменить бронь
PATCH /reservations/{id} — права reservations:write. Передайте только поля, которые меняются.
curl -X PATCH "https://menulab.ru/api/v1/reservations/845" \
-H "X-API-Key: $MENULAB_API_KEY" \
-H "X-API-Secret: $MENULAB_API_SECRET" \
-H "X-Idempotency-Key: booking-8f2c1a-move-2000" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"time": "20:00", "guests_count": 4}'
Можно изменить: table_id, additional_table_ids (передайте [], чтобы оставить один стол), date, time, duration_minutes, guests_count, guest_name, guest_phone, guest_email, comment.
- Если меняется время или стол, MenuLab заново проверяет, свободен ли он. Сама бронь при этом своё время не блокирует.
- Если передан только
time, дата и длительность остаются прежними. - Новый
guest_phoneзаново привязывает бронь к клиенту с этим телефоном. - Изменить бронь в статусе
seated,completed,cancelledилиno_showнельзя — ответ409 RESERVATION_NOT_EDITABLE.
Ответ — бронь целиком, в том же формате, что при создании. Персонал получает уведомление об изменении.
Отменить бронь
POST /reservations/{id}/cancel — права reservations:write.
curl -X POST "https://menulab.ru/api/v1/reservations/845/cancel" \
-H "X-API-Key: $MENULAB_API_KEY" \
-H "X-API-Secret: $MENULAB_API_SECRET" \
-H "X-Idempotency-Key: booking-8f2c1a-cancel" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"reason": "Гость отменил на сайте"}'
reason необязателен, до 500 символов; администратор видит его в брони. Отмена освобождает стол.
Повторная отмена уже отменённой брони ничего не меняет и возвращает 200 с текущей бронью. Завершённую бронь, бронь с гостями за столом и неявку отменить нельзя — ответ 409 RESERVATION_NOT_CANCELLABLE.
Если по брони внесён депозит, деньги при отмене через API автоматически не возвращаются: возврат или удержание оформляет ресторан. Состояние депозита — в deposit.amount и deposit.status.
Прочитать брони
GET /reservations/{id} — одна бронь. GET /reservations — список с фильтрами, права reservations:read.
| Параметр | Описание |
|---|---|
date |
Брони на дату визита, YYYY-MM-DD |
from, to |
Период дат визита включительно |
status |
Один или несколько статусов через запятую: pending,confirmed |
table_id |
Брони конкретного стола, включая объединённые |
phone |
Брони гостя по телефону в любом формате: +7 999 123-45-67, 89991234567 |
page, per_page |
Страница и размер страницы, по умолчанию 20, максимум 100 |
curl "https://menulab.ru/api/v1/reservations?phone=89991234567&status=pending,confirmed" \
-H "X-API-Key: $MENULAB_API_KEY" \
-H "X-API-Secret: $MENULAB_API_SECRET" \
-H "Accept: application/json"
Список отсортирован по дате и времени визита. Сведения о страницах — в meta.pagination:
{
"success": true,
"data": [ { "id": 845, "status": "pending", "...": "..." } ],
"meta": {
"pagination": {"total": 1, "per_page": 20, "current_page": 1, "last_page": 1, "from": 1, "to": 1}
}
}
Список возвращает все брони ресторана, а не только созданные вашей интеграцией: так в личном кабинете гостя можно показать и бронь, оформленную по телефону. Источник брони — в поле source: api — внешняя система, website — сайт ресторана на MenuLab, vk — приложение ВКонтакте, staff — сотрудник ресторана, crm — старые брони, источник которых неизвестен.
Поле source: "api" одинаково у всех внешних систем ресторана. Чтобы отличать свои брони, храните id из ответа на создание — это единственная связь вашей заявки с бронью MenuLab.
Webhook о бронях
MenuLab сообщает о каждом изменении брони — откуда бы оно ни пришло: из вашей интеграции, кассы, приложения броней, сайта ресторана или автоматической отметки неявки.
| Событие | Когда |
|---|---|
reservation.created |
Создана новая бронь, в том числе сотрудником или с другого канала |
reservation.updated |
Бронь изменилась: статус, время, стол, гости, контакты, комментарий или депозит |
reservation.cancelled |
Бронь отменена. Приходит вместе с reservation.updated |
События получают только API-клиенты с правом reservations:read, потому что в брони есть телефон гостя.
{
"event_id": "1e3f8a52-7c3e-4b8e-9a41-0d52c7f1a9b0",
"event_type": "reservation.updated",
"timestamp": "2026-11-01T12:30:12+00:00",
"data": {
"reservation_id": 845,
"restaurant_id": 3,
"status": "confirmed",
"action": "updated",
"changes": {
"status": {"from": "pending", "to": "confirmed"}
},
"reservation": {
"id": 845,
"status": "confirmed",
"status_label": "Подтверждено",
"date": "2026-11-07",
"time_from": "19:30",
"time_to": "21:30",
"...": "бронь целиком, как в GET /reservations/{id}"
}
}
}
data.reservation— состояние брони после изменения, в том же формате, что в API.data.changes— что именно изменилось:{"поле": {"from": ..., "to": ...}}. Возможные поля:status,starts_at,ends_at,table_id,additional_table_ids,guests_count,guest_name,guest_phone,guest_email,comment,deposit_amount,deposit_status. Время вchanges— ISO 8601 в поясе ресторана.- Если вам важно только состояние, берите его из
data.reservation, а не собирайте изchanges.
Подпись, повторы доставки и дедупликация по event_id — общие для всех событий, см. статью «Webhook-события». Порядок событий не гарантируется: сравнивайте data.reservation.updated_at с сохранённым у вас и не перезаписывайте более новое состояние более старым.
Если webhook ещё не настроен или временно недоступен, сверяйте незавершённые брони через GET /reservations/{id} не чаще раза в минуту.
Пример: сервер сайта на Node.js
const API = "https://menulab.ru/api/v1";
const headers = {
"X-API-Key": process.env.MENULAB_API_KEY,
"X-API-Secret": process.env.MENULAB_API_SECRET,
"Accept": "application/json",
"Content-Type": "application/json",
};
async function menulab(method, path, body, idempotencyKey) {
const response = await fetch(API + path, {
method,
headers: idempotencyKey ? { ...headers, "X-Idempotency-Key": idempotencyKey } : headers,
body: body ? JSON.stringify(body) : undefined,
});
// Ответ не в JSON (например, страница ошибки прокси) — отдаём HTTP-статус.
const isJson = response.headers.get("content-type")?.includes("application/json");
const json = isJson ? await response.json() : { success: false, message: `HTTP ${response.status}` };
if (!json.success) {
const error = new Error(json.error?.message ?? json.message);
error.status = response.status;
error.code = json.error?.code ?? (response.status === 422 ? "VALIDATION_ERROR" : "UNKNOWN");
error.fields = json.errors;
throw error;
}
return json.data;
}
// Свободные столы для формы бронирования
export async function freeTables({ date, time, guests }) {
const data = await menulab("POST", "/reservations/availability", {
date, time, guests_count: guests, duration_minutes: 120,
});
return data.available_tables.filter((table) => table.fits_guests);
}
// Бронь из формы. requestId — ID заявки в вашей системе, attempt — номер попытки (новый после 409/422)
export async function book(form, requestId, attempt = 1) {
try {
return await menulab("POST", "/reservations", {
table_id: form.tableId,
date: form.date,
time: form.time,
guests_count: form.guests,
guest_name: form.name,
guest_phone: form.phone,
comment: form.comment,
}, `site-booking-${requestId}-${attempt}`);
} catch (error) {
if (error.code === "TIME_SLOT_UNAVAILABLE") {
// Стол только что заняли: заново показать свободное время
}
throw error;
}
}
Пример: PHP
<?php
function menulab(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
{
$headers = [
'X-API-Key: ' . getenv('MENULAB_API_KEY'),
'X-API-Secret: ' . getenv('MENULAB_API_SECRET'),
'Accept: application/json',
'Content-Type: application/json',
];
if ($idempotencyKey !== null) {
$headers[] = 'X-Idempotency-Key: ' . $idempotencyKey;
}
$curl = curl_init('https://menulab.ru/api/v1' . $path);
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body, JSON_UNESCAPED_UNICODE),
]);
$raw = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$json = json_decode((string) $raw, true);
if ($raw === false || empty($json['success'])) {
// У ошибок заполнения нет error.code — есть errors с перечнем полей.
$code = $json['error']['code'] ?? ($status === 422 ? 'VALIDATION_ERROR' : "HTTP {$status}");
throw new RuntimeException($code . ': ' . json_encode($json['errors'] ?? $json['message'] ?? null, JSON_UNESCAPED_UNICODE));
}
return $json['data'];
}
$reservation = menulab('POST', '/reservations', [
'table_id' => 12,
'date' => '2026-11-07',
'time' => '19:30',
'guests_count' => 3,
'guest_name' => 'Анна Петрова',
'guest_phone' => '+79991234567',
], 'site-booking-8f2c1a-1');
echo $reservation['id'], ' ', $reservation['status']; // 845 pending
Что хранить у себя
ваш ID заявки → MenuLab reservation_id → последний status → updated_at
По reservation_id вы найдёте бронь в API и сопоставите с ней webhook. Ключ идемпотентности стройте как {ID заявки}-{номер попытки}: повтор той же отправки после обрыва связи не создаст вторую бронь, а новая попытка после 409 получит свой ключ.
Проверка перед запуском
- Получите столы и свободное время на завтра.
- Создайте бронь — она появится у администратора в приложении броней со статусом «Ожидает подтверждения» и источником «Внешняя система».
- Повторите создание с тем же
X-Idempotency-Keyи телом — второй брони нет, в ответе заголовокX-Idempotent-Replayed: true. - Попробуйте занять тот же стол на пересекающееся время —
409 TIME_SLOT_UNAVAILABLE. - Попросите администратора подтвердить бронь — придёт
reservation.updatedсchanges.statuspending → confirmed. - Перенесите бронь через
PATCHи отмените через/cancel— придутreservation.updatedиreservation.cancelled. - Создайте бронь сотрудником в кассе — придёт
reservation.createdсsource: "staff". - Проверьте подпись каждого webhook и повторную доставку одного
event_id.
Нужны ключи или права для интеграции? Их выдаёт администратор ресторана в бэк-офисе: Настройки → Интеграции → API-клиенты.