MenuLabБаза знаний Быстрый старт API

PUBLIC API V1 · ДЛЯ РАЗРАБОТЧИКОВ

Бронирование столов

Свободные столы, создание, перенос и отмена брони, статусы и webhook.

Содержание

Что умеет API бронирования

Через Public API ваш сайт, мобильное приложение, агрегатор или внешняя CRM может:

  • получить столы и зоны зала ресторана;
  • узнать, какие столы свободны на нужные дату, время и длительность визита;
  • создать бронь от имени гостя;
  • изменить время, количество гостей, стол, контакты или комментарий;
  • отменить бронь;
  • получать webhook, когда ресторан подтверждает бронь, сажает гостей, отмечает неявку или меняет бронь сам.

Бронь из API — обычная бронь MenuLab. Она сразу появляется у администратора в кассе и в приложении броней, проверяется на пересечения так же, как бронь, внесённая сотрудником, привязывается к карточке гостя по телефону, а персонал получает уведомление о новой заявке.

Подключение по шагам

  1. Ресторан создаёт API-клиент типа «Сайт» или «Приложение» с вашим Webhook URL и передаёт вам ключи.
  2. GET /auth/me — в scopes есть reservations:write.
  3. GET /tables — столы и зоны.
  4. POST /reservations/availability — свободные столы на время гостя.
  5. POST /reservations с ключом {заявка}-{попытка} — бронь в статусе pending.
  6. Webhook: проверка подписи, сохранение по event_id, ответ 2xx.
  7. Перенос — PATCH /reservations/{id}, отмена — POST /reservations/{id}/cancel.
  8. Сверка по GET /reservations/{id} — если webhook не дошёл.

Ниже — каждый шаг подробно.

Перед началом

  1. Администратор ресторана открывает в бэк-офисе Настройки → Интеграции → API-клиенты и нажимает «Добавить API-клиент».
  2. Тип интеграции — «Сайт» или «Приложение». Эти типы включают права tables:read, reservations:read и reservations:write, а также события reservation.*.
  3. В поле Webhook URL указывается HTTPS-адрес вашего обработчика.
  4. После сохранения 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 получит свой ключ.

Проверка перед запуском

  1. Получите столы и свободное время на завтра.
  2. Создайте бронь — она появится у администратора в приложении броней со статусом «Ожидает подтверждения» и источником «Внешняя система».
  3. Повторите создание с тем же X-Idempotency-Key и телом — второй брони нет, в ответе заголовок X-Idempotent-Replayed: true.
  4. Попробуйте занять тот же стол на пересекающееся время — 409 TIME_SLOT_UNAVAILABLE.
  5. Попросите администратора подтвердить бронь — придёт reservation.updated с changes.status pending → confirmed.
  6. Перенесите бронь через PATCH и отмените через /cancel — придут reservation.updated и reservation.cancelled.
  7. Создайте бронь сотрудником в кассе — придёт reservation.created с source: "staff".
  8. Проверьте подпись каждого webhook и повторную доставку одного event_id.

Нужны ключи или права для интеграции? Их выдаёт администратор ресторана в бэк-офисе: Настройки → Интеграции → API-клиенты.