{
  "openapi": "3.1.0",
  "info": {
    "title": "OmniGate API",
    "version": "1.0.0",
    "summary": "Единый REST API к WhatsApp, Telegram, VK, MAX, Instagram и чату на сайте клиента.",
    "description": "OmniGate — шлюз сообщений: один HTTP-интерфейс ко всем мессенджерам сразу, вебхуки на входящие и готовая интеграция с amoCRM.\n\n## Авторизация\nВсе запросы к `/api/v1/*` требуют заголовок `X-API-Key: og_live_…` (принимается и `Authorization: Bearer og_live_…`). Ключ выпускается в кабинете https://omnigate.website/app, раздел «API». Ключ видит только каналы своего аккаунта.\n\n## Права ключа\nПри выпуске ключа в кабинете можно отметить только нужные права — удобно, когда ключ отдаётся подрядчику или ИИ-агенту. Ключ, выпущенный без ограничений (и любой ключ, выпущенный до появления прав), может всё.\n\n| Право | Что открывает |\n|---|---|\n| `sessions:read` | `GET /api/v1/sessions`, `GET /api/v1/sessions/{id}` |\n| `sessions:write` | создание, изменение и удаление канала, ввод кода, resolve |\n| `messages:read` | `GET /api/v1/messages` |\n| `messages:send` | `POST /api/v1/messages`, `POST /api/v1/messages/upload` |\n| `messages:inbound` | `POST /api/v1/messages/inbound` |\n| `events:read` | `GET /api/v1/events` (SSE) |\n| `webhook:read` | `GET /api/v1/webhook`, `GET /api/v1/webhook/deliveries` |\n| `webhook:write` | смена адреса и секрета, тест и повтор доставки |\n| `webchat:read` | `GET /api/v1/webchat` |\n| `webchat:write` | создание, настройка и удаление виджета |\n| `account:read` | `GET /api/v1/account` |\n\nЕсли права не хватает — ответ 403 с полями `scope` (какое право нужно) и `scopes` (какие есть у ключа). Права можно изменить в кабинете, не перевыпуская сам ключ. Ключ с ограниченными правами не попадёт и на эндпоинт, для которого право ещё не заведено, — тоже 403.\n\nРучки `/api/v1/webhook`, `/api/v1/webchat`, `/api/v1/events` и `/api/v1/account` работают от лица владельца ключа и возвращают данные того аккаунта, которому ключ выдан.\n\n## Базовые понятия\n- **Канал** (в API — session) — подключённый аккаунт мессенджера или виджет чата на сайте. У канала есть `id` (UUID), `channel` и `status`.\n- **Диалог** (`chatId`) — собеседник внутри канала. Формат зависит от канала: телефон для WhatsApp, числовой id для Telegram/VK, произвольный ключ для своего канала.\n- **Входящие** приходят POST-ом на ваш webhook (см. раздел `webhooks`) и одновременно сохраняются в историю.\n- **Исходящие** отправляются через `POST /api/v1/messages` и автоматически зеркалятся в amoCRM, если она подключена.\n\n## Ограничения\n- 120 запросов в минуту на аккаунт. При превышении — 429 с полем `retryAfter` и заголовком `Retry-After` (секунды).\n- Текст входящего через `/api/v1/messages/inbound` — не длиннее 4000 символов.\n- Файл в `/api/v1/messages/upload` — не больше 25 МБ.\n- Число каналов и сообщений в месяц ограничено тарифом; при превышении — 402.\n\n## Формат ошибок\nЛюбая ошибка возвращает `{ \"ok\": false, \"error\": \"текст на русском\" }` с соответствующим HTTP-кодом. Успешный ответ всегда содержит `\"ok\": true`.\n\n## Быстрый старт\n1. `POST /api/v1/sessions` с телом `{\"channel\":\"webchat\"}` — в ответе `session.id`, `session.publicKey` и готовый `session.embed`.\n2. Вставьте `session.embed` на сайт — посетители пишут в виджет.\n3. Задайте адрес доставки входящих: `PATCH /api/v1/sessions/{id}` с `{\"webhookUrl\":\"https://ваш-сервис/hook\"}`.\n4. Отвечайте через `POST /api/v1/messages` с `{sessionId, chatId, text}`.",
    "termsOfService": "https://omnigate.website/oferta",
    "contact": {
      "name": "Поддержка OmniGate",
      "url": "https://omnigate.website/contact"
    },
    "license": {
      "name": "Условия использования OmniGate",
      "url": "https://omnigate.website/oferta"
    }
  },
  "externalDocs": {
    "description": "Документация для людей (примеры curl, подключение каналов, amoCRM)",
    "url": "https://omnigate.website/docs"
  },
  "servers": [
    {
      "url": "https://omnigate.website",
      "description": "Продакшн"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    },
    {
      "BearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Каналы",
      "description": "Подключение и настройка каналов (мессенджеры, виджет на сайте)."
    },
    {
      "name": "Сообщения",
      "description": "Отправка, приём и история сообщений."
    },
    {
      "name": "Вебхук",
      "description": "Глобальный вебхук аккаунта: адрес, секрет подписи, журнал доставок."
    },
    {
      "name": "Веб-чат",
      "description": "Виджеты веб-чата: создание, настройка, удаление."
    },
    {
      "name": "Аккаунт",
      "description": "Тариф, лимиты и текущий расход владельца ключа."
    },
    {
      "name": "Запись клиентов",
      "description": "Расписание салона и запись клиентов: услуги, мастера, свободное время, создание записи."
    },
    {
      "name": "Сервис",
      "description": "Служебные ручки без авторизации."
    },
    {
      "name": "MCP",
      "description": "Тот же API для ИИ-агентов по Model Context Protocol."
    }
  ],
  "paths": {
    "/api/v1/sessions": {
      "post": {
        "tags": [
          "Каналы"
        ],
        "operationId": "createSession",
        "summary": "Создать и запустить канал",
        "description": "Создаёт канал и сразу поднимает подключение. Для `webchat` учётные данные генерирует сервер (присланные игнорируются) и ответ дополнительно содержит `publicKey` и готовый `embed` — HTML-сниппет виджета. Ключ отдаётся ровно один раз, при создании.\n\nПсевдонимы `site`, `web`, `custom`, `widget` означают тот же `webchat`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateSessionRequest"
              },
              "examples": {
                "webchat": {
                  "summary": "Чат на своём сайте",
                  "value": {
                    "channel": "webchat",
                    "title": "Чат с нами",
                    "color": "#4f46e5",
                    "welcome": "Здравствуйте! Чем можем помочь?"
                  }
                },
                "telegramBot": {
                  "summary": "Telegram-бот",
                  "value": {
                    "channel": "telegram",
                    "credentials": {
                      "botToken": "1234567:AA…"
                    },
                    "webhookUrl": "https://example.com/omnigate"
                  }
                },
                "vk": {
                  "summary": "Сообщество VK",
                  "value": {
                    "channel": "vk",
                    "credentials": {
                      "groupId": 123456789,
                      "accessToken": "vk1.a.…"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Канал создан и запущен",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "session"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "session": {
                      "$ref": "#/components/schemas/WebchatSession"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "Каналы"
        ],
        "operationId": "listSessions",
        "summary": "Список каналов со статистикой",
        "description": "Возвращает каналы, доступные ключу, вместе со сводкой по сообщениям (`stats`).",
        "responses": {
          "200": {
            "description": "Список каналов",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "sessions"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "sessions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SessionWithStats"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/sessions/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Идентификатор канала (UUID)",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "tags": [
          "Каналы"
        ],
        "operationId": "getSession",
        "summary": "Статус канала и QR-код",
        "description": "Для WhatsApp и Telegram в режиме личного аккаунта клиент опрашивает этот эндпоинт, пока `session.status` не станет `connected`: пока статус `qr`, поле `qr` содержит data-URL картинки, которую нужно отсканировать в мобильном приложении.",
        "responses": {
          "200": {
            "description": "Состояние канала",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "session"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "session": {
                      "$ref": "#/components/schemas/Session"
                    },
                    "qr": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "QR в виде data-URL, если канал ждёт сканирования"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "patch": {
        "tags": [
          "Каналы"
        ],
        "operationId": "updateSession",
        "summary": "Настроить канал",
        "description": "Меняет настройки канала. Нужно передать хотя бы одно из полей `webhookUrl`, `deliveryWebhookUrl`, `mirrorAmocrm`, `aiAgent` — иначе 400.\n\nПустая строка или `null` в `webhookUrl` / `deliveryWebhookUrl` очищает адрес: тогда входящие уходят на глобальный вебхук аккаунта. URL обязан быть публичным http/https — приватные адреса отклоняются (защита от SSRF).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateSessionRequest"
              },
              "examples": {
                "setWebhook": {
                  "summary": "Задать вебхук входящих",
                  "value": {
                    "webhookUrl": "https://example.com/omnigate"
                  }
                },
                "clearWebhook": {
                  "summary": "Очистить вебхук канала",
                  "value": {
                    "webhookUrl": null
                  }
                },
                "muteAmocrm": {
                  "summary": "Не зеркалить канал в amoCRM",
                  "value": {
                    "mirrorAmocrm": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/SessionOk"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "Каналы"
        ],
        "operationId": "deleteSession",
        "summary": "Остановить и удалить канал",
        "description": "Останавливает подключение и удаляет канал. Действие необратимо: для мессенджеров потребуется заново авторизоваться, для виджета — сгенерируется новый `publicKey`.",
        "responses": {
          "200": {
            "description": "Канал удалён",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/sessions/{id}/code": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Идентификатор канала (UUID)",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "tags": [
          "Каналы"
        ],
        "operationId": "submitSessionCode",
        "summary": "Подтвердить вход кодом или паролем 2FA",
        "description": "Для каналов со входом по номеру телефона (личный Telegram, MAX). Когда канал в статусе `awaiting_code` — пришлите `code` из SMS или приложения. Если после этого статус стал `awaiting_password`, пришлите только `password` — облачный пароль двухфакторной защиты.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "code": {
                    "type": "string",
                    "description": "Код подтверждения из SMS или приложения"
                  },
                  "password": {
                    "type": "string",
                    "description": "Облачный пароль 2FA (шаг после кода)"
                  }
                },
                "anyOf": [
                  {
                    "required": [
                      "code"
                    ]
                  },
                  {
                    "required": [
                      "password"
                    ]
                  }
                ]
              },
              "examples": {
                "code": {
                  "summary": "Шаг 1: код",
                  "value": {
                    "code": "12345"
                  }
                },
                "password": {
                  "summary": "Шаг 2: пароль 2FA",
                  "value": {
                    "password": "мой-облачный-пароль"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/SessionOk"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/sessions/{id}/resolve": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Идентификатор канала (UUID)",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "tags": [
          "Каналы"
        ],
        "operationId": "resolveTarget",
        "summary": "Найти chatId по @username",
        "description": "Превращает `@username` в числовой `chatId`, пригодный для `POST /api/v1/messages`, и заодно проверяет, что такой собеседник существует. Работает только для Telegram в режиме личного аккаунта — Bot API так не умеет.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "username"
                ],
                "properties": {
                  "username": {
                    "type": "string",
                    "description": "Имя пользователя, с @ или без",
                    "examples": [
                      "@durov"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Собеседник найден",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ResolveResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/messages": {
      "get": {
        "tags": [
          "Сообщения"
        ],
        "operationId": "listMessages",
        "summary": "История сообщений",
        "description": "Возвращает сообщения по каналам, доступным ключу, от новых к старым. Постраничный обход: возьмите `created_at` последнего элемента и передайте его в `before` следующего запроса.\n\nИстория хранится ограниченное время и периодически чистится — для долгого хранения сохраняйте сообщения у себя при получении на вебхук.",
        "parameters": [
          {
            "name": "sessionId",
            "in": "query",
            "required": false,
            "description": "Ограничить одним каналом",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Сколько вернуть: от 1 до 200",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "description": "Только сообщения старше этой метки времени (Unix, мс)",
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Сообщения от новых к старым",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "messages"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "messages": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Message"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Сообщения"
        ],
        "operationId": "sendMessage",
        "summary": "Отправить сообщение",
        "description": "Единая отправка в любой канал: передайте либо `text`, либо `mediaUrl` (с необязательными `kind` и `caption`). Отправленное сообщение попадает в историю и зеркалится в amoCRM от имени «Оператор», если интеграция подключена и у канала не выключен `mirrorAmocrm`.\n\n`chatId` — в формате конкретного канала: телефон для WhatsApp, числовой id для Telegram и VK, `chatId` из вебхука для своего канала.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendMessageRequest"
              },
              "examples": {
                "text": {
                  "summary": "Текст",
                  "value": {
                    "sessionId": "3f2b…",
                    "chatId": "79991234567",
                    "text": "Здравствуйте! Заказ уже в пути."
                  }
                },
                "media": {
                  "summary": "Картинка по ссылке",
                  "value": {
                    "sessionId": "3f2b…",
                    "chatId": "79991234567",
                    "mediaUrl": "https://example.com/photo.jpg",
                    "kind": "photo",
                    "caption": "Ваш заказ"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Сообщение принято каналом",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SendResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/ChannelRejected"
          }
        }
      }
    },
    "/api/v1/messages/inbound": {
      "post": {
        "tags": [
          "Сообщения"
        ],
        "operationId": "receiveInbound",
        "summary": "Принять входящее из своего канала",
        "description": "Мост для источников, которых нет среди мессенджеров: чат на своём сайте, мобильное приложение, любой самописный фронт. Сообщение проходит тот же конвейер, что и из мессенджера: история, живое обновление кабинета, зеркало в amoCRM, вебхук владельца.\n\nЕсли источник сообщения и приёмник вебхука — один и тот же ваш сервис, передайте `webhook: false`, чтобы не получить обратно то, что сами же прислали.\n\nЭто серверная замена публичной ручке виджета `/webchat/{publicKey}/message`, у которой лимит 30 сообщений в минуту на IP: при проксировании через свой бэкенд весь трафик идёт с одного адреса и упирается в этот лимит. Здесь работает обычный лимит 120 запросов в минуту на ключ.\n\nОтвечать на такой диалог можно обычным `POST /api/v1/messages`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InboundRequest"
              },
              "examples": {
                "text": {
                  "summary": "Сообщение с сайта",
                  "value": {
                    "sessionId": "3f2b…",
                    "chatId": "visitor-42",
                    "text": "Здравствуйте, когда доставите?",
                    "fromName": "Анна"
                  }
                },
                "noWebhook": {
                  "summary": "Без обратного вебхука",
                  "value": {
                    "sessionId": "3f2b…",
                    "chatId": "visitor-42",
                    "text": "Привет",
                    "webhook": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Входящее принято",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "chatId"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "chatId": {
                      "type": "string",
                      "description": "Нормализованный chatId (обрезан до 120 символов)"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/messages/upload": {
      "post": {
        "tags": [
          "Сообщения"
        ],
        "operationId": "uploadAndSend",
        "summary": "Загрузить файл и отправить его в чат",
        "description": "Принимает файл в base64, сохраняет его на своей стороне и одним запросом отправляет собеседнику. Публичный адрес файла возвращается каналу и попадает в историю; тип (фото / видео / документ) определяется по расширению `filename`.\n\nПредел — 25 МБ на файл. Если файл уже лежит в интернете, дешевле отправить его через `POST /api/v1/messages` с `mediaUrl`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UploadRequest"
              },
              "examples": {
                "invoice": {
                  "summary": "Счёт в PDF",
                  "value": {
                    "sessionId": "3f2b…",
                    "chatId": "79991234567",
                    "filename": "schet-2026-07.pdf",
                    "dataBase64": "JVBERi0xLjQK…",
                    "caption": "Счёт на оплату"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Файл отправлен",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SendResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/ChannelRejected"
          }
        }
      }
    },
    "/api/v1/events": {
      "get": {
        "tags": [
          "Сообщения"
        ],
        "operationId": "streamEvents",
        "summary": "Поток событий аккаунта (SSE)",
        "description": "Держит открытое соединение и присылает событие на каждое новое сообщение аккаунта — входящее и исходящее. Формат — Server-Sent Events: строки `data: <json>`, разделённые пустой строкой. Раз в 25 секунд сервер шлёт комментарий-пинг (`: ping`), чтобы прокси не закрыл простаивающее соединение, и передаёт `retry: 5000` — интервал переподключения.\n\nПолезная нагрузка события: `{ \"type\": \"message\", \"sessionId\": \"…\", \"direction\": \"in\", \"chatId\": \"79990000000\", \"at\": 1730000000000 }`.\n\nЭто уведомление, а не доставка: текста сообщения в событии нет — заберите его через `GET /api/v1/messages`. Для интеграций «сервер-сервер» надёжнее вебхук: он повторяет неудачные доставки, а SSE — нет.\n\nРаботает от лица владельца ключа. Требует право `events:read`.",
        "responses": {
          "200": {
            "description": "Открытый поток событий",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                },
                "example": "retry: 5000\n\n: connected\n\ndata: {\"type\":\"message\",\"sessionId\":\"3f1c8a92-...\",\"direction\":\"in\",\"chatId\":\"79990000000\",\"at\":1730000000000}\n\n"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/OwnerKeyRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhook": {
      "get": {
        "tags": [
          "Вебхук"
        ],
        "operationId": "getWebhook",
        "summary": "Адрес и секрет глобального вебхука",
        "description": "Возвращает адрес, на который OmniGate шлёт входящие сообщения всего аккаунта, и секрет подписи. Вебхук, заданный на конкретном канале (поле `webhookUrl` в `PATCH /api/v1/sessions/{id}`), приоритетнее глобального.\n\nТребует право `webhook:read`.",
        "responses": {
          "200": {
            "description": "Текущие настройки",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "url": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "uri",
                      "description": "Адрес глобального вебхука; `null` — не задан"
                    },
                    "secret": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Секрет для проверки подписи `X-OmniGate-Signature`"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "url": "https://example.com/hooks/omnigate",
                  "secret": "whs_9f2c1b7a4d6e"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/OwnerKeyRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Вебхук"
        ],
        "operationId": "setWebhook",
        "summary": "Задать или очистить адрес вебхука",
        "description": "Пустая строка (или отсутствующее поле `url`) очищает адрес — доставка входящих на уровне аккаунта прекращается. Адрес обязан быть публичным `http`/`https`: приватные и локальные диапазоны отклоняются.\n\nТребует право `webhook:write`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uri",
                    "description": "Публичный http/https адрес; пустая строка или null — очистить"
                  }
                }
              },
              "example": {
                "url": "https://example.com/hooks/omnigate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Адрес сохранён",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "url": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "uri"
                    },
                    "secret": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "url": "https://example.com/hooks/omnigate",
                  "secret": "whs_9f2c1b7a4d6e"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhook/rotate": {
      "post": {
        "tags": [
          "Вебхук"
        ],
        "operationId": "rotateWebhookSecret",
        "summary": "Перевыпустить секрет подписи",
        "description": "Генерирует новый секрет и сразу возвращает его. Старый перестаёт действовать в тот же момент — обновите его на своей стороне до следующего входящего сообщения.\n\nТребует право `webhook:write`.",
        "responses": {
          "200": {
            "description": "Новый секрет",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "secret"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "secret": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "secret": "whs_5b3e0d81c7a2"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/OwnerKeyRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhook/test": {
      "post": {
        "tags": [
          "Вебхук"
        ],
        "operationId": "testWebhook",
        "summary": "Отправить тестовую доставку",
        "description": "Шлёт на указанный адрес (или на сохранённый, если тело пустое) подписанный тестовый payload с `event: \"test\"`. Перед отправкой хост резолвится и проверяется на принадлежность к публичным адресам.\n\nОтвет `200` означает, что попытка состоялась: поле `ok` показывает, ответила ли ваша сторона кодом `2xx`, а `status` — какой именно код вернулся. Сетевая ошибка или таймаут (10 секунд) дают `502`.\n\nТребует право `webhook:write`.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Куда отправить тест; по умолчанию — сохранённый адрес"
                  }
                }
              },
              "example": {
                "url": "https://example.com/hooks/omnigate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Попытка выполнена — смотрите `ok` и `status`",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "description": "true, если ваш сервер ответил 2xx"
                    },
                    "status": {
                      "type": "integer",
                      "description": "HTTP-код ответа вашего сервера"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "status": 200
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "До адреса не удалось достучаться: сеть недоступна или истёк таймаут",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhook/deliveries": {
      "get": {
        "tags": [
          "Вебхук"
        ],
        "operationId": "listWebhookDeliveries",
        "summary": "Журнал последних доставок",
        "description": "Последние 50 доставок вебхука по аккаунту, свежие сверху. Пригодно для разбора «почему не пришло»: видно число попыток, код ответа и текст ошибки.\n\nТребует право `webhook:read`.",
        "responses": {
          "200": {
            "description": "Журнал доставок",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "deliveries"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "deliveries": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookDelivery"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/OwnerKeyRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhook/deliveries/{id}/retry": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Идентификатор доставки из журнала",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Вебхук"
        ],
        "operationId": "retryWebhookDelivery",
        "summary": "Повторить доставку",
        "description": "Возвращает доставку в очередь. Ответ `200` означает «поставлено в очередь», а не «доставлено» — результат смотрите в журнале.\n\nТребует право `webhook:write`.",
        "responses": {
          "200": {
            "description": "Поставлено в очередь",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  }
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/OwnerKeyRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webchat": {
      "get": {
        "tags": [
          "Веб-чат"
        ],
        "operationId": "listWebchatWidgets",
        "summary": "Список виджетов веб-чата",
        "description": "Все виджеты аккаунта вместе с готовым HTML-сниппетом для вставки на сайт.\n\nТребует право `webchat:read`.",
        "responses": {
          "200": {
            "description": "Виджеты аккаунта",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "widgets"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "widgets": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebchatWidget"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/OwnerKeyRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Веб-чат"
        ],
        "operationId": "createWebchatWidget",
        "summary": "Создать виджет",
        "description": "Создаёт виджет и сразу поднимает его. Виджет — это канал, поэтому он занимает место в лимите каналов тарифа: при исчерпании лимита придёт `402`.\n\nТо же самое можно сделать через `POST /api/v1/sessions` с `channel: \"webchat\"`; эта ручка удобнее, когда нужны оформление и кнопки мессенджеров.\n\nТребует право `webchat:write`.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebchatWidgetInput"
              },
              "example": {
                "title": "Чат с нами",
                "color": "#4f46e5",
                "welcome": "Здравствуйте! Чем можем помочь?",
                "webchatEnabled": true,
                "channels": [
                  {
                    "type": "whatsapp",
                    "value": "79990000000",
                    "label": "WhatsApp"
                  }
                ],
                "webhookUrl": "https://example.com/hooks/widget"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/WebchatWidgetOk"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimit"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webchat/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "Идентификатор виджета (он же идентификатор канала)",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "tags": [
          "Веб-чат"
        ],
        "operationId": "updateWebchatWidget",
        "summary": "Изменить оформление виджета",
        "description": "Меняются только переданные поля. `channels` заменяется целиком, а не дополняется; передайте пустой массив, чтобы убрать все кнопки. `webhookUrl: null` или пустая строка очищает адрес — входящие снова пойдут на глобальный вебхук аккаунта.\n\nТребует право `webchat:write`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebchatWidgetInput"
              },
              "example": {
                "title": "Поддержка",
                "color": "#0ea5e9"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/WebchatWidgetOk"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "Веб-чат"
        ],
        "operationId": "deleteWebchatWidget",
        "summary": "Удалить виджет",
        "description": "Останавливает и удаляет виджет; место в лимите каналов освобождается. Сниппет, уже вставленный на сайт, перестаёт работать.\n\nТребует право `webchat:write`.",
        "responses": {
          "200": {
            "description": "Виджет удалён",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  }
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/OwnerKeyRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/account": {
      "get": {
        "tags": [
          "Аккаунт"
        ],
        "operationId": "getAccount",
        "summary": "Тариф, лимиты и расход",
        "description": "Сводка по аккаунту, которому выдан ключ: тариф, состояние подписки и сколько из лимитов уже израсходовано в текущем календарном месяце. Удобно, чтобы заранее понять, пройдёт ли отправка, и не упереться в `402` посреди рассылки.\n\nПри безлимитном тарифе `plan.maxMessages` и `usage.messagesLeft` равны `null`, а `plan.unlimitedMessages` — `true`.\n\nТолько чтение: сменить тариф через API нельзя, оплата идёт через кабинет. Требует право `account:read`.",
        "responses": {
          "200": {
            "description": "Состояние аккаунта",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "account",
                    "plan",
                    "usage"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "account": {
                      "$ref": "#/components/schemas/Account"
                    },
                    "plan": {
                      "$ref": "#/components/schemas/AccountPlan"
                    },
                    "subscription": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/AccountSubscription"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "usage": {
                      "$ref": "#/components/schemas/AccountUsage"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "account": {
                    "id": "3f1c8a92-...",
                    "email": "you@example.com",
                    "createdAt": 1727000000000
                  },
                  "plan": {
                    "id": "pro",
                    "title": "Про",
                    "price": 2990,
                    "maxSessions": 5,
                    "maxMessages": 20000,
                    "unlimitedMessages": false
                  },
                  "subscription": {
                    "planId": "pro",
                    "status": "active",
                    "currentPeriodEnd": 1732000000000,
                    "quantity": 1,
                    "periodMonths": 1
                  },
                  "usage": {
                    "periodStart": 1730419200000,
                    "channels": 3,
                    "channelsActive": 2,
                    "channelsLeft": 3,
                    "messagesThisMonth": 1840,
                    "messagesLeft": 18160
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/OwnerKeyRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/booking/branches": {
      "get": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingBranches",
        "summary": "Филиалы, подключённые к аккаунту",
        "description": "Салоны и клиники, которые владелец ключа подключил в кабинете, раздел YCLIENTS. Возвращает `companyId` — он же `branchId` во всех остальных ручках — и название.\n\nЧужой филиал через API недоступен: список берётся из подключений владельца, номер подобрать нельзя, незнакомый `branchId` даёт 404. Если филиал один, дальше `branchId` можно не передавать.\n\nЕсли не подключено ничего, приходит 200 с пустым списком, а не ошибка.",
        "responses": {
          "200": {
            "description": "Список подключённых филиалов",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "branches"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "branches": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BookingBranch"
                      }
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "branches": [
                    {
                      "companyId": "2121042",
                      "title": "Барбершоп на Ленина"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          }
        }
      }
    },
    "/api/v1/booking/services": {
      "get": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingServices",
        "summary": "Услуги филиала",
        "description": "Услуги, на которые филиал принимает онлайн-запись: `id`, название, вилка цены и длительность сеанса.\n\nСкрытые и выключенные услуги не отдаются — предлагать их клиенту нельзя. Параметр `staffId` оставит только те услуги, которые делает этот мастер.",
        "parameters": [
          {
            "name": "branchId",
            "in": "query",
            "required": false,
            "description": "Филиал из `/branches`. Обязателен, только если филиалов несколько",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "staffId",
            "in": "query",
            "required": false,
            "description": "Оставить услуги только этого мастера",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Услуги, доступные для онлайн-записи",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "branchId",
                    "services"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "branchId": {
                      "type": "string"
                    },
                    "services": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BookingService"
                      }
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "branchId": "2121042",
                  "services": [
                    {
                      "id": 30759483,
                      "title": "Мужская стрижка",
                      "categoryId": 8721340,
                      "priceMin": 800,
                      "priceMax": 800,
                      "durationSec": 3600
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BookingBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/BookingBranchNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BookingNoBranch"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/BookingProviderDown"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          }
        }
      }
    },
    "/api/v1/booking/staff": {
      "get": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingStaff",
        "summary": "Мастера филиала",
        "description": "Мастера, к которым филиал реально принимает онлайн-запись: `id`, имя, специализация, рейтинг.\n\nМастера, закрытые для онлайн-записи, отфильтрованы — в расписании салона они видны, но запись на них не примут. Параметр `serviceId` оставит только тех, кто делает эту услугу.",
        "parameters": [
          {
            "name": "branchId",
            "in": "query",
            "required": false,
            "description": "Филиал из `/branches`. Обязателен, только если филиалов несколько",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "serviceId",
            "in": "query",
            "required": false,
            "description": "Оставить мастеров, которые делают эту услугу",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Мастера, доступные для онлайн-записи",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "branchId",
                    "staff"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "branchId": {
                      "type": "string"
                    },
                    "staff": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BookingStaff"
                      }
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "branchId": "2121042",
                  "staff": [
                    {
                      "id": 5813061,
                      "name": "Михаил Литвинов",
                      "specialization": "Парикмахер",
                      "rating": 5
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BookingBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/BookingBranchNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BookingNoBranch"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/BookingProviderDown"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          }
        }
      }
    },
    "/api/v1/booking/dates": {
      "get": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingDates",
        "summary": "Дни, в которые есть свободное время",
        "description": "Дни, в которые остался хотя бы один свободный слот, в виде `2026-08-07`. Сужается параметрами `staffId` и `serviceId`.\n\nОтсюда берётся дата для `/slots`. Перебирать календарь днём за днём не нужно и вредно: это упирается в лимит 120 запросов в минуту.",
        "parameters": [
          {
            "name": "branchId",
            "in": "query",
            "required": false,
            "description": "Филиал из `/branches`. Обязателен, только если филиалов несколько",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "staffId",
            "in": "query",
            "required": false,
            "description": "Дни только этого мастера",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "serviceId",
            "in": "query",
            "required": false,
            "description": "Дни, когда доступна эта услуга",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Дни со свободным временем",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "branchId",
                    "dates"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "branchId": {
                      "type": "string"
                    },
                    "dates": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "examples": [
                          "2026-08-07"
                        ]
                      }
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "branchId": "2121042",
                  "dates": [
                    "2026-08-06",
                    "2026-08-07",
                    "2026-08-08"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BookingBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/BookingBranchNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BookingNoBranch"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/BookingProviderDown"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          }
        }
      }
    },
    "/api/v1/booking/slots": {
      "get": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingSlots",
        "summary": "Свободное время мастера на день",
        "description": "Свободное время конкретного мастера на конкретный день.\n\nПоле `time` («9:00») показывают клиенту. Поле `datetime` отдают обратно в `/check` и `/create` **ровно в том виде, в каком оно пришло**, вместе со смещением часового пояса: смещение принадлежит филиалу, а не вам и не UTC. Пересчёт сдвинет запись на час, а собранная вручную строка получит отказ 409, даже если время свободно.\n\nСлот свободен на момент ответа, а не навсегда: между показом и записью его может занять живой человек через сайт салона или администратор по телефону. Поэтому при записи он проверяется заново.",
        "parameters": [
          {
            "name": "branchId",
            "in": "query",
            "required": false,
            "description": "Филиал из `/branches`. Обязателен, только если филиалов несколько",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "staffId",
            "in": "query",
            "required": true,
            "description": "Мастер из `/staff`",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "date",
            "in": "query",
            "required": true,
            "description": "День в виде `2026-08-07`, из `/dates`",
            "schema": {
              "type": "string",
              "examples": [
                "2026-08-07"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Свободные слоты на этот день",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "branchId",
                    "staffId",
                    "date",
                    "slots"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "branchId": {
                      "type": "string"
                    },
                    "staffId": {
                      "type": "integer"
                    },
                    "date": {
                      "type": "string"
                    },
                    "slots": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BookingSlot"
                      }
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "branchId": "2121042",
                  "staffId": 5813061,
                  "date": "2026-08-07",
                  "slots": [
                    {
                      "time": "9:00",
                      "datetime": "2026-08-07T09:00:00+05:00",
                      "durationSec": 3600
                    },
                    {
                      "time": "9:30",
                      "datetime": "2026-08-07T09:30:00+05:00",
                      "durationSec": 3600
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BookingBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/BookingBranchNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BookingNoBranch"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/BookingProviderDown"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          }
        }
      }
    },
    "/api/v1/booking/check": {
      "post": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingCheck",
        "summary": "Проверить, свободно ли время",
        "description": "Проверяет, что мастер работает, оказывает эту услугу и что время свободно. **Ничего не создаёт и ничего не бронирует** — слот остаётся доступным всем.\n\nПеред `/create` вызывать не обязательно: `/create` делает ровно те же две проверки сам. Отдельно `/check` полезен, когда нужно подтвердить время клиенту, ещё не спросив у него имя и телефон.\n\n`datetime` берите из `/slots` без изменений, вместе со смещением.\n\nМетод POST, но право нужно `booking:write` — проверка идёт POST-запросом, хотя ничего не меняет.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookingCheckRequest"
              },
              "example": {
                "serviceId": 30759483,
                "staffId": 5813061,
                "datetime": "2026-08-07T13:00:00+05:00"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Время свободно и мастер его берёт",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "branchId",
                    "available",
                    "staffId",
                    "serviceIds",
                    "datetime"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "branchId": {
                      "type": "string"
                    },
                    "available": {
                      "const": true
                    },
                    "staffId": {
                      "type": "integer"
                    },
                    "serviceIds": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      }
                    },
                    "datetime": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "branchId": "2121042",
                  "available": true,
                  "staffId": 5813061,
                  "serviceIds": [
                    30759483
                  ],
                  "datetime": "2026-08-07T13:00:00+05:00"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BookingBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/BookingBranchNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BookingSlotTaken"
          },
          "422": {
            "$ref": "#/components/responses/BookingProviderRejected"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/BookingProviderDown"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          }
        }
      }
    },
    "/api/v1/booking/create": {
      "post": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingCreate",
        "summary": "Записать клиента",
        "description": "Создаёт запись в расписании салона. Это единственная ручка раздела, которая что-то меняет у живого бизнеса, поэтому сервер перепроверяет всё сам и не верит вызывающей стороне.\n\nПеред созданием сервер заново берёт `/slots` и заново прогоняет проверку на стороне YCLIENTS — вызывать `/check` отдельно не нужно.\n\n**Имя и мобильный телефон спрашивайте у клиента до вызова**: без них записи не будет. Телефон принимается в любом формате записи и нормализуется к `79161234567`; годится только мобильный, начинающийся на `+79`.\n\n**Повтор безопасен.** Ключ идемпотентности по умолчанию — филиал + мастер + время + телефон, живёт час. Повторный вызов с теми же данными вернёт первую запись с полем `duplicate: true` и тем же `recordId`, а не заведёт второго клиента на тот же слот. Повтор не тратит лимит и не ходит в YCLIENTS. Свой ключ можно передать в `idempotencyKey`.\n\nВладельцу филиала уходит уведомление о каждой записи, сделанной через API.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookingCreateRequest"
              },
              "examples": {
                "обычная": {
                  "summary": "Одна услуга, минимум полей",
                  "value": {
                    "serviceId": 30759483,
                    "staffId": 5813061,
                    "datetime": "2026-08-07T13:00:00+05:00",
                    "phone": "+7 916 123-45-67",
                    "name": "Анна"
                  }
                },
                "сПочтой": {
                  "summary": "Салон требует email — повтор после отказа 422",
                  "value": {
                    "serviceId": 30759483,
                    "staffId": 5813061,
                    "datetime": "2026-08-07T13:00:00+05:00",
                    "phone": "+7 916 123-45-67",
                    "name": "Анна",
                    "email": "anna@example.com"
                  }
                },
                "несколькоУслуг": {
                  "summary": "Несколько услуг за один визит и пожелание администратору",
                  "value": {
                    "serviceIds": [
                      30759483,
                      30759484
                    ],
                    "staffId": 5813061,
                    "datetime": "2026-08-07T13:00:00+05:00",
                    "phone": "89161234567",
                    "name": "Анна",
                    "comment": "Первый раз, аллергия на аммиак"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Клиент записан. При повторе того же запроса — та же запись с `duplicate: true`",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "branchId",
                    "records",
                    "staffId",
                    "serviceIds",
                    "datetime",
                    "client"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "branchId": {
                      "type": "string"
                    },
                    "records": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BookingRecord"
                      }
                    },
                    "staffId": {
                      "type": "integer"
                    },
                    "serviceIds": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      }
                    },
                    "datetime": {
                      "type": "string"
                    },
                    "client": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "phone": {
                          "type": "string",
                          "description": "Нормализованный номер в виде 79161234567"
                        }
                      },
                      "required": [
                        "name",
                        "phone"
                      ]
                    },
                    "duplicate": {
                      "const": true,
                      "description": "Есть только в ответе на повтор: запись не создавалась заново"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "branchId": "2121042",
                  "records": [
                    {
                      "recordId": 1886753778,
                      "recordHash": "41c800a7897c4eae6798de58bc09616a"
                    }
                  ],
                  "staffId": 5813061,
                  "serviceIds": [
                    30759483
                  ],
                  "datetime": "2026-08-07T13:00:00+05:00",
                  "client": {
                    "name": "Анна",
                    "phone": "79161234567"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BookingBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/BookingBranchNotFound"
          },
          "409": {
            "$ref": "#/components/responses/BookingSlotTaken"
          },
          "422": {
            "$ref": "#/components/responses/BookingProviderRejected"
          },
          "429": {
            "$ref": "#/components/responses/BookingTooMany"
          },
          "502": {
            "$ref": "#/components/responses/BookingProviderDown"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          }
        }
      }
    },
    "/api/v1/booking/cancel": {
      "post": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingCancel",
        "summary": "Отменить запись",
        "description": "Снимает запись, созданную через `/create`.\n\n**Отменить чужую запись нельзя физически.** YCLIENTS отдаёт код отмены (`record_hash`) ровно один раз — в ответе на создание — и больше нигде: ни в списке записей, ни по номеру. OmniGate сохраняет его у себя, поэтому снять может только то, что сам и создал. Запись, сделанную администратором салона или через сайт салона, отменить через API невозможно; направьте клиента звонить в салон.\n\n**Повтор безопасен и не является ошибкой**: уже отменённая запись снова отвечает 200, с признаком `already: true`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookingCancelRequest"
              },
              "example": {
                "recordId": "1886753778"
              }
            }
          }
        },
        "responses": {
          "400": {
            "$ref": "#/components/responses/BookingBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/BookingRecordNotFound"
          },
          "502": {
            "$ref": "#/components/responses/BookingProviderDown"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          },
          "200": {
            "description": "Запись снята. Повтор на уже отменённой записи отвечает тем же 200 с `already: true`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "recordId",
                    "cancelled"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "recordId": {
                      "type": "string"
                    },
                    "cancelled": {
                      "const": true
                    },
                    "branchId": {
                      "type": "string"
                    },
                    "already": {
                      "const": true,
                      "description": "Есть только в ответе на повтор: запись была снята раньше"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "recordId": "1886753778",
                  "cancelled": true,
                  "branchId": "2121042"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/BookingRecordConflict"
          }
        }
      }
    },
    "/api/v1/booking/reschedule": {
      "post": {
        "tags": [
          "Запись клиентов"
        ],
        "operationId": "bookingReschedule",
        "summary": "Перенести запись",
        "description": "Переносит запись на другое время. Работает только для записей, созданных через `/create` — по той же причине, что и `/cancel`.\n\n**Переноса в YCLIENTS не существует.** `PUT` по записи закрыт для партнёрского доступа, поэтому перенос собран из двух шагов: сначала создаётся новая запись, потом снимается старая. Порядок выбран сознательно. Если создать новую не удалось, старая остаётся на месте и клиент ничего не замечает. При обратном порядке отказ на создании оставил бы человека вообще без записи, причём молча.\n\nХудший случай — новая создана, старую снять не удалось. Тогда приходит 409, а владельцу филиала уходит уведомление с обоими номерами: две записи на одного клиента видно и легко исправить, в отличие от нуля.\n\n`staffId` и услуги можно не передавать — останутся прежние. Слот сервер перепроверяет сам, вызывать `/check` не нужно.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookingRescheduleRequest"
              },
              "examples": {
                "толькоВремя": {
                  "summary": "То же самое, но в другой день",
                  "value": {
                    "recordId": "1886753778",
                    "datetime": "2026-08-08T10:00:00+05:00"
                  }
                },
                "другойМастер": {
                  "summary": "Клиент просит сменить и мастера",
                  "value": {
                    "recordId": "1886753778",
                    "datetime": "2026-08-08T10:00:00+05:00",
                    "staffId": 5813062
                  }
                }
              }
            }
          }
        },
        "responses": {
          "400": {
            "$ref": "#/components/responses/BookingBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/BookingAddonRequired"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/BookingRecordNotFound"
          },
          "502": {
            "$ref": "#/components/responses/BookingProviderDown"
          },
          "503": {
            "$ref": "#/components/responses/BookingNotConfigured"
          },
          "200": {
            "description": "Клиент перенесён: новая запись создана, старая снята.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "branchId",
                    "moved",
                    "fromRecordId",
                    "records",
                    "datetime"
                  ],
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "branchId": {
                      "type": "string"
                    },
                    "moved": {
                      "const": true
                    },
                    "fromRecordId": {
                      "type": "string",
                      "description": "Номер снятой записи"
                    },
                    "fromDatetime": {
                      "type": "string",
                      "description": "Время, с которого перенесли"
                    },
                    "records": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BookingRecord"
                      }
                    },
                    "staffId": {
                      "type": "integer"
                    },
                    "serviceIds": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      }
                    },
                    "datetime": {
                      "type": "string"
                    },
                    "client": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "phone": {
                          "type": "string"
                        }
                      }
                    },
                    "duplicate": {
                      "const": true,
                      "description": "Есть только в ответе на повтор: перенос не выполнялся заново"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "branchId": "2121042",
                  "moved": true,
                  "fromRecordId": "1886753778",
                  "fromDatetime": "2026-08-07T13:00:00+05:00",
                  "records": [
                    {
                      "recordId": 1887001234,
                      "recordHash": "41c800a7897c4eae6798de58bc09616a"
                    }
                  ],
                  "staffId": 5813061,
                  "serviceIds": [
                    30759483
                  ],
                  "datetime": "2026-08-08T10:00:00+05:00",
                  "client": {
                    "name": "Анна",
                    "phone": "79161234567"
                  }
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/BookingRescheduleConflict"
          },
          "422": {
            "$ref": "#/components/responses/BookingProviderRejected"
          },
          "429": {
            "$ref": "#/components/responses/BookingTooMany"
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "Сервис"
        ],
        "operationId": "health",
        "summary": "Проверка доступности сервиса",
        "security": [],
        "responses": {
          "200": {
            "description": "Сервис работает",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    },
                    "service": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "service": "omnigate"
                }
              }
            }
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "tags": [
          "MCP"
        ],
        "operationId": "mcp",
        "summary": "MCP-сервер: тот же API для ИИ-агентов",
        "description": "Model Context Protocol поверх этого же API. Транспорт — Streamable HTTP: одно JSON-RPC 2.0 сообщение в теле POST, ответ обычным `application/json`. Авторизация та же — `X-API-Key`. Поддерживаемые версии протокола: `2025-11-25`, `2025-06-18`, `2025-03-26`.\n\nМетоды: `initialize`, `ping`, `tools/list`, `tools/call`. Инструменты повторяют эндпоинты этого документа один в один и выполняются от лица владельца ключа, поэтому права ключа, лимиты тарифа и рейт-лимит действуют точно так же. `tools/list` возвращает только инструменты, доступные вашему ключу.\n\nОтказ сервиса приходит не ошибкой JSON-RPC, а результатом с `isError: true` и текстом на русском; код `-32602` означает неизвестный инструмент, `-32601` — неизвестный метод.\n\nОтличия от REST: секрет подписи вебхука через MCP не выдаётся (только признак наличия и последние 4 символа), а SSE-поток `/api/v1/events` инструментом не представлен — вместо него опрашивайте историю сообщений. Свой лимит: 240 JSON-RPC запросов в минуту.\n\nПодробнее — https://omnigate.website/llms.txt и https://omnigate.website/docs",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Одно сообщение JSON-RPC 2.0. Массивы (батчи) не поддерживаются.",
                "required": [
                  "jsonrpc",
                  "method"
                ],
                "properties": {
                  "jsonrpc": {
                    "const": "2.0"
                  },
                  "id": {
                    "type": [
                      "string",
                      "integer",
                      "null"
                    ],
                    "description": "Без `id` сообщение считается уведомлением: ответ будет 202 без тела"
                  },
                  "method": {
                    "type": "string",
                    "enum": [
                      "initialize",
                      "ping",
                      "tools/list",
                      "tools/call"
                    ]
                  },
                  "params": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              },
              "example": {
                "jsonrpc": "2.0",
                "id": 1,
                "method": "tools/call",
                "params": {
                  "name": "message_send",
                  "arguments": {
                    "sessionId": "3f2b8c1e-0f4a-4b7d-9a2e-1c5d6e7f8a90",
                    "chatId": "79991234567",
                    "text": "Здравствуйте!"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ответ JSON-RPC: либо `result`, либо `error`",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jsonrpc": {
                      "const": "2.0"
                    },
                    "id": {
                      "type": [
                        "string",
                        "integer",
                        "null"
                      ]
                    },
                    "result": {
                      "type": "object",
                      "additionalProperties": true
                    },
                    "error": {
                      "type": "object",
                      "properties": {
                        "code": {
                          "type": "integer"
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Уведомление принято (сообщение без `id`), тела нет"
          },
          "400": {
            "description": "Тело не разобрать или это не одиночное JSON-RPC сообщение"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "405": {
            "description": "Метод HTTP не поддерживается — на `/mcp` работает только POST"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    }
  },
  "webhooks": {
    "inboundMessage": {
      "post": {
        "tags": [
          "Сообщения"
        ],
        "operationId": "onInboundMessage",
        "summary": "Входящее сообщение (доставляется на ваш адрес)",
        "description": "OmniGate POST-ит на ваш адрес каждое входящее сообщение — единым форматом для всех каналов. Адрес задаётся либо на канале (`PATCH /api/v1/sessions/{id}` с `webhookUrl`), либо глобально на аккаунте в кабинете; вебхук канала приоритетнее.\n\nОтвечайте `2xx` как можно быстрее. Любой другой код, редирект или таймаут считается неуспехом: доставка повторяется с экспоненциальной задержкой (1, 2, 4, 8… секунд, до 32).\n\n### Проверка подписи\nЕсли в кабинете задан секрет вебхука, запрос содержит `X-OmniGate-Signature: sha256=<hex>`, где hex — это `HMAC-SHA256(секрет, \"<X-OmniGate-Timestamp>.<сырое тело запроса>\")`. Считайте подпись по сырому телу до разбора JSON. Дублирующие заголовки `X-Webhook-Signature-V2` и `X-Webhook-Timestamp` содержат то же самое, но с временем в секундах.\n\n`X-OmniGate-Delivery` — идентификатор доставки: используйте его для защиты от повторной обработки при ретраях.",
        "security": [],
        "parameters": [
          {
            "name": "X-OmniGate-Signature",
            "in": "header",
            "required": false,
            "description": "sha256=<hex HMAC-SHA256 от \"<timestamp мс>.<тело>\">",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-OmniGate-Timestamp",
            "in": "header",
            "required": false,
            "description": "Время отправки, Unix-мс",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-OmniGate-Delivery",
            "in": "header",
            "required": false,
            "description": "Идентификатор доставки (для идемпотентности)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InboundMessage"
              },
              "examples": {
                "text": {
                  "summary": "Текст из WhatsApp",
                  "value": {
                    "channel": "whatsapp",
                    "sessionId": "3f2b8c1e-0f4a-4b7d-9a2e-1c5d6e7f8a90",
                    "chatId": "79991234567",
                    "from": "79991234567",
                    "fromName": "Анна",
                    "text": "Здравствуйте, когда доставите?",
                    "timestamp": 1785348000000,
                    "raw": {}
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Принято. Любой ответ 2xx считается успешной доставкой."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Ключ вида `og_live_…` из кабинета https://omnigate.website/app, раздел «API». У ключа может быть ограниченный набор прав — см. раздел «Права ключа» в описании API; при нехватке права ответ 403."
      },
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Тот же ключ, переданный как `Authorization: Bearer og_live_…`."
      }
    },
    "schemas": {
      "Channel": {
        "type": "string",
        "description": "Тип канала. Псевдонимы `site`, `web`, `custom`, `widget` при создании означают `webchat`.",
        "enum": [
          "whatsapp",
          "telegram",
          "vk",
          "max",
          "instagram",
          "webchat"
        ]
      },
      "SessionStatus": {
        "type": "string",
        "description": "Состояние канала: `created` — создан, `connecting` — идёт подключение, `qr` — нужно отсканировать QR, `awaiting_code` — ждёт код из SMS, `awaiting_password` — ждёт пароль 2FA, `connected` — готов к работе, `disconnected` — отключён, `error` — ошибка.",
        "enum": [
          "created",
          "connecting",
          "qr",
          "awaiting_code",
          "awaiting_password",
          "connected",
          "disconnected",
          "error"
        ]
      },
      "ChannelIdentity": {
        "type": "object",
        "description": "Человекочитаемая «личность» канала — без секретов.",
        "properties": {
          "accountType": {
            "type": "string",
            "description": "«Бот», «Личный аккаунт», «Сообщество», «Аккаунт» или «Виджет на сайте»"
          },
          "handle": {
            "type": "string",
            "description": "Видимый идентификатор: @username, club123 или замаскированный телефон вида «+7 995 •••• 12»"
          },
          "title": {
            "type": "string",
            "description": "Необязательное имя (бота, виджета)"
          }
        },
        "required": [
          "accountType",
          "handle"
        ]
      },
      "SessionStats": {
        "type": "object",
        "properties": {
          "in": {
            "type": "integer",
            "description": "Сколько входящих в канале"
          },
          "out": {
            "type": "integer",
            "description": "Сколько исходящих в канале"
          },
          "last": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Время последнего сообщения, Unix-мс"
          }
        },
        "required": [
          "in",
          "out",
          "last"
        ]
      },
      "Session": {
        "type": "object",
        "description": "Канал. Учётные данные наружу не отдаются никогда — вместо них флаг `hasCredentials` и безопасный `identity`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Идентификатор канала — его передают в sessionId"
          },
          "channel": {
            "$ref": "#/components/schemas/Channel"
          },
          "status": {
            "$ref": "#/components/schemas/SessionStatus"
          },
          "createdAt": {
            "type": "integer",
            "format": "int64",
            "description": "Когда создан, Unix-мс"
          },
          "webhookUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Адрес доставки входящих для этого канала; null — используется глобальный вебхук аккаунта"
          },
          "deliveryWebhookUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Куда POST-ить ответы менеджера из amoCRM для «своего канала»; null — доставка через провайдера канала"
          },
          "mirrorAmocrm": {
            "type": "boolean",
            "description": "Зеркалить ли переписку этого канала в amoCRM (по умолчанию true)"
          },
          "aiAgent": {
            "type": "boolean",
            "description": "Отвечает ли на этом канале встроенный ИИ-агент (по умолчанию false)"
          },
          "amocrmPipelineId": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Воронка amoCRM, в «Неразобранное» которой падают заявки канала"
          },
          "amocrmSourceId": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Идентификатор источника в amoCRM"
          },
          "userId": {
            "type": [
              "string",
              "null"
            ],
            "description": "Владелец канала"
          },
          "hasCredentials": {
            "type": "boolean",
            "description": "Заданы ли учётные данные канала"
          },
          "identity": {
            "$ref": "#/components/schemas/ChannelIdentity"
          }
        },
        "required": [
          "id",
          "channel",
          "status",
          "createdAt",
          "hasCredentials",
          "identity"
        ]
      },
      "SessionWithStats": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Session"
          },
          {
            "type": "object",
            "properties": {
              "stats": {
                "$ref": "#/components/schemas/SessionStats"
              }
            }
          }
        ]
      },
      "WebchatSession": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Session"
          },
          {
            "type": "object",
            "description": "Поля, которые приходят только при создании канала webchat.",
            "properties": {
              "publicKey": {
                "type": "string",
                "description": "Ключ виджета вида `wc_…`. Отдаётся ровно один раз — при создании; сохраните его",
                "examples": [
                  "wc_9f1c2a7b4e6d8f0a1b2c3d4e"
                ]
              },
              "embed": {
                "type": "string",
                "description": "Готовый HTML-сниппет: вставьте его на сайт перед </body>"
              }
            }
          }
        ]
      },
      "Message": {
        "type": "object",
        "description": "Сообщение из истории. Поля названы как в хранилище — через подчёркивание.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Порядковый номер сообщения"
          },
          "session_id": {
            "type": "string",
            "format": "uuid",
            "description": "Канал, в котором было сообщение"
          },
          "direction": {
            "type": "string",
            "enum": [
              "in",
              "out"
            ],
            "description": "`in` — от собеседника, `out` — от вас"
          },
          "chat_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Диалог (собеседник) внутри канала"
          },
          "body": {
            "type": [
              "string",
              "null"
            ],
            "description": "Текст сообщения или подпись к медиа"
          },
          "sender": {
            "type": [
              "string",
              "null"
            ],
            "description": "Имя или идентификатор отправителя"
          },
          "media": {
            "type": [
              "string",
              "null"
            ],
            "description": "Вложения в виде JSON-строки: массив `[{type,url,name?}]`. Разбирайте её отдельно"
          },
          "created_at": {
            "type": "integer",
            "format": "int64",
            "description": "Время сообщения, Unix-мс. Его же передают в параметр before для пагинации"
          }
        },
        "required": [
          "id",
          "session_id",
          "direction",
          "created_at"
        ]
      },
      "InboundMessage": {
        "type": "object",
        "description": "Нормализованное входящее — единый формат для всех каналов.",
        "properties": {
          "channel": {
            "$ref": "#/components/schemas/Channel"
          },
          "sessionId": {
            "type": "string",
            "format": "uuid",
            "description": "Канал, куда пришло сообщение"
          },
          "chatId": {
            "type": "string",
            "description": "Диалог. Именно это значение передают обратно в POST /api/v1/messages, чтобы ответить"
          },
          "from": {
            "type": "string",
            "description": "Отправитель: телефон, числовой id или username"
          },
          "fromName": {
            "type": "string",
            "description": "Человекочитаемое имя, если канал его отдаёт"
          },
          "text": {
            "type": [
              "string",
              "null"
            ],
            "description": "Текст сообщения; null, если пришло только вложение"
          },
          "attachments": {
            "type": "array",
            "description": "Вложения",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string",
                  "description": "photo, video, file, voice…"
                },
                "url": {
                  "type": "string",
                  "description": "Прямая ссылка на файл"
                }
              }
            }
          },
          "timestamp": {
            "type": "integer",
            "format": "int64",
            "description": "Время получения, Unix-мс"
          },
          "raw": {
            "description": "Исходный payload канала как есть — на случай, если нужны поля, которых нет в общем формате"
          }
        },
        "required": [
          "channel",
          "sessionId",
          "chatId",
          "from",
          "text",
          "timestamp"
        ]
      },
      "CreateSessionRequest": {
        "type": "object",
        "required": [
          "channel"
        ],
        "properties": {
          "channel": {
            "$ref": "#/components/schemas/Channel"
          },
          "credentials": {
            "type": "object",
            "description": "Учётные данные под конкретный канал. Для `webchat` игнорируются — их генерирует сервер.\n\n- telegram (бот): `{ botToken }`; telegram (личный аккаунт): `{ apiId, apiHash, phone }`\n- vk: `{ groupId, accessToken }`\n- max (бот): `{ botToken }`; max (личный аккаунт): `{ phone }`\n- instagram: `{ igId, pageAccessToken }`\n- whatsapp: `{}` — авторизация по QR, см. GET /api/v1/sessions/{id}",
            "additionalProperties": true
          },
          "webhookUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Куда доставлять входящие этого канала. Нужен публичный http/https"
          },
          "title": {
            "type": "string",
            "maxLength": 60,
            "description": "Только webchat: заголовок виджета"
          },
          "color": {
            "type": "string",
            "maxLength": 20,
            "description": "Только webchat: цвет кнопки, например #4f46e5"
          },
          "welcome": {
            "type": "string",
            "maxLength": 200,
            "description": "Только webchat: приветственное сообщение"
          },
          "webchatEnabled": {
            "type": "boolean",
            "default": true,
            "description": "Только webchat: показывать ли виджет"
          },
          "channels": {
            "type": "array",
            "maxItems": 8,
            "items": {
              "$ref": "#/components/schemas/WebchatChannel"
            },
            "description": "Только webchat: какие кнопки мессенджеров показать в виджете"
          }
        }
      },
      "UpdateSessionRequest": {
        "type": "object",
        "description": "Нужно хотя бы одно поле.",
        "minProperties": 1,
        "properties": {
          "webhookUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Адрес доставки входящих; пустая строка или null — очистить"
          },
          "deliveryWebhookUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Адрес доставки ответов менеджера из amoCRM; пустая строка или null — очистить"
          },
          "mirrorAmocrm": {
            "type": "boolean",
            "description": "Зеркалить ли канал в amoCRM"
          },
          "aiAgent": {
            "type": "boolean",
            "description": "Отвечает ли на канале встроенный ИИ-агент"
          }
        }
      },
      "SendMessageRequest": {
        "type": "object",
        "required": [
          "sessionId",
          "chatId"
        ],
        "description": "Нужен либо `text`, либо `mediaUrl`.",
        "properties": {
          "sessionId": {
            "type": "string",
            "format": "uuid",
            "description": "Канал, из которого отправляем"
          },
          "chatId": {
            "type": "string",
            "description": "Получатель в формате канала"
          },
          "text": {
            "type": "string",
            "description": "Текст сообщения"
          },
          "mediaUrl": {
            "type": "string",
            "format": "uri",
            "description": "Прямая публичная ссылка на файл"
          },
          "kind": {
            "type": "string",
            "enum": [
              "photo",
              "document"
            ],
            "default": "photo",
            "description": "Как отправить вложение"
          },
          "caption": {
            "type": "string",
            "description": "Подпись к вложению"
          }
        }
      },
      "InboundRequest": {
        "type": "object",
        "required": [
          "sessionId",
          "chatId"
        ],
        "description": "Нужен либо `text`, либо `mediaUrl`.",
        "properties": {
          "sessionId": {
            "type": "string",
            "format": "uuid",
            "description": "Канал, в который кладём входящее"
          },
          "chatId": {
            "type": "string",
            "maxLength": 120,
            "description": "Диалог: ваш идентификатор посетителя или клиента"
          },
          "text": {
            "type": "string",
            "maxLength": 4000,
            "description": "Текст сообщения"
          },
          "from": {
            "type": "string",
            "maxLength": 120,
            "description": "Отправитель; по умолчанию равен chatId"
          },
          "fromName": {
            "type": "string",
            "maxLength": 80,
            "description": "Имя отправителя для показа менеджеру"
          },
          "mediaUrl": {
            "type": "string",
            "format": "uri",
            "description": "Ссылка на вложение"
          },
          "mediaKind": {
            "type": "string",
            "default": "file",
            "description": "Тип вложения: photo, video, file…"
          },
          "webhook": {
            "type": "boolean",
            "default": true,
            "description": "false — не доставлять это сообщение на ваш вебхук (когда источник и приёмник — один сервис)"
          },
          "deliverWebhook": {
            "type": "boolean",
            "default": true,
            "description": "Синоним webhook"
          }
        }
      },
      "UploadRequest": {
        "type": "object",
        "required": [
          "sessionId",
          "chatId",
          "dataBase64"
        ],
        "properties": {
          "sessionId": {
            "type": "string",
            "format": "uuid",
            "description": "Канал, из которого отправляем"
          },
          "chatId": {
            "type": "string",
            "description": "Получатель в формате канала"
          },
          "filename": {
            "type": "string",
            "description": "Имя файла — по его расширению определяется тип вложения"
          },
          "dataBase64": {
            "type": "string",
            "description": "Содержимое файла в base64. Допускается префикс `data:<mime>;base64,`. До 25 МБ"
          },
          "caption": {
            "type": "string",
            "description": "Подпись к файлу"
          }
        }
      },
      "SendResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "Принял ли канал сообщение"
          },
          "messageId": {
            "type": "string",
            "description": "Идентификатор сообщения на стороне канала, если он его вернул"
          },
          "error": {
            "type": "string",
            "description": "Причина отказа, когда ok = false"
          }
        },
        "required": [
          "ok"
        ]
      },
      "ResolveResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "chatId": {
            "type": "string",
            "description": "Числовой идентификатор для отправки"
          },
          "name": {
            "type": "string",
            "description": "Человекочитаемое имя"
          },
          "username": {
            "type": "string",
            "description": "Канонический username без @"
          },
          "error": {
            "type": "string"
          }
        },
        "required": [
          "ok"
        ]
      },
      "WebchatChannel": {
        "type": "object",
        "description": "Кнопка мессенджера в виджете. `value` — номер, username или готовая ссылка; сервер сам собирает deep-link. Непригодные значения молча отбрасываются, максимум 8 кнопок.",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "whatsapp",
              "telegram",
              "vk",
              "max",
              "viber",
              "instagram",
              "phone",
              "email",
              "link"
            ],
            "description": "Тип кнопки. `link` принимается только с готовой ссылкой (http, https, tel, mailto, viber)"
          },
          "value": {
            "type": "string",
            "maxLength": 200,
            "description": "Номер, username или готовая ссылка"
          },
          "label": {
            "type": "string",
            "maxLength": 40,
            "description": "Подпись кнопки; по умолчанию — название мессенджера"
          }
        },
        "required": [
          "type",
          "value"
        ]
      },
      "WebchatWidgetInput": {
        "type": "object",
        "description": "Оформление виджета. Все поля необязательны: при создании пропущенные берутся по умолчанию, при обновлении — остаются прежними.",
        "properties": {
          "title": {
            "type": "string",
            "maxLength": 60,
            "default": "Чат с нами",
            "description": "Заголовок окна чата"
          },
          "color": {
            "type": "string",
            "maxLength": 20,
            "default": "#4f46e5",
            "description": "Основной цвет виджета (CSS-цвет)"
          },
          "welcome": {
            "type": "string",
            "maxLength": 200,
            "default": "Здравствуйте! Чем можем помочь?",
            "description": "Приветственное сообщение"
          },
          "webchatEnabled": {
            "type": "boolean",
            "default": true,
            "description": "Показывать встроенный чат. `false` — виджет остаётся только меню кнопок мессенджеров"
          },
          "channels": {
            "type": "array",
            "maxItems": 8,
            "items": {
              "$ref": "#/components/schemas/WebchatChannel"
            },
            "description": "Кнопки мессенджеров. При обновлении список заменяется целиком"
          },
          "webhookUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Вебхук этого виджета; приоритетнее глобального. Пустая строка или null — очистить"
          }
        }
      },
      "WebchatWidget": {
        "type": "object",
        "description": "Виджет веб-чата. Одновременно является каналом и учитывается в лимите каналов тарифа.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Идентификатор виджета, он же идентификатор канала"
          },
          "status": {
            "type": "string",
            "description": "Состояние канала: created, connected, error, disconnected"
          },
          "publicKey": {
            "type": "string",
            "description": "Публичный ключ виджета — используется в сниппете, не является секретом"
          },
          "title": {
            "type": "string"
          },
          "color": {
            "type": "string"
          },
          "welcome": {
            "type": "string"
          },
          "webchatEnabled": {
            "type": "boolean"
          },
          "channels": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebchatChannel"
            }
          },
          "webhookUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          },
          "embed": {
            "type": "string",
            "description": "Готовый HTML-сниппет для вставки на сайт"
          },
          "createdAt": {
            "type": "integer",
            "format": "int64",
            "description": "Время создания, Unix-мс"
          }
        },
        "required": [
          "id",
          "status",
          "publicKey",
          "embed"
        ]
      },
      "WebhookDelivery": {
        "type": "object",
        "description": "Запись журнала доставок вебхука.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Идентификатор доставки; совпадает с заголовком X-OmniGate-Delivery"
          },
          "status": {
            "type": "string",
            "description": "Состояние: pending — в очереди, ok — доставлено, failed — попытки исчерпаны"
          },
          "attempts": {
            "type": "integer",
            "description": "Сколько раз пытались доставить"
          },
          "lastStatus": {
            "type": [
              "integer",
              "null"
            ],
            "description": "HTTP-код последнего ответа вашего сервера"
          },
          "lastError": {
            "type": [
              "string",
              "null"
            ],
            "description": "Текст последней ошибки, если она была"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Адрес, на который шла доставка"
          },
          "createdAt": {
            "type": "integer",
            "format": "int64"
          },
          "updatedAt": {
            "type": "integer",
            "format": "int64"
          },
          "preview": {
            "type": [
              "object",
              "null"
            ],
            "description": "Короткая выжимка полезной нагрузки для журнала",
            "properties": {
              "channel": {
                "type": "string"
              },
              "chatId": {
                "type": "string"
              },
              "text": {
                "type": "string",
                "description": "Первые 80 символов текста"
              }
            }
          }
        },
        "required": [
          "id",
          "status",
          "attempts",
          "url"
        ]
      },
      "Account": {
        "type": "object",
        "description": "Владелец ключа.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "createdAt": {
            "type": "integer",
            "format": "int64",
            "description": "Дата регистрации, Unix-мс"
          }
        },
        "required": [
          "id",
          "email"
        ]
      },
      "AccountPlan": {
        "type": "object",
        "description": "Действующий тариф с учётом оплаченной подписки.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Код тарифа"
          },
          "title": {
            "type": "string",
            "description": "Название тарифа"
          },
          "price": {
            "type": "integer",
            "description": "Цена за месяц, рубли"
          },
          "maxSessions": {
            "type": "integer",
            "description": "Сколько каналов можно держать одновременно"
          },
          "maxMessages": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Лимит сообщений в календарный месяц; null — безлимит"
          },
          "unlimitedMessages": {
            "type": "boolean",
            "description": "true — лимита сообщений нет"
          }
        },
        "required": [
          "id",
          "title",
          "maxSessions",
          "unlimitedMessages"
        ]
      },
      "AccountSubscription": {
        "type": "object",
        "description": "Оплаченная подписка. `null`, если аккаунт на бесплатном тарифе.",
        "properties": {
          "planId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "Состояние подписки: active, past_due, canceled"
          },
          "currentPeriodEnd": {
            "type": "integer",
            "format": "int64",
            "description": "Когда закончится оплаченный период, Unix-мс"
          },
          "quantity": {
            "type": "integer",
            "description": "Сколько пакетов каналов оплачено"
          },
          "periodMonths": {
            "type": "integer",
            "description": "Длина оплаченного периода в месяцах (1, 3, 6 или 12)"
          }
        },
        "required": [
          "planId",
          "status"
        ]
      },
      "AccountUsage": {
        "type": "object",
        "description": "Расход по лимитам тарифа в текущем календарном месяце.",
        "properties": {
          "periodStart": {
            "type": "integer",
            "format": "int64",
            "description": "Начало текущего месяца, Unix-мс — от него считается расход сообщений"
          },
          "channels": {
            "type": "integer",
            "description": "Всего каналов на аккаунте"
          },
          "channelsActive": {
            "type": "integer",
            "description": "Каналов не в статусе disconnected — именно они занимают лимит"
          },
          "channelsLeft": {
            "type": "integer",
            "description": "Сколько ещё каналов можно создать"
          },
          "messagesThisMonth": {
            "type": "integer",
            "description": "Отправлено и принято сообщений с начала месяца"
          },
          "messagesLeft": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Остаток сообщений до лимита; null — безлимит"
          }
        },
        "required": [
          "periodStart",
          "channels",
          "channelsActive",
          "channelsLeft",
          "messagesThisMonth"
        ]
      },
      "BookingBranch": {
        "type": "object",
        "description": "Филиал — салон или клиника, подключённая владельцем ключа в кабинете.",
        "properties": {
          "companyId": {
            "type": "string",
            "description": "Идентификатор филиала; он же branchId во всех ручках раздела"
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Название филиала"
          }
        },
        "required": [
          "companyId",
          "title"
        ]
      },
      "BookingService": {
        "type": "object",
        "description": "Услуга, на которую филиал принимает онлайн-запись.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "serviceId для /slots, /check и /create"
          },
          "title": {
            "type": "string"
          },
          "categoryId": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Категория услуги в справочнике филиала"
          },
          "priceMin": {
            "type": "number",
            "description": "Нижняя граница цены в рублях; 0 — цена не указана"
          },
          "priceMax": {
            "type": "number",
            "description": "Верхняя граница цены в рублях"
          },
          "durationSec": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Длительность сеанса в секундах, если филиал её задал"
          }
        },
        "required": [
          "id",
          "title",
          "categoryId",
          "priceMin",
          "priceMax",
          "durationSec"
        ]
      },
      "BookingStaff": {
        "type": "object",
        "description": "Мастер, к которому филиал реально принимает онлайн-запись.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "staffId для /dates, /slots, /check и /create"
          },
          "name": {
            "type": "string"
          },
          "specialization": {
            "type": [
              "string",
              "null"
            ],
            "description": "Например «Парикмахер»"
          },
          "rating": {
            "type": [
              "number",
              "null"
            ]
          }
        },
        "required": [
          "id",
          "name",
          "specialization",
          "rating"
        ]
      },
      "BookingSlot": {
        "type": "object",
        "description": "Свободное время. Свободно на момент ответа, не навсегда: при записи слот проверяется заново.",
        "properties": {
          "time": {
            "type": "string",
            "description": "Время для показа человеку",
            "examples": [
              "9:00"
            ]
          },
          "datetime": {
            "type": "string",
            "description": "Метка со смещением часового пояса ФИЛИАЛА. В /check и /create возвращается байт в байт; пересчитывать нельзя",
            "examples": [
              "2026-08-07T09:00:00+05:00"
            ]
          },
          "durationSec": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Длительность сеанса в секундах"
          }
        },
        "required": [
          "time",
          "datetime",
          "durationSec"
        ]
      },
      "BookingRecord": {
        "type": "object",
        "description": "Созданная запись в расписании салона.",
        "properties": {
          "recordId": {
            "type": "integer",
            "description": "Номер записи в YCLIENTS"
          },
          "recordHash": {
            "type": [
              "string",
              "null"
            ],
            "description": "Хэш для ссылки клиента на запись; YCLIENTS отдаёт его не всегда"
          }
        },
        "required": [
          "recordId",
          "recordHash"
        ]
      },
      "BookingCheckRequest": {
        "type": "object",
        "description": "Что именно проверяем: услуга, мастер, время.",
        "properties": {
          "branchId": {
            "type": "string",
            "description": "Филиал из /branches. Обязателен, только если филиалов несколько"
          },
          "serviceId": {
            "type": "integer",
            "description": "Услуга из /services"
          },
          "serviceIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 10,
            "description": "Несколько услуг за один визит, вместо serviceId. Не больше 10"
          },
          "staffId": {
            "type": "integer",
            "description": "Мастер из /staff"
          },
          "datetime": {
            "type": "string",
            "description": "Время из /slots без изменений, вместе со смещением. Не в прошлом и не дальше 180 дней вперёд",
            "examples": [
              "2026-08-07T09:00:00+05:00"
            ]
          }
        },
        "required": [
          "staffId",
          "datetime"
        ]
      },
      "BookingCreateRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BookingCheckRequest"
          },
          {
            "type": "object",
            "properties": {
              "phone": {
                "type": "string",
                "description": "Мобильный телефон клиента в любом формате записи; нормализуется к 79161234567. Только российский мобильный, начинающийся на +79",
                "examples": [
                  "+7 916 123-45-67"
                ]
              },
              "name": {
                "type": "string",
                "maxLength": 100,
                "description": "Имя клиента, как он представился. Не короче 2 символов"
              },
              "email": {
                "type": "string",
                "maxLength": 120,
                "description": "Почта клиента. Нужна не всем салонам — спрашивайте её только после отказа 422 с текстом про обязательный email. Свой адрес подставлять нельзя: он попадёт в карточку клиента в CRM салона"
              },
              "comment": {
                "type": "string",
                "maxLength": 500,
                "description": "Пожелание клиента для администратора салона"
              },
              "idempotencyKey": {
                "type": "string",
                "maxLength": 120,
                "description": "Свой ключ повтора. По умолчанию — филиал + мастер + время + телефон, живёт час"
              }
            },
            "required": [
              "phone",
              "name"
            ]
          }
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "ok": {
            "const": false
          },
          "error": {
            "type": "string",
            "description": "Причина отказа, текстом на русском"
          },
          "retryAfter": {
            "type": "integer",
            "description": "Только для 429: через сколько секунд повторить"
          },
          "scope": {
            "type": "string",
            "description": "Только для 403: право, которого не хватило ключу"
          },
          "scopes": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Только для 403: права, которые есть у ключа"
          }
        },
        "required": [
          "ok",
          "error"
        ]
      },
      "BookingCancelRequest": {
        "type": "object",
        "required": [
          "recordId"
        ],
        "properties": {
          "recordId": {
            "type": [
              "string",
              "integer"
            ],
            "description": "Номер записи из ответа /create, поле records[].recordId",
            "examples": [
              "1886753778"
            ]
          }
        }
      },
      "BookingRescheduleRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BookingCancelRequest"
          },
          {
            "type": "object",
            "required": [
              "datetime"
            ],
            "properties": {
              "datetime": {
                "type": "string",
                "description": "Новое время из /slots без изменений, вместе со смещением часового пояса",
                "examples": [
                  "2026-08-08T10:00:00+05:00"
                ]
              },
              "staffId": {
                "type": "integer",
                "description": "Другой мастер. По умолчанию остаётся прежний"
              },
              "serviceId": {
                "type": "integer",
                "description": "Другая услуга. По умолчанию остаётся прежняя"
              },
              "serviceIds": {
                "type": "array",
                "items": {
                  "type": "integer"
                },
                "description": "Несколько услуг вместо serviceId"
              },
              "email": {
                "type": "string",
                "maxLength": 120,
                "description": "Почта клиента. Нужна не всем салонам — спрашивайте её только после отказа 422"
              },
              "comment": {
                "type": "string",
                "maxLength": 500,
                "description": "Пожелание клиента для администратора салона"
              },
              "idempotencyKey": {
                "type": "string",
                "maxLength": 120,
                "description": "Свой ключ повтора. По умолчанию — филиал + мастер + новое время + телефон, живёт час"
              }
            }
          }
        ]
      }
    },
    "responses": {
      "SessionOk": {
        "description": "Актуальное состояние канала",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "ok",
                "session"
              ],
              "properties": {
                "ok": {
                  "const": true
                },
                "session": {
                  "$ref": "#/components/schemas/Session"
                }
              }
            }
          }
        }
      },
      "WebchatWidgetOk": {
        "description": "Актуальное состояние виджета",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "ok",
                "widget"
              ],
              "properties": {
                "ok": {
                  "const": true
                },
                "widget": {
                  "$ref": "#/components/schemas/WebchatWidget"
                }
              }
            }
          }
        }
      },
      "BadRequest": {
        "description": "Неверные параметры запроса: нет обязательного поля, неизвестный канал, некорректный URL. Для ручек «от лица владельца» — ещё и попытка обратиться админским ключом",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "OwnerKeyRequired": {
        "description": "Ручка работает от лица владельца, а ключ ни к кому не привязан (админский ключ из конфигурации). Нужен ключ, выпущенный в кабинете",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "этот эндпоинт работает от лица владельца — нужен ключ кабинета, а не админский"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Ключ не передан, неверен или отозван",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "нужен X-API-Key"
            }
          }
        }
      },
      "Forbidden": {
        "description": "У ключа нет права на эту операцию. В теле — требуемое право (`scope`) и права ключа (`scopes`)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "у ключа нет права «messages:send» — выпустите ключ с этим правом в кабинете",
              "scope": "messages:send",
              "scopes": [
                "sessions:read",
                "messages:read"
              ]
            }
          }
        }
      },
      "PlanLimit": {
        "description": "Достигнут лимит тарифа: число каналов или сообщений за месяц",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Канал не найден или принадлежит другому аккаунту",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "session not found"
            }
          }
        }
      },
      "TooLarge": {
        "description": "Сообщение длиннее 4000 символов или файл больше 25 МБ",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "BookingAddonRequired": {
        "description": "Опция «Запись клиентов» не подключена к тарифу владельца ключа",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "опция «Запись клиентов» не подключена к вашему тарифу — подключите её в кабинете"
            }
          }
        }
      },
      "BookingNotConfigured": {
        "description": "Запись через YCLIENTS не настроена на стороне сервиса. Это не ваша ошибка и повтор её не исправит",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "запись через YCLIENTS не настроена на сервере"
            }
          }
        }
      },
      "BookingBadRequest": {
        "description": "Запрос не разобран. Причина в `error`: не передан `serviceId` или `staffId`; больше 10 услуг за раз; `datetime` без смещения часового пояса, в прошлом или дальше 180 дней вперёд; `date` не в виде `2026-08-07`; телефон не мобильный; имя короче 2 символов; `email` не похож на адрес. Отдельный случай — филиалов у аккаунта несколько, а `branchId` не передан: тогда в ответе есть `branches` со списком, из которого надо выбрать",
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Error"
                },
                {
                  "type": "object",
                  "properties": {
                    "branches": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BookingBranch"
                      },
                      "description": "Только когда филиалов несколько, а branchId не передан"
                    }
                  }
                }
              ]
            },
            "example": {
              "ok": false,
              "error": "datetime нужно взять из /slots без изменений, вместе со смещением: 2026-08-07T09:00:00+05:00"
            }
          }
        }
      },
      "BookingBranchNotFound": {
        "description": "Филиал не подключён к этому аккаунту. Именно 404, а не 403: существует ли такой филиал вообще — не сообщается, чтобы номера нельзя было перебирать",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "филиал не подключён к этому аккаунту"
            }
          }
        }
      },
      "BookingNoBranch": {
        "description": "К аккаунту не подключён ни один филиал. Подключает его владелец в кабинете, раздел YCLIENTS — через API это сделать нельзя",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "к аккаунту не подключён ни один филиал — подключите его в кабинете, раздел YCLIENTS"
            }
          }
        }
      },
      "BookingSlotTaken": {
        "description": "Время у мастера уже занято или он в этот день не работает. В ответе `freeTimes` — до 8 ближайших свободных времён того же дня, чтобы исправиться с первой попытки, а не перебирать время вслепую.\n\nТот же код возвращается, если к аккаунту не подключён ни один филиал — там `freeTimes` нет.\n\nОтказ приходит и тогда, когда `datetime` собран вручную и не совпадает байт в байт со строкой из `/slots`: строка сверяется целиком, совпадения по моменту времени недостаточно",
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Error"
                },
                {
                  "type": "object",
                  "properties": {
                    "freeTimes": {
                      "type": "array",
                      "description": "Ближайшее свободное время того же дня, до 8 штук",
                      "items": {
                        "type": "object",
                        "properties": {
                          "time": {
                            "type": "string",
                            "examples": [
                              "9:00"
                            ]
                          },
                          "datetime": {
                            "type": "string",
                            "examples": [
                              "2026-08-07T09:00:00+05:00"
                            ]
                          }
                        },
                        "required": [
                          "time",
                          "datetime"
                        ]
                      }
                    }
                  }
                }
              ]
            },
            "example": {
              "ok": false,
              "error": "это время у мастера уже занято или он в этот день не работает",
              "freeTimes": [
                {
                  "time": "13:30",
                  "datetime": "2026-08-07T13:30:00+05:00"
                },
                {
                  "time": "14:00",
                  "datetime": "2026-08-07T14:00:00+05:00"
                }
              ]
            }
          }
        }
      },
      "BookingProviderRejected": {
        "description": "Салон отклонил запись, причина в `error` дословно от YCLIENTS.\n\nСамый частый случай — «Не передан обязательный параметр email.»: часть салонов включает требование почты в настройках онлайн-записи. Спросите адрес у клиента и повторите запрос с полем `email`. Подставлять свой адрес нельзя — он попадёт в карточку клиента в CRM салона",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "Не передан обязательный параметр email."
            }
          }
        }
      },
      "BookingTooMany": {
        "description": "Сработал предохранитель записи: не больше 3 записей в час с одного номера и не больше 20 в час по филиалу. Считаются попытки, а не успехи.\n\nЭто отдельный лимит, помимо общих 120 запросов в минуту. Повтор того же запроса лимит не тратит — он возвращает первую запись с `duplicate: true`",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "с этого номера уже несколько записей за час — оформите остальные по телефону салона"
            }
          }
        }
      },
      "BookingProviderDown": {
        "description": "YCLIENTS не ответил или ответил непонятным. Ошибка временная — можно повторить позже; создание записи при этом не происходило",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "YCLIENTS не ответил"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Превышен лимит 120 запросов в минуту. Повторите через `retryAfter` секунд",
        "headers": {
          "Retry-After": {
            "description": "Через сколько секунд повторить",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ChannelRejected": {
        "description": "Канал не принял сообщение — подробности в поле `error`",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/SendResult"
            }
          }
        }
      },
      "BookingRecordNotFound": {
        "description": "Записи с таким номером среди созданных через OmniGate нет. Записи, сделанные администратором салона или через сайт салона, недоступны: YCLIENTS отдаёт код отмены только в ответе на создание.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "запись не найдена среди созданных через OmniGate — отменить или перенести её можно только в YCLIENTS"
            }
          }
        }
      },
      "BookingRecordConflict": {
        "description": "С записью в её нынешнем состоянии сделать это нельзя: она уже перенесена, либо у неё не сохранён код отмены.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "у этой записи не сохранён код отмены — её нужно отменить в YCLIENTS вручную"
            }
          }
        }
      },
      "BookingRescheduleConflict": {
        "description": "Либо новое время уже заняли — тогда в ответе есть freeTimes с ближайшим свободным, — либо запись уже отменена или перенесена, либо новая запись создана, а старую снять не удалось (у клиента две записи, салону ушло уведомление).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "это время у мастера уже занято или он в этот день не работает",
              "freeTimes": [
                {
                  "time": "10:30",
                  "datetime": "2026-08-08T10:30:00+05:00"
                }
              ]
            }
          }
        }
      }
    }
  }
}
