openapi: 3.1.0

info:
  title: DSS Partner API
  version: "0.2.0"
  summary: Партнёрский API продажи билетов ДСС (Ticketon, Kassir и другие агрегаторы)
  description: |
    Контракт партнёрского API Единой цифровой платформы ДСС.

    **Статус: контракт версии 0.2.0 (после двух внутренних ревью), кода на
    стороне ДСС ещё нет.** Документ выдан партнёру, чтобы его разработка встала
    в очередь параллельно нашей. Правило версий: добавление полей и кодов
    происходит без предупреждения, удаление и переименование только новой
    версией с уведомлением и окном параллельной работы.

    Форма API задана техническим заданием СКУП ДСС §4.2.14. Отличия от ТЗ, которых
    шесть, перечислены в сопроводительном документе
    `2026-09-01-ticketon-partner-api-contract.md` и помечены в описаниях ниже
    словом «Отступление».

    ## Порядок вызовов

    ```
    GET  /events                       что продавать (и поллинг изменений)
    GET  /events/{id}                  карточка мероприятия
    GET  /events/{id}/seats            сводка секторов, затем места сектора
    POST /bookings                     удержать места на время выбора
    POST /orders                       заказ: места заперты платёжным окном
    ...списание денег на стороне партнёра...
    POST /orders/{id}/confirm          оплата подтверждена, билеты в ответе
    POST /orders/{id}/cancel           эквайер отказал, места освобождены
    GET  /orders?partner_order_id=     восстановление после потери ответа
    ```

    🔴 **Списывать деньги с покупателя раньше `201` на `POST /orders` запрещено
    контрактом.** Заказ и есть гарантия места: до него бронь может истечь, и
    получится «деньги списаны, места нет». Порядок «сначала заказ, потом
    списание» защищает покупателя партнёра, и он проверяется сертификацией и
    сверкой (`paid_at` не раньше `created_at` заказа).

    ## Общие правила

    * Транспорт: HTTPS, TLS 1.2 и выше. HTTP не поддерживается.
    * Кодировка UTF-8, тела запросов и ответов JSON.
    * Имена полей `snake_case` (по ТЗ). Остальные поверхности платформы используют
      `camelCase`; партнёрский префикс из этого правила выведен намеренно.
    * Успешный ответ обёрнут в `{"data": ...}`, списки дополнительно несут
      `{"meta": {"total", "limit", "offset"}}`. ТЗ формы успеха не задаёт, поэтому
      она взята платформенная.
    * Ошибка возвращается в формате ТЗ: `{"error": {"code", "message", "details"}}`.
    * Денежные суммы: целые тенге, без копеек и без дробной части.
    * Время: ISO 8601 со смещением, например `2026-10-05T19:00:00+05:00`.
      Часовой пояс площадок Алматы, UTC+5.
    * Идентификаторы мест (`space_id`, `sector_id`, `row_id`) это строки платформы
      вида `seat-AA-A-03-007`, `sector-AA-A`. Партнёр обращается с ними как с
      непрозрачными строками и не разбирает их структуру.

    ## Аутентификация

    Каждый запрос подписывается. Требуются три заголовка:

    | Заголовок | Значение |
    |---|---|
    | `X-Api-Key` | публичный ключ партнёра, выдаётся ДСС |
    | `X-Timestamp` | время запроса, Unix-секунды, целое число |
    | `X-Signature` | HMAC-SHA256 в нижнем регистре hex |

    Подписываемая строка (`canonical`) собирается из четырёх частей, разделённых
    символом перевода строки `\n`:

    ```
    <timestamp>\n<METHOD>\n<uri>\n<body>
    ```

    * `<METHOD>` пишется заглавными: `GET`, `POST`.
    * `<uri>` это полный путь с query-строкой ровно в том виде, в каком он уходит на
      провод, включая префикс `/api/v1/partners`. Хост не входит. Никакой
      пересортировки параметров: подписывается то, что отправлено, байт в байт.
    * `<body>` это тело запроса ровно как отправлено. Для `GET` пустая строка,
      но разделитель `\n` перед ней сохраняется.

    Подпись: `HMAC-SHA256(key = secret_key, message = canonical)`, результат в hex.
    Секретный ключ выступает КЛЮЧОМ HMAC, а не частью сообщения.

    Окно валидности `X-Timestamp` составляет 5 минут в обе стороны. За его пределами
    ответ `401 INVALID_SIGNATURE`.

    Необязательный заголовок `X-Request-Id` (UUID) принимается на всех запросах и
    служит идентификатором сквозной трассировки между нашими системами.
    Он возвращается тем же заголовком в ответе, в том числе в ответе с ошибкой, и
    его следует писать в свой журнал рядом с телом отказа: по нему ДСС находит
    запрос у себя. На идемпотентность он не влияет, за неё отвечает
    `partner_order_id`.

    ### Проверочный вектор

    Обе стороны обязаны получить эти подписи до начала интеграции.
    Секрет: `sandbox_secret_do_not_use_in_prod`

    POST, `X-Timestamp: 1790841600`, тело без пробелов:
    ```
    1790841600\nPOST\n/api/v1/partners/bookings\n{"event_id":32,"partner_booking_id":"TKT-BKG-2026-0009871","seats":["seat-AA-A-03-005","seat-AA-A-03-006"],"customer":{"email":"buyer@ticketon.kz"}}
    ```
    `X-Signature: d2b089654f675c25a6f8e3dacce46f5850c7a5a6c62a8a697fcd92e8fc6c5527`

    GET, `X-Timestamp: 1790841600`, тело пустое:
    ```
    1790841600\nGET\n/api/v1/partners/events?date_from=2026-10-01&limit=50\n
    ```
    `X-Signature: d783761a531da8c84c1e6fba1c048f5ebc6cf8c9ac66fe43a7cf060dd95616ca`

    ⚠️ `\n` в канонических строках выше это ОДИН символ перевода строки (байт
    `0x0A`), а не два символа «обратный слэш и n»: строки показаны в одну строку
    только для читаемости. Escape-последовательность, скопированная буквально,
    даст другую подпись.

    Отдельного nonce в схеме нет намеренно: повтор запроса внутри пятиминутного
    окна безвреден, потому что каждый изменяющий вызов идемпотентен по ключам
    партнёра (`partner_booking_id`, `partner_order_id`). Replay воспроизводит
    тот же запрос с тем же результатом, нового эффекта не создаёт.

    Три частые ошибки реализации: `X-Timestamp` это СЕКУНДЫ (десять цифр), не
    миллисекунды; тело сериализуется ровно один раз, и подписываются те же
    байты, что уходят на провод; `Content-Type: application/json` обязателен.
    Отсутствующий `X-Api-Key` отвечает `INVALID_API_KEY`, всё остальное
    некорректное в аутентификации отвечает `INVALID_SIGNATURE`.

    ## Ограничение частоты

    100 запросов в минуту на партнёра: значение по умолчанию из ТЗ. **Боевое
    значение договорное** и настраивается на партнёра, как и TTL брони с
    платёжным окном; под онсейл оно согласуется заранее. Каждый ответ несёт
    `X-RateLimit-Limit`, `X-RateLimit-Remaining` и `X-RateLimit-Reset`
    (Unix-секунды). При превышении: `429` с кодом `RATE_LIMIT_EXCEEDED`.

    Рекомендованная каденция чтений: сводку секторов опрашивать не чаще раза в
    10 секунд на мероприятие, места сектора запрашивать по факту открытия
    сектора пользователем, а не фоном по всем секторам. Доступность в чтениях
    справочная: истиной является только успешная бронь, поэтому частый опрос
    точности не добавляет.

    ⚠️ Перед приложением стоит общий внешний щит инфраструктуры: его `429`
    может прийти БЕЗ тела нашего формата и без заголовков `X-RateLimit-*`.
    Ветвиться по HTTP-статусу и повторять с нарастающей задержкой. То же про
    `5xx`: тело может быть вне формата `{"error": …}`. Все изменяющие вызовы
    идемпотентны, повтор безопасен; рекомендуемый таймаут клиента 10 секунд.

    ## Доступ к мероприятиям

    Партнёр видит и продаёт только те мероприятия, на которые ДСС выдала ему
    грант; грант выдаётся поштучно на мероприятие (требование техспеки
    заказчика: партнёр получает только разрешённые данные). Правила отказов:
    несуществующий `event_id` отвечает `404 EVENT_NOT_FOUND`; существующий, но
    без гранта отвечает `403 ACCESS_DENIED`; в списке `GET /events` ни того ни
    другого нет. Отменённое мероприятие с грантом остаётся ЧИТАЕМЫМ (список с
    `updated_since`, карточка, места), а бронь по нему отвечает
    `409 SALES_NOT_OPEN`.

    Квотной модели нет: партнёр работает с живым инвентарём, тем же самым, что
    витрина ДСС. Место, проданное на витрине, исчезает у партнёра немедленно, и
    наоборот. ДСС остаётся мастер-системой статусов мест.

    ## Деньги

    Партнёр принимает оплату у себя и сам выполняет фискализацию (ТЗ §4.2.14,
    «Партнеры-агрегаторы: самостоятельная фискализация»). Платёжный шлюз ДСС в
    партнёрском потоке не участвует. `payment_info` в создании заказа это внешний
    факт, который ДСС принимает и хранит для сверки, а не платёж, который ДСС
    проводит. Взаиморасчёт периодический, вне API.

  contact:
    name: ДСС, техническое руководство БКС (адрес передаётся вместе с ключами)

