{
  "openapi": "3.1.0",
  "info": {
    "title": "Jooptor Sites API",
    "version": "1.0.0",
    "summary": "Меню, заказы и заявки для сайтов и лендингов магазинов и заведений на Jooptor",
    "description": "Публичный API для сайтов, которые работают с Jooptor: сайт берёт меню (товары или блюда, цены, стоп-лист, точки самовывоза) и отправляет заказы и заявки. Они попадают в панель Jooptor рядом с заказами из Telegram, Instagram и WhatsApp.\n\nКлюч `site_…` выдаётся владельцу бизнеса в панели: Настройки → Интеграции → «Сайты и лендинги». Это публикуемый ключ: он открывает только то, что видит покупатель. Цены и сумму заказа всегда считает сервер.\n\nДокументация с примерами: https://jooptor.xyz/developers.html",
    "contact": { "name": "Jooptor", "url": "https://jooptor.xyz/developers.html" },
    "termsOfService": "https://jooptor.xyz/privacy.html"
  },
  "servers": [{ "url": "https://europe-west1-supercrm-a250d.cloudfunctions.net/api/v1", "description": "Production" }],
  "security": [{ "siteKey": [] }],
  "tags": [
    { "name": "Menu", "description": "Каталог: товары или блюда" },
    { "name": "Orders", "description": "Заказы с корзиной" },
    { "name": "Requests", "description": "Заявки без корзины: бронь стола, обратный звонок, вопрос" }
  ],
  "paths": {
    "/menu": {
      "get": {
        "tags": ["Menu"],
        "operationId": "getMenu",
        "summary": "Меню целиком",
        "description": "Категории, позиции с ценами и отметкой стоп-листа, точки самовывоза. Кэшируется на 30 секунд.",
        "responses": {
          "200": {
            "description": "Меню",
            "headers": { "Cache-Control": { "schema": { "type": "string", "example": "private, max-age=30" } }, "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Menu" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/orders": {
      "post": {
        "tags": ["Orders"],
        "operationId": "createOrder",
        "summary": "Оформить заказ",
        "description": "Сервер пересчитывает цены по меню, проверяет стоп-лист и телефон. Повторите запрос с тем же `Idempotency-Key`, если ответ не дошёл, — второго заказа не будет.",
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OrderInput" } } } },
        "responses": {
          "201": {
            "description": "Заказ принят",
            "headers": { "Idempotent-Replayed": { "description": "true — это сохранённый ответ на повтор запроса", "schema": { "type": "string" } }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Order" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "422": { "$ref": "#/components/responses/IdempotencyConflict" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/orders/{id}": {
      "get": {
        "tags": ["Orders"],
        "operationId": "getOrder",
        "summary": "Статус заказа",
        "description": "Только заказы, оформленные этим ключом. Без персональных данных.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z0-9]{1,64}$" } }],
        "responses": {
          "200": { "description": "Статус", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OrderStatus" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    },
    "/requests": {
      "post": {
        "tags": ["Requests"],
        "operationId": "createRequest",
        "summary": "Оставить заявку",
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RequestInput" } } } },
        "responses": {
          "201": { "description": "Заявка принята", "content": { "application/json": { "schema": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "422": { "$ref": "#/components/responses/IdempotencyConflict" },
          "429": { "$ref": "#/components/responses/TooManyRequests" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "siteKey": { "type": "http", "scheme": "bearer", "bearerFormat": "site_…", "description": "Ключ сайта из панели Jooptor: `Authorization: Bearer site_…`" }
    },
    "parameters": {
      "IdempotencyKey": { "name": "Idempotency-Key", "in": "header", "required": false, "description": "Уникальная строка на одну попытку оформления (например, UUID). Повтор с тем же ключом и телом вернёт тот же заказ в его текущем состоянии. Хранится 24 часа.", "schema": { "type": "string", "pattern": "^[A-Za-z0-9_.:-]{8,128}$" } }
    },
    "headers": {
      "RequestId": { "description": "Идентификатор запроса — назовите его в поддержке", "schema": { "type": "string", "format": "uuid" } },
      "RateLimitLimit": { "description": "Запросов в окне (час) с одного IP для этого бизнеса", "schema": { "type": "integer" } },
      "RateLimitRemaining": { "description": "Осталось в текущем окне", "schema": { "type": "integer" } },
      "RateLimitReset": { "description": "Секунд до нового окна", "schema": { "type": "integer" } }
    },
    "responses": {
      "BadRequest": { "description": "Неверное тело, телефон или адрес (`invalid_request`, `invalid_phone`)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Unauthorized": { "description": "Нет ключа или ключ неизвестен (`missing_key`, `invalid_key`)", "headers": { "WWW-Authenticate": { "schema": { "type": "string", "example": "Bearer realm=\"jooptor\", error=\"invalid_token\"" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Forbidden": { "description": "Ключ выключен, домен не разрешён, покупатель заблокирован (`key_disabled`, `origin_not_allowed`, `customer_blocked`)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "NotFound": { "description": "Нет такого адреса или заказа (`not_found`)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Conflict": { "description": "Позиции закончились или в стоп-листе (`items_unavailable`, `details.problems`), либо запрос с этим Idempotency-Key ещё выполняется (`idempotency_in_progress`)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "IdempotencyConflict": { "description": "Idempotency-Key уже использован с другим телом (`idempotency_conflict`)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "TooManyRequests": { "description": "Лимит запросов (`rate_limited`): 600 в минуту с одного IP на ключ, 30 в минуту с неверным ключом, 30 в час заказов и 30 заявок с одного IP на бизнес; см. Retry-After", "headers": { "Retry-After": { "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error", "code", "requestId"],
        "properties": {
          "error": { "type": "string", "description": "Текст для человека (язык — поле lang запроса)" },
          "code": { "type": "string", "enum": ["missing_key", "invalid_key", "key_disabled", "origin_not_allowed", "invalid_request", "invalid_phone", "customer_blocked", "items_unavailable", "not_found", "rate_limited", "idempotency_in_progress", "idempotency_conflict", "server_error"] },
          "requestId": { "type": "string" },
          "issues": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "message": { "type": "string" } } } },
          "details": { "type": "object", "properties": { "problems": { "type": "array", "items": { "type": "object", "properties": { "productId": { "type": "string" }, "available": { "type": "integer" } } } } } }
        }
      },
      "Menu": {
        "type": "object",
        "required": ["business", "categories", "items", "locations"],
        "properties": {
          "business": {
            "type": "object",
            "properties": {
              "name": { "type": "string" }, "description": { "type": "string" }, "phone": { "type": "string" }, "workingHours": { "type": "string" },
              "mode": { "type": "string", "enum": ["shop", "restaurant"], "description": "restaurant — блюда" },
              "country": { "type": "string", "enum": ["KG", "KZ"] }, "currency": { "type": "string", "example": "KGS" },
              "paymentDetails": { "type": "string" }, "paymentQrUrl": { "type": "string" }
            }
          },
          "categories": { "type": "array", "items": { "type": "object", "required": ["id", "name"], "properties": { "id": { "type": "string" }, "name": { "type": "string" } } } },
          "items": { "type": "array", "description": "В порядке меню", "items": { "$ref": "#/components/schemas/Item" } },
          "locations": { "type": "array", "description": "Точки самовывоза", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "address": { "type": "string" }, "landmark": { "type": "string" } } } }
        }
      },
      "Item": {
        "type": "object",
        "required": ["id", "name", "priceKgs", "available"],
        "properties": {
          "id": { "type": "string" }, "name": { "type": "string" }, "description": { "type": "string" },
          "priceKgs": { "type": "number" }, "photoUrls": { "type": "array", "items": { "type": "string", "format": "uri" } },
          "categoryId": { "type": ["string", "null"] },
          "attributes": { "type": "object", "additionalProperties": { "type": "string" }, "example": { "Выход": "350 г" } },
          "available": { "type": "boolean", "description": "false — стоп-лист или закончилось: показать можно, заказать нельзя" }
        }
      },
      "Address": {
        "type": "object", "additionalProperties": false,
        "required": ["city", "street", "house", "flat", "comment"],
        "properties": { "city": { "type": "string", "maxLength": 80 }, "street": { "type": "string", "maxLength": 120 }, "house": { "type": "string", "maxLength": 20 }, "flat": { "type": "string", "maxLength": 20 }, "comment": { "type": "string", "maxLength": 300 } }
      },
      "OrderInput": {
        "type": "object", "additionalProperties": false,
        "required": ["items", "name", "phone", "fulfillment"],
        "properties": {
          "items": { "type": "array", "minItems": 1, "maxItems": 50, "items": { "type": "object", "additionalProperties": false, "required": ["productId", "qty"], "properties": { "productId": { "type": "string" }, "qty": { "type": "integer", "minimum": 1, "maximum": 999 } } } },
          "name": { "type": "string", "minLength": 1, "maxLength": 80 },
          "phone": { "type": "string", "description": "КР: +996 и 9 цифр (0555…, 555…, +996555… подходят); КЗ: +7 и 10 цифр" },
          "fulfillment": {
            "oneOf": [
              { "type": "object", "additionalProperties": false, "required": ["method", "locationId"], "properties": { "method": { "const": "pickup" }, "locationId": { "type": "string" } } },
              { "type": "object", "additionalProperties": false, "required": ["method", "address"], "properties": {
                "method": { "const": "delivery" },
                "address": { "oneOf": [{ "$ref": "#/components/schemas/Address" }, { "type": "string", "minLength": 5, "maxLength": 200 }] },
                "location": { "type": "object", "additionalProperties": false, "required": ["lat", "lng"], "properties": { "lat": { "type": "number", "minimum": -90, "maximum": 90 }, "lng": { "type": "number", "minimum": -180, "maximum": 180 }, "accuracy": { "type": "number", "minimum": 0 } } }
              } }
            ]
          },
          "comment": { "type": "string", "maxLength": 500 },
          "lang": { "type": "string", "enum": ["ru", "ky", "uz"], "description": "Язык текстов ошибок" }
        }
      },
      "Order": {
        "type": "object",
        "required": ["id", "number", "status", "totalKgs", "items"],
        "properties": {
          "id": { "type": "string" }, "number": { "type": "string", "description": "Короткий номер для покупателя" },
          "status": { "type": "string", "enum": ["new", "confirmed", "shipped", "done", "cancelled"] },
          "totalKgs": { "type": "number" },
          "items": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "qty": { "type": "integer" }, "priceKgs": { "type": "number" } } } },
          "paymentUrl": { "type": ["string", "null"], "description": "Ссылка на онлайн-оплату, если подключена" },
          "paymentDetails": { "type": "string" }, "paymentQrUrl": { "type": "string" }
        }
      },
      "OrderStatus": {
        "type": "object",
        "properties": { "id": { "type": "string" }, "number": { "type": "string" }, "status": { "type": "string", "enum": ["new", "confirmed", "shipped", "done", "cancelled"] }, "totalKgs": { "type": "number" }, "paid": { "type": "boolean" }, "createdAt": { "type": "string", "format": "date-time" } }
      },
      "RequestInput": {
        "type": "object", "additionalProperties": false,
        "required": ["name", "phone"],
        "properties": {
          "kind": { "type": "string", "enum": ["booking", "callback", "question", "other"], "default": "other" },
          "name": { "type": "string", "minLength": 1, "maxLength": 80 },
          "phone": { "type": "string" },
          "text": { "type": "string", "maxLength": 1000 },
          "date": { "type": "string", "maxLength": 40, "description": "Как ввёл посетитель: «пт 19:30»" },
          "guests": { "type": "integer", "minimum": 1, "maximum": 500 },
          "lang": { "type": "string", "enum": ["ru", "ky", "uz"] }
        }
      }
    }
  }
}