servers:
  - url: https://api-sandbox.dss.ndsvs.kz/api/v1/partners
    description: |
      Sandbox, планируемая доступность с 15 сентября 2026. ⚠️ Адрес подлежит
      утверждению ДСС: в ТЗ стоит пример `api-sandbox.skup-dss.kz`, живой домен
      платформы `ndsvs.kz`.
  - url: https://api-dss.ndsvs.kz/api/v1/partners
    description: Продуктив. Доступ открывается после прохождения сценариев sandbox.

security:
  - ApiKey: []
    Timestamp: []
    Signature: []

tags:
  - name: events
    description: Поиск мероприятий и доступность мест
  - name: bookings
    description: Временное бронирование
  - name: orders
    description: Подтверждение продажи и возврат

paths:

  /events:
    parameters:
      - $ref: "#/components/parameters/RequestId"
    get:
      tags: [events]
      operationId: listEvents
      summary: Поиск и список доступных мероприятий
      description: |
        Возвращает мероприятия, на которые у партнёра есть грант и которые открыты
        для продажи. Мероприятие без гранта в выдачу не попадает.

        **Поллинг изменений обязателен.** Партнёр опрашивает эту ручку с
        `updated_since` **не реже раза в 5 минут**: это гарантированный канал,
        которым доезжают отмена и перенос мероприятия (`status`, `starts_at`) и
        отзыв доступа (`access: "revoked"`). Вебхуки появятся позже как
        ускоритель и обязательный поллинг не отменят. Отменённое, перенесённое
        или отозванное мероприятие ОСТАЁТСЯ в выдаче с `updated_since`, а не
        молча исчезает: исчезновение из списка не событие, и партнёр его не
        заметил бы. Ответ несёт `meta.cursor` для следующего запроса.
      parameters:
        - name: updated_since
          in: query
          required: false
          description: |
            Курсор изменений: значение `meta.cursor` из ПРЕДЫДУЩЕГО ответа этой
            ручки. Собственное время партнёра сюда не подставляется: часы двух
            систем расходятся, курсор серверный и непрозрачный. Первый запрос
            делается без параметра. Изменением считаются статус, даты и отзыв
            доступа; цены и доступность не считаются.
          schema: { type: string, maxLength: 64 }
        - name: date_from
          in: query
          required: false
          description: Начало периода по дате начала мероприятия, `YYYY-MM-DD`.
          schema: { type: string, format: date, examples: ["2026-10-01"] }
        - name: date_to
          in: query
          required: false
          description: Конец периода включительно, `YYYY-MM-DD`.
          schema: { type: string, format: date, examples: ["2026-12-31"] }
        - name: venue_id
          in: query
          required: false
          description: Фильтр по объекту (площадке).
          schema: { type: string, examples: ["almaty-arena"] }
        - name: event_type
          in: query
          required: false
          description: Тип мероприятия, словарь закрыт.
          schema:
            type: string
            enum: [concert, sport, theatre, exhibition, conference, festival, kids, other]
            examples: ["sport"]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: Список мероприятий
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Event" }
                  meta: { $ref: "#/components/schemas/Meta" }
              example:
                data:
                  - event_id: 32
                    title: { ru: "Демо-концерт", kk: "Демо-концерт", en: "Demo Concert" }
                    description: null
                    event_type: concert
                    age_rating: "12+"
                    starts_at: "2026-12-20T19:00:00+05:00"
                    ends_at: "2026-12-20T22:00:00+05:00"
                    venue_id: almaty-arena
                    venue_name: "Алматы-Арена"
                    venue_address: "г. Алматы, ул. Момышулы, 2"
                    hall_name: { ru: "Чаша" }
                    poster_url: "https://api-dss.ndsvs.kz/files/dss-plans/photos/events/event-32.jpg"
                    price_from_tenge: 6000
                    available_seats: 914
                    max_seats_per_booking: 8
                    status: sales_open
                    sales_start_at: "2026-09-20T10:00:00+05:00"
                    sales_end_at: null
                    access: active
                meta: { total: 1, limit: 50, offset: 0, cursor: "2026-10-01T08:00:00.412906+00:00" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/InvalidParameters" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /events/{event_id}:
    parameters:
      - $ref: "#/components/parameters/RequestId"
    get:
      tags: [events]
      operationId: getEvent
      summary: Карточка мероприятия
      description: |
        Текущее состояние одного мероприятия с грантом: обновление страницы
        партнёра между опросами фида. Отменённое мероприятие с грантом
        читается.
      parameters:
        - name: event_id
          in: path
          required: true
          schema: { type: integer, examples: [32] }
      responses:
        "200":
          description: Карточка мероприятия
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccessDenied" }
        "404": { $ref: "#/components/responses/EventNotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /events/{event_id}/seats:
    parameters:
      - $ref: "#/components/parameters/RequestId"
    get:
      tags: [events]
      operationId: listSeats
      summary: Доступность мест на мероприятие
      description: |
        **Отступление от ТЗ (1 из 6).** ТЗ просит «схему зала с актуальными
        статусами мест» одним ответом. Платформа отдаёт места ПОСЕКТОРНО: зал
        первого объекта это 960 мест в шести секторах, и посекторная выдача с
        посекторным кэшем принята архитектурно.

        Поэтому ручка работает в двух режимах:

        * без параметра `sector`: сводка по секторам (`sectors`), где для каждого
          сектора есть минимальная цена и число свободных мест;
        * с параметром `sector`: места этого сектора (`seats`).

        Партнёр строит схему зала одним запросом сводки и по одному запросу на
        сектор, который открывает пользователь.

        **Геометрия для отрисовки:** ДСС по запросу отдаёт публичный SVG-план
        зала, в котором `id` элементов совпадают с `space_id` этого API по
        построению (геометрия и данные на платформе разделены именно так).
        Запросить у технического контакта.
      parameters:
        - name: event_id
          in: path
          required: true
          schema: { type: integer, examples: [32] }
        - name: sector
          in: query
          required: false
          description: Идентификатор сектора. Без него возвращается сводка по секторам.
          schema: { type: string, examples: ["sector-AA-A"] }
        - name: category
          in: query
          required: false
          description: |
            Фильтр по коду категории билетов. Допустимые значения:
            `standard`, `vip`, `fan`, `invitation`, `service`.
          schema:
            type: string
            enum: [standard, vip, fan, invitation, service]
      responses:
        "200":
          description: Сводка по секторам либо места сектора
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SeatsPayload" }
              examples:
                sectors_summary:
                  summary: Без параметра sector, сводка по секторам
                  value:
                    data:
                      event_id: 32
                      sector_id: null
                      sectors:
                        - { sector_id: sector-AA-A, sector_code: A, price_from_tenge: 8000, available_count: 152 }
                        - { sector_id: sector-AA-B, sector_code: B, price_from_tenge: 6000, available_count: 160 }
                sector_seats:
                  summary: С параметром sector, места сектора
                  value:
                    data:
                      event_id: 32
                      sector_id: sector-AA-A
                      seats:
                        - { space_id: seat-AA-A-03-005, row_id: row-AA-A-03, row_code: "03", code: "005", seat_ord: 5, status: free, is_sellable: true, category_code: standard, price_tenge: 8000 }
                        - { space_id: seat-AA-A-03-006, row_id: row-AA-A-03, row_code: "03", code: "006", seat_ord: 6, status: held, is_sellable: false, category_code: standard, price_tenge: 8000 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccessDenied" }
        "404": { $ref: "#/components/responses/EventNotFound" }
        "422": { $ref: "#/components/responses/InvalidParameters" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /bookings:
    parameters:
      - $ref: "#/components/parameters/RequestId"
    post:
      tags: [bookings]
      operationId: createBooking
      summary: Временное бронирование мест
      description: |
        Удерживает места на срок брони. Захват атомарный: либо удержаны все
        перечисленные места, либо ни одно.

        Срок брони настраивается для каждого партнёра, значение по умолчанию
        3 минуты (ТЗ). Точное значение приезжает в `expires_at` и `ttl_seconds`;
        не зашивать его константой.

        **Цена фиксируется в момент брони**: `price_tenge` каждого места в
        ответе и `total_tenge` брони наследуются заказом как есть. До брони цены
        могут меняться без предупреждения, поэтому показывать покупателю итог
        стоит из ответа брони, а не из ранее закэшированных чтений.

        Льготных цен в первой фазе интеграции нет: каждое место бронируется по
        полной цене своей категории.

        Бронь ничего не резервирует за покупателем и не является продажей. По
        истечении срока места освобождаются автоматически.

        Идемпотентна по `partner_booking_id`: повтор с тем же значением возвращает
        ту же бронь с кодом `200` и не удерживает второй набор мест.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookingCreate" }
            example:
              event_id: 32
              partner_booking_id: "TKT-BKG-2026-0009871"
              seats: ["seat-AA-A-03-005", "seat-AA-A-03-006"]
              customer: { email: "buyer@ticketon.kz" }
      responses:
        "201":
          description: Места удержаны
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Booking" }
              example:
                data:
                  booking_id: "6f1c1a34-6c8a-4c1e-9f2b-2b7f5a1e0d11"
                  partner_booking_id: "TKT-BKG-2026-0009871"
                  event_id: 32
                  status: active
                  seats:
                    - { space_id: "seat-AA-A-03-005", sector_id: "sector-AA-A", row_code: "03", seat_code: "005", category_code: standard, price_tenge: 8000 }
                    - { space_id: "seat-AA-A-03-006", sector_id: "sector-AA-A", row_code: "03", seat_code: "006", category_code: standard, price_tenge: 8000 }
                  expires_at: "2026-10-01T13:03:00+05:00"
                  ttl_seconds: 180
                  total_tenge: 16000
        "200":
          description: |
            Повтор с тем же `partner_booking_id`. Возвращается ранее созданная
            бронь, новые места не удерживаются.
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Booking" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccessDenied" }
        "404": { $ref: "#/components/responses/EventNotFound" }
        "409": { $ref: "#/components/responses/SeatsNotAvailable" }
        "422": { $ref: "#/components/responses/InvalidParameters" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /orders:
    parameters:
      - $ref: "#/components/parameters/RequestId"
    get:
      tags: [orders]
      operationId: getOrderByPartnerOrderId
      summary: Чтение заказа по идентификатору партнёра (восстановление)
      description: |
        Ручка восстановления и разбора: возвращает заказ по `partner_order_id`
        в его текущем статусе, с билетами, если они выпущены.

        **Сценарий, ради которого она существует:** ответ на `POST /orders`
        потерян (таймаут, падение процесса), `order_id` неизвестен, тело для
        идемпотентного повтора не сохранилось. Этот `GET` отдаёт заказ по
        единственному, что у партнёра точно есть: его собственному
        идентификатору. Дальше `confirm` или `cancel` по полученному `order_id`.

        Также пригодна поддержке партнёра для ручного разбора обращений.
      parameters:
        - name: partner_order_id
          in: query
          required: true
          schema: { type: string, maxLength: 64 }
      responses:
        "200":
          description: Заказ найден
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Order" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/OrderNotFound" }
        "422": { $ref: "#/components/responses/InvalidParameters" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
    post:
      tags: [orders]
      operationId: createOrder
      summary: Оформление заказа до списания денег
      description: |
        **Отступление от ТЗ (5 из 6).** В ТЗ этот шаг совмещает подтверждение
        продажи с передачей платежа. Здесь он разделён на два вызова ради
        покупателя партнёра: заказ запирает места на платёжное окно ДО списания,
        поэтому «деньги списаны, а места нет» не существует как класс.

        Превращает бронь в заказ со статусом `pending`. Места остаются за заказом
        до `expires_at` (платёжное окно партнёра, по умолчанию 5 минут,
        настраивается). Дальше партнёр списывает деньги и зовёт `confirm`; если
        эквайер отказал, зовёт `cancel`. Не случилось ни того ни другого: заказ
        истекает сам, места возвращаются в продажу, подвисших билетов без денег
        не остаётся.

        Билеты в этом ответе НЕ приходят: они выпускаются на `confirm`.

        **Идемпотентность.** Пара `(партнёр, partner_order_id)` уникальна. Повтор
        того же `partner_order_id` возвращает тот же заказ с кодом `200` и не
        создаёт второго. Пара разных `partner_order_id` на одну бронь это ошибка
        `409 BOOKING_ALREADY_CONFIRMED`; тот же `partner_order_id` с другой
        бронью это `409 IDEMPOTENCY_CONFLICT`.

        Бронь с истёкшим сроком не превращается в заказ: `409 BOOKING_EXPIRED`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderCreate" }
            example:
              booking_id: "6f1c1a34-6c8a-4c1e-9f2b-2b7f5a1e0d11"
              partner_order_id: "TKT-2026-0001234"
              customer: { name: "Айгерим С.", email: "buyer@ticketon.kz", phone: "+77011234567" }
              consent: true
      responses:
        "201":
          description: Заказ создан, места заперты платёжным окном
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Order" }
        "200":
          description: |
            Повтор с тем же `partner_order_id`. Возвращается ранее созданный
            заказ в его текущем статусе, ничего не создаётся.
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Order" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccessDenied" }
        "409": { $ref: "#/components/responses/BookingExpired" }
        "422": { $ref: "#/components/responses/InvalidParameters" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /orders/{order_id}/confirm:
    parameters:
      - $ref: "#/components/parameters/RequestId"
    post:
      tags: [orders]
      operationId: confirmOrder
      summary: Оплата подтверждена, выпуск билетов
      description: |
        Зовётся СРАЗУ после успешного списания, не из отложенной очереди.
        `payment_info` описывает внешний платёж и сохраняется у ДСС слепком для
        сверки.

        Билеты возвращаются в ответе: подписанный `qr_token` для турникета и
        `pdf_url` для скачивания. Писем покупателю ДСС по партнёрскому каналу не
        отправляет: доставка на стороне партнёра.

        Идемпотентен: повтор по уже подтверждённому заказу возвращает `200` с
        теми же билетами. Слепок `payment_info` при повторе не перезаписывается:
        учитывается первый принятый.

        Отказ `422` по сумме заказ НЕ убивает: он остаётся `pending`, окно
        продолжает идти, повтор с верной суммой допустим до `expires_at`.

        Заказ, истёкший до подтверждения: `409 ORDER_EXPIRED`. Деньги в этот
        момент уже списаны, поэтому партнёр возвращает их покупателю сам и
        немедленно. Такой исход означает, что между списанием и `confirm` прошло
        больше платёжного окна: это всегда дефект интеграции, и сверка считает
        каждый такой случай.
      parameters:
        - name: order_id
          in: path
          required: true
          description: Идентификатор заказа в системе ДСС, из ответа создания заказа.
          schema: { type: integer, examples: [1042] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderConfirm" }
            example:
              payment_info:
                amount_tenge: 16000
                currency: KZT
                paid_at: "2026-10-01T13:02:11+05:00"
                external_payment_id: "tk-pay-8842177"
                method: card
      responses:
        "200":
          description: Оплата принята, билеты выпущены (повтор возвращает те же)
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Order" }
              example:
                data:
                  order_id: 1042
                  order_number: "DSS-2026-001042"
                  partner_order_id: "TKT-2026-0001234"
                  event_id: 32
                  status: paid
                  total_tenge: 16000
                  created_at: "2026-10-01T13:01:40+05:00"
                  expires_at: null
                  tickets:
                    - ticket_id: 5001
                      number: "DSS-TKT-000061"
                      status: active
                      space_id: "seat-AA-A-03-005"
                      sector_code: "A"
                      row_code: "03"
                      seat_code: "005"
                      category_name: { ru: "Стандарт", kk: "Стандарт", en: "Standard" }
                      price_tenge: 8000
                      qr_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.<полезная нагрузка>.<подпись>"
                      pdf_url: "https://api-dss.ndsvs.kz/api/v1/tickets/5001/pdf?sig=…"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccessDenied" }
        "404": { $ref: "#/components/responses/OrderNotFound" }
        "409": { $ref: "#/components/responses/OrderExpired" }
        "422": { $ref: "#/components/responses/InvalidParameters" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /orders/{order_id}/cancel:
    parameters:
      - $ref: "#/components/parameters/RequestId"
    post:
      tags: [orders]
      operationId: cancelOrder
      summary: Списание не удалось, немедленное освобождение мест
      description: |
        Зовётся при отказе эквайера. Отменяет `pending`-заказ и возвращает места
        в продажу сразу, не дожидаясь истечения платёжного окна: покупатель,
        который тут же пробует другую карту, не должен ждать собственные места.

        Идемпотентен: повтор по уже отменённому или истёкшему заказу возвращает
        `200`. Подтверждённый заказ отменить нельзя (`409 ORDER_NOT_CANCELLABLE`):
        для оплаченного существует только возврат.
      parameters:
        - name: order_id
          in: path
          required: true
          schema: { type: integer, examples: [1042] }
      responses:
        "200":
          description: Заказ отменён либо уже был отменён, места свободны
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Order" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccessDenied" }
        "404": { $ref: "#/components/responses/OrderNotFound" }
        "409": { $ref: "#/components/responses/OrderNotCancellable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /orders/{order_id}/refund:
    parameters:
      - $ref: "#/components/parameters/RequestId"
    post:
      tags: [orders]
      operationId: refundOrder
      summary: Отметка возврата билетов
      description: |
        ⚠️ Доступна со ВТОРОЙ фазы интеграции. До неё возврат оформляется
        обращением в ДСС (контакт и срок обработки фиксируются договором): наш
        персонал гасит билет вручную, деньги покупателю в любом случае
        возвращает партнёр у себя.

        Гасит перечисленные билеты и освобождает их места. Деньги покупателю
        возвращает партнёр на своей стороне: ДСС отмечает факт и приводит инвентарь
        в соответствие.

        Погашенный билет на входе не проходит: контролёр видит отказ с причиной.
        Операция идемпотентна по билету: повтор по уже погашенному билету с той же
        причиной возвращает успех и не создаёт второй записи.

        Билет, по которому гость уже прошёл на объект, не возвращается:
        `409 TICKET_NOT_REFUNDABLE` с текущим статусом в `details`.
      parameters:
        - name: order_id
          in: path
          required: true
          description: Идентификатор заказа в системе ДСС, из ответа создания заказа.
          schema: { type: integer, examples: [1042] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefundCreate" }
            example:
              tickets: [5001, 5002]
              reason: "Покупатель отказался от поездки, возврат оформлен TKT-RF-511"
      responses:
        "200":
          description: Возврат отмечен
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/RefundResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AccessDenied" }
        "404": { $ref: "#/components/responses/OrderNotFound" }
        "409": { $ref: "#/components/responses/TicketNotRefundable" }
        "422": { $ref: "#/components/responses/InvalidParameters" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

webhooks:

  booking_expired:
    post:
      operationId: webhookBookingExpired
      summary: Истечение брони, места освобождены
      description: |
        ⚠️ **Все вебхуки это вторая фаза интеграции и ускоритель, а не гарантия.**
        Гарантированный канал изменений с первого дня: поллинг
        `GET /events?updated_since=` не реже раза в 5 минут (см. описание ручки).
        Свои брони и заказы партнёр и так знает по их `expires_at`.

        ДСС вызывает URL партнёра. Запрос подписывается ТОЙ ЖЕ схемой HMAC, что и
        входящие: заголовки `X-Api-Key`, `X-Timestamp`, `X-Signature`, где `uri` это
        полный путь вызываемого URL без хоста.

        Партнёр отвечает `2xx`. Любой другой ответ или таймаут означает повтор с
        нарастающей задержкой. Доставка **не менее одного раза**: обработчик обязан
        быть идемпотентным по `booking_id`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [booking_id, expired_at]
              properties:
                booking_id: { type: string, format: uuid }
                expired_at: { type: string, format: date-time }
                event_id: { type: integer }
                seats:
                  type: array
                  items: { type: string }
      responses:
        "200": { description: Принято }

  event_updated:
    post:
      operationId: webhookEventUpdated
      summary: Изменение мероприятия (перенос, правка)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_id, change_type]
              properties:
                event_id: { type: integer }
                change_type:
                  type: string
                  enum: [rescheduled, updated, sales_paused, sales_resumed]
                new_date:
                  type: string
                  format: date-time
                  description: Только для `change_type = rescheduled`.
      responses:
        "200": { description: Принято }

  event_cancelled:
    post:
      operationId: webhookEventCancelled
      summary: Отмена мероприятия
      description: |
        Инициирует возвраты на стороне партнёра. Билеты отменённого мероприятия
        гасятся ДСС автоматически и на входе не проходят.

        **Отступление от ТЗ (6 из 6):** в ТЗ вебхук единственный канал отмены.
        Здесь гарантия доставки отмены держится обязательным поллингом
        `updated_since`, а вебхук ускоряет её, когда подключён.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_id, cancellation_reason]
              properties:
                event_id: { type: integer }
                cancellation_reason: { type: string }
                cancelled_at: { type: string, format: date-time }
      responses:
        "200": { description: Принято }

components:

  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        Публичный ключ партнёра. Ключи стенда начинаются с `pk_sandbox_`,
        продуктивные с `pk_live_`.
    Timestamp:
      type: apiKey
      in: header
      name: X-Timestamp
      description: Unix-секунды. Окно валидности 5 минут.
    Signature:
      type: apiKey
      in: header
      name: X-Signature
      description: HMAC-SHA256 в hex, схема подписи в описании API.

  parameters:
    RequestId:
      name: X-Request-Id
      in: header
      required: false
      description: |
        UUID сквозной трассировки. Возвращается тем же заголовком в ответе,
        включая ответы с ошибкой; пишите его в свой журнал рядом с телом.
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      required: false
      description: Размер страницы.
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    Offset:
      name: offset
      in: query
      required: false
      schema: { type: integer, minimum: 0, default: 0 }

  headers:
    RequestId:
      description: Эхо присланного `X-Request-Id` либо серверный идентификатор.
      schema: { type: string }
    RateLimitLimit:
      description: Предел запросов в минуту для партнёра.
      schema: { type: integer, examples: [100] }
    RateLimitRemaining:
      description: Остаток в текущем окне.
      schema: { type: integer, examples: [97] }
    RateLimitReset:
      description: Момент сброса счётчика, Unix-секунды.
      schema: { type: integer, examples: [1790841660] }

  schemas:

    Meta:
      type: object
      required: [total, limit, offset]
      properties:
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        cursor:
          type: string
          description: |
            Только в ответах `GET /events`: непрозрачный серверный курсор
            изменений, передаётся в `updated_since` следующего запроса.

    LocalizedText:
      type: object
      description: |
        Трёхъязычный текст с гарантией всех трёх языков. Гарантия платформы
        покрывает `title` мероприятия (гейт публикации проверяет три языка).
      required: [ru, kk, en]
      properties:
        ru: { type: string }
        kk: { type: string }
        en: { type: string }

    LocalizedTextPartial:
      type: object
      description: |
        Локализованный текст, где гарантирован только русский: `kk` и `en`
        могут отсутствовать, показывайте с откатом на `ru`.
      required: [ru]
      properties:
        ru: { type: string }
        kk: { type: [string, "null"] }
        en: { type: [string, "null"] }

    Event:
      type: object
      required:
        [event_id, title, venue_id, venue_name, starts_at, status,
         available_seats, price_from_tenge]
      properties:
        event_id: { type: integer, examples: [32] }
        title: { $ref: "#/components/schemas/LocalizedText" }
        description:
          oneOf:
            - { $ref: "#/components/schemas/LocalizedTextPartial" }
            - { type: "null" }
        event_type:
          type: [string, "null"]
          enum: [concert, sport, theatre, exhibition, conference, festival, kids, other, null]
          examples: ["sport"]
        age_rating:
          type: [string, "null"]
          enum: ["0+", "6+", "12+", "16+", "18+", "21+", null]
          examples: ["12+"]
        starts_at: { type: string, format: date-time }
        ends_at: { type: [string, "null"], format: date-time }
        venue_id: { type: string, examples: ["almaty-arena"] }
        venue_name: { type: string, examples: ["Алматы-Арена"] }
        venue_address:
          type: [string, "null"]
          examples: ["г. Алматы, ул. Момышулы, 2"]
        hall_name:
          oneOf:
            - { $ref: "#/components/schemas/LocalizedTextPartial" }
            - { type: "null" }
        max_seats_per_booking:
          type: integer
          default: 8
          description: |
            Действующий потолок мест в одной брони для этого мероприятия.
            Превышение отвечает `409 BOOKING_LIMIT_EXCEEDED`.
        poster_url: { type: [string, "null"], format: uri }
        price_from_tenge:
          type: [integer, "null"]
          description: Минимальная цена по мероприятию. `null`, если цены не заданы.
        available_seats:
          type: integer
          description: Свободных мест на момент ответа, сумма по секторам.
        status:
          type: string
          enum: [sales_scheduled, sales_open, sales_paused, sales_closed, cancelled]
          description: |
            Словарь закрыт, внутренние состояния платформы сворачиваются в эти
            пять. Без `updated_since` в выдаче `sales_open`, `sales_paused`
            (пауза: бронирование отвергается, читать можно) и `sales_scheduled`
            (грант есть, продажи ещё не открыты: собирайте страницу заранее,
            онсейл в `sales_start_at`; бронь до открытия отвечает
            `SALES_NOT_OPEN`). С `updated_since` дополнительно
            приходят `cancelled` и `sales_closed`: это канал, которым партнёр
            узнаёт об отмене и закрытии продаж. Перенос приезжает изменением
            `starts_at`.
        sales_start_at: { type: [string, "null"], format: date-time }
        sales_end_at: { type: [string, "null"], format: date-time }
        access:
          type: string
          enum: [active, revoked]
          default: active
          description: |
            `revoked` приходит в выдаче с `updated_since`, когда ДСС отозвала
            доступ партнёра к мероприятию: продажу остановить, дальнейшие брони
            ответят 403. Уже проданные билеты остаются действительными, а `confirm`
            и `cancel` по уже созданным `pending`-заказам работают до конца
            платёжного окна: отзыв закрывает только новые брони и заказы.

    SectorSummary:
      type: object
      required: [sector_id, available_count]
      properties:
        sector_id: { type: string, examples: ["sector-AA-A"] }
        sector_code: { type: [string, "null"], examples: ["A"] }
        price_from_tenge: { type: [integer, "null"] }
        available_count: { type: integer }

    Seat:
      type: object
      required: [space_id, status, is_sellable]
      properties:
        space_id: { type: string, examples: ["seat-AA-A-03-007"] }
        row_id: { type: [string, "null"], examples: ["row-AA-A-03"] }
        row_code: { type: [string, "null"], examples: ["03"] }
        code:
          type: [string, "null"]
          description: Номер места, как он написан в зале.
          examples: ["007"]
        seat_ord: { type: [integer, "null"] }
        status:
          type: string
          enum: [free, held, sold, blocked]
          description: |
            Живой статус. `held` значит, что место удерживается кем-то прямо
            сейчас, в том числе покупателем на витрине ДСС. Внутренние
            служебные состояния инвентаря отдаются как `blocked`.
        is_sellable:
          type: boolean
          description: Можно ли это место продать сейчас. Учитывает статус и цену.
        category_code:
          type: [string, "null"]
          enum: [standard, vip, fan, invitation, service, null]
        price_tenge: { type: [integer, "null"] }

    SeatsPayload:
      type: object
      required: [event_id]
      description: |
        Без параметра `sector` заполнен `sectors`, поле `seats` отсутствует.
        С параметром `sector` наоборот.
      properties:
        event_id: { type: integer }
        sector_id: { type: [string, "null"] }
        sectors:
          type: array
          items: { $ref: "#/components/schemas/SectorSummary" }
        seats:
          type: array
          items: { $ref: "#/components/schemas/Seat" }

    Customer:
      type: object
      description: |
        Данные покупателя со стороны партнёра, для персонализации билета и сверки.
        ДСС хранит их как персональные данные и не использует для рассылки по
        партнёрскому каналу.
      properties:
        name: { type: [string, "null"] }
        email: { type: [string, "null"], format: email }
        phone: { type: [string, "null"], examples: ["+77011234567"] }

    OrderCustomer:
      type: object
      description: |
        То же, что `Customer`, но при создании заказа адрес обязателен: колонка
        `buyer_email` в базе ДСС не допускает пустого значения, и по ней же
        находится заказ при обращении в поддержку ДСС.

        Если Ticketon не передаёт адрес покупателя (вопрос принадлежности данных
        покупателя открыт), согласуется псевдоним, уникальный на заказ, например
        `order-TKT-2026-0001234@partner.ticketon.kz`. Доставка билета покупателю
        в этом случае целиком на партнёре, что и так предусмотрено контрактом.
      required: [email]
      properties:
        name: { type: [string, "null"] }
        email: { type: string, format: email }
        phone: { type: [string, "null"], examples: ["+77011234567"] }

    BookingCreate:
      type: object
      required: [event_id, seats, partner_booking_id]
      properties:
        event_id: { type: integer, examples: [32] }
        partner_booking_id:
          type: string
          maxLength: 64
          description: |
            Идентификатор брони на стороне партнёра, уникальный в пределах
            партнёра. Ключ идемпотентности: повтор с тем же значением возвращает
            ТУ ЖЕ бронь (200), а не создаёт вторую. Обязателен именно поэтому:
            ретрай после таймаута без него удерживал бы двойной набор мест.
          examples: ["TKT-BKG-2026-0009871"]
        seats:
          type: array
          minItems: 1
          maxItems: 20
          description: |
            Потолок 20 мест на бронь в первой фазе; групповые продажи сверх
            него обсуждаются отдельно.
          items: { type: string }
          examples: [["seat-AA-A-03-005", "seat-AA-A-03-006"]]
        customer: { $ref: "#/components/schemas/Customer" }

    Booking:
      type: object
      required: [booking_id, partner_booking_id, event_id, status, seats,
                 expires_at, ttl_seconds, total_tenge]
      properties:
        booking_id: { type: string, format: uuid }
        partner_booking_id: { type: string }
        status:
          type: string
          enum: [active, expired, converted]
          description: |
            `converted` означает, что по брони уже создан заказ, см. `order_id`.
            Повтор `POST /bookings` возвращает бронь в её текущем состоянии и
            мест заново не удерживает.
        order_id:
          type: [integer, "null"]
          description: Заказ, в который бронь сконвертирована; `null` иначе.
        event_id: { type: integer }
        seats:
          type: array
          items: { $ref: "#/components/schemas/BookedSeat" }
        expires_at: { type: string, format: date-time }
        ttl_seconds:
          type: integer
          description: Срок брони партнёра. По умолчанию 180.
          examples: [180]
        total_tenge:
          type: integer
          description: Сумма по забронированным местам без учёта льгот.

    BookedSeat:
      type: object
      required: [space_id, price_tenge]
      properties:
        space_id: { type: string }
        sector_id: { type: [string, "null"] }
        row_code: { type: [string, "null"] }
        seat_code: { type: [string, "null"] }
        category_code: { type: [string, "null"] }
        price_tenge: { type: integer }

    PaymentInfo:
      type: object
      description: Внешний платёж, проведённый партнёром. ДСС его не проводит.
      required: [amount_tenge, paid_at, external_payment_id]
      properties:
        amount_tenge:
          type: integer
          description: |
            Билетная часть платежа: сумма за билеты по прайсу ДСС. Должна
            совпадать с `total_tenge` заказа, иначе `422 INVALID_PARAMETERS` с
            обеими суммами в `details`. Сервисный сбор партнёра с покупателя
            сюда НЕ входит: он остаётся на стороне партнёра, ДСС его не видит,
            не сверяет и не ограничивает.
        currency: { type: string, default: KZT, enum: [KZT] }
        paid_at: { type: string, format: date-time }
        external_payment_id:
          type: string
          description: Идентификатор платежа в системе партнёра или его эквайера.
        method: { type: [string, "null"], examples: ["card"] }

    OrderCreate:
      type: object
      required: [booking_id, partner_order_id, customer, consent]
      properties:
        booking_id: { type: string, format: uuid }
        partner_order_id:
          type: string
          maxLength: 64
          description: |
            Идентификатор заказа у партнёра. Уникален в пределах партнёра и
            служит ключом идемпотентности. Обязан существовать ДО списания и не
            меняться при повторах.
          examples: ["TKT-2026-0001234"]
        customer: { $ref: "#/components/schemas/OrderCustomer" }
        consent:
          type: boolean
          const: true
          description: |
            Подтверждение партнёра, что согласие покупателя на обработку
            персональных данных получено на его стороне до оформления.

            Поле обязательное и обязано быть `true`: в базе ДСС момент согласия
            (`consent_at`) не допускает пустого значения, а само согласие по
            партнёрскому каналу собирает партнёр, не мы. Значение `false`
            отвергается с `422 INVALID_PARAMETERS`.

    OrderConfirm:
      type: object
      required: [payment_info]
      properties:
        payment_info: { $ref: "#/components/schemas/PaymentInfo" }

    Ticket:
      type: object
      required: [ticket_id, number, space_id, status, price_tenge]
      properties:
        ticket_id: { type: integer }
        number: { type: string, examples: ["DSS-TKT-000061"] }
        status:
          type: string
          enum: [active, used, void, expired]
          description: |
            `active` действителен · `used` гость прошёл на объект · `void`
            погашен (возврат, отмена мероприятия, гашение по инициативе ДСС) ·
            `expired` мероприятие завершилось, билет не был использован.
        space_id: { type: string }
        sector_code: { type: [string, "null"] }
        row_code: { type: [string, "null"] }
        seat_code: { type: [string, "null"] }
        category_name:
          oneOf:
            - { $ref: "#/components/schemas/LocalizedTextPartial" }
            - { type: "null" }
        price_tenge: { type: integer }
        qr_token:
          type: [string, "null"]
          description: |
            Подписанный токен для предъявления на турникете. Партнёр кодирует
            ЭТУ строку в QR. Кодировать ссылку на страницу билета нельзя:
            сканер разбирает строку как токен и ответит отказом.
            `null` означает, что билет погашен и QR рисовать не нужно.

            Практика: это JWT, ASCII, примерно 530-560 символов. Кодировать в
            байтовом режиме QR с коррекцией M (выйдет около 20-й версии), не
            усекать и не перекодировать; между чтениями токен стабилен.
        pdf_url:
          type: [string, "null"]
          format: uri
          description: |
            Подписанная ссылка на PDF-билет, действует до окончания
            мероприятия плюс 30 дней; `GET /orders` возвращает те же ссылки.
            Отдавайте её покупателю, а не скачивайте PDF пачками со своего
            сервера: маршрут защищён отдельным лимитом на адрес.

    Order:
      type: object
      required: [order_id, order_number, partner_order_id, event_id, status,
                 total_tenge, created_at, tickets]
      properties:
        order_id: { type: integer, examples: [1042] }
        order_number: { type: string, examples: ["DSS-2026-001042"] }
        partner_order_id: { type: string }
        event_id: { type: integer }
        status:
          type: string
          enum: [pending, paid, cancelled, expired, refunded, partially_refunded]
          description: |
            `pending` до `confirm`, места заперты до `expires_at`. `expired`
            означает, что ни `confirm`, ни `cancel` не пришли вовремя, и
            ВЫЧИСЛЯЕТСЯ по `expires_at` в момент чтения: заказ с прошедшим
            окном читается `expired` сразу, независимо от внутренней уборки.
            `refunded` и `partially_refunded` появятся вместе с ручкой
            возврата во второй фазе, в первой не встречаются.
        total_tenge:
          type: integer
          description: |
            Сумма заказа по нашему прайсу. Ровно её партнёр списывает с
            покупателя: `payment_info.amount_tenge` на `confirm` сверяется с ней.
        created_at: { type: string, format: date-time }
        expires_at:
          type: [string, "null"]
          format: date-time
          description: Конец платёжного окна. `null` после подтверждения.
        tickets:
          type: array
          description: Пуст до подтверждения оплаты, заполняется на `confirm`.
          items: { $ref: "#/components/schemas/Ticket" }

    RefundCreate:
      type: object
      required: [tickets, reason]
      properties:
        tickets:
          type: array
          minItems: 1
          items:
            type: integer
            description: "`ticket_id` из ответа создания заказа."
          examples: [[5001, 5002]]
        reason:
          type: string
          minLength: 1
          maxLength: 500
          description: Причина возврата в свободной форме, попадает в журнал ДСС.

    RefundResult:
      type: object
      required: [order_id, status, refunded, released_seats]
      properties:
        order_id: { type: integer }
        status:
          type: string
          enum: [refunded, partially_refunded]
        refunded:
          type: array
          items:
            type: object
            required: [ticket_id, number, status]
            properties:
              ticket_id: { type: integer }
              number: { type: string }
              status: { type: string, enum: [void] }
        released_seats:
          type: array
          items: { type: string }
          description: Места, вернувшиеся в свободную продажу.

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message:
              type: string
              description: Описание на русском языке, пригодное для журнала.
            details:
              type: object
              description: Машиночитаемые подробности. Может быть пустым объектом.
              additionalProperties: true

  responses:

    Unauthorized:
      description: |
        Ключ неизвестен, подпись не сошлась или `X-Timestamp` вне окна 5 минут.
        Коды: `INVALID_API_KEY`, `INVALID_SIGNATURE`.
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            invalid_signature:
              value:
                error:
                  code: INVALID_SIGNATURE
                  message: Подпись запроса неверна или устарела
                  details: {}

    AccessDenied:
      description: |
        `ACCESS_DENIED`: у партнёра нет гранта на это мероприятие.
        `PARTNER_DISABLED`: доступ партнёра приостановлен ДСС целиком; код
        отличён от проблемы ключа намеренно. Уже созданные заказы при этом
        живут до конца окна: `confirm`/`cancel`/чтения работают.
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    EventNotFound:
      description: "Код: `EVENT_NOT_FOUND`."
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    OrderNotFound:
      description: "Код: `ORDER_NOT_FOUND`. Расширение сверх списка ТЗ."
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    SeatsNotAvailable:
      description: |
        Отказ брони, ни одно место не удержано. Коды этого статуса:
        `SEATS_NOT_AVAILABLE` (место занято, `details.seats`), `SALES_PAUSED` и
        `SALES_NOT_OPEN` (состояние продаж мероприятия), `BOOKING_LIMIT_EXCEEDED`
        (мест больше действующего предела, `details: {limit, requested}`),
        `IDEMPOTENCY_CONFLICT` (ваш ключ с другим телом).
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            taken:
              value:
                error:
                  code: SEATS_NOT_AVAILABLE
                  message: Часть мест уже недоступна
                  details:
                    seats: ["seat-AA-A-03-006"]

    BookingExpired:
      description: |
        Бронь истекла, не найдена, уже подтверждена другим заказом, либо ваш
        `partner_order_id` уже использован с другой бронью. Коды:
        `BOOKING_EXPIRED`, `BOOKING_ALREADY_CONFIRMED` (расширение),
        `BOOKING_NOT_FOUND` (расширение), `IDEMPOTENCY_CONFLICT` (расширение,
        `details: {field, stored, received}`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    OrderExpired:
      description: |
        Заказ нельзя подтвердить. Коды: `ORDER_EXPIRED` (окно истекло, места
        возвращены в продажу; списанное партнёр немедленно возвращает сам),
        `ORDER_CANCELLED` (заказ уже отменён через `cancel`), `EVENT_CANCELLED`
        (мероприятие отменено между заказом и оплатой, партнёр возвращает
        деньги).
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    OrderNotCancellable:
      description: |
        Заказ уже подтверждён, отмена невозможна. Код: `ORDER_NOT_CANCELLABLE`
        (расширение), текущий статус в `details`. Для оплаченного заказа
        существует только возврат.
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    TicketNotRefundable:
      description: |
        Билет уже использован на входе, погашен по другой причине или относится к
        другому заказу. Код: `TICKET_NOT_REFUNDABLE` (расширение), текущий статус
        в `details`.
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    InvalidParameters:
      description: |
        Коды: `INVALID_PARAMETERS` (разбор по полям в `details`, включая
        неизвестный `sector` в `details.sector`) и `SEATS_UNKNOWN` (место не
        существует или чужого мероприятия, список в `details.seats`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    TooManyRequests:
      description: |
        Коды: `RATE_LIMIT_EXCEEDED` (частота или конкурентность) и
        `PENDING_LIMIT_EXCEEDED` (слишком много незакрытых `pending`-заказов,
        повторить после `Retry-After`).
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
        X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
