openapi: 3.0.3
info:
  title: minik Click & Tracking API
  version: "3"
  description: >-
    Публичная (клиентская) сторона API трекера — всё, что вызывает не панель, а
    интегратор: трекинговый редирект, прокладка на лендинге, программный клик,
    удалённое исполнение MClient, приём конверсий (S2S-постбэк и браузерные
    /action и /px.gif) и отдача SDK-файлов.


    Административные операции (кампании, потоки, офферы, отчёты, команда) живут
    в отдельной спеке Admin API — 200 роутов под /admin_api/v1.


    Авторизация здесь разная и указана у каждой операции: /click-api/v3 требует
    API-ключ (ключи выпускаются через Admin API, POST /admin_api/v1/api-keys),
    входящий постбэк — ключ воркспейса в пути URL, /mclient/v1 — токен кампании
    в теле запроса, браузерные конверсии — пер-клик токен. Остальные трекинговые
    эндпоинты открыты: их «авторизация» — сам alias кампании или click_id.
servers:
  - url: https://tr.example.com
    description: Домен трекера (замените на свой)
  - url: http://localhost:8080
    description: Локальный запуск
security: []
tags:
  - name: Click API
    description: Программный клик по API-ключу.
  - name: Трекинг
    description: Публичный трекинговый край — редирект клика и прокладка лендинга.
  - name: MClient
    description: Удалённое исполнение решения кампании на стороне чужого сайта.
  - name: Конверсии
    description: Приём конверсий — серверный постбэк и браузерные эндпоинты.
  - name: SDK
    description: Runtime-файлы для лендингов.
  - name: Служебные
    description: Health-check.
paths:
  /click-api/v3:
    post:
      tags: [Click API]
      operationId: resolveClick
      summary: Резолв клика по alias кампании
      description: >-
        Прогоняет клик через выбор флоу/лендинга/оффера кампании и возвращает
        конечный URL вместо HTTP-редиректа. Ключ может резолвить клики только
        по кампаниям своего workspace.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClickRequest'
            example:
              campaign: abc123
              ip: 203.0.113.10
              user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
              referrer: "https://example.com/"
              language: en-US
              country: US
              sub_ids:
                sub_id_1: creative_42
                sub_id_2: adset_7
              parameters:
                clickid: fb.abc123
      responses:
        '200':
          description: Клик резолвлен, флоу привёл к редиректу (лендинг или оффер).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClickResponse'
              example:
                click_id: b6f1e2b0-8a3a-4c9e-9c9b-8f2e6a1d7c44
                destination: "https://lp.example.com/?_c=b6f1e2b0-8a3a-4c9e-9c9b-8f2e6a1d7c44"
                campaign_id: 3a1e2222-0000-4000-8000-000000000001
                flow_id: 9c2f3333-0000-4000-8000-000000000002
                landing_id: 1d4a4444-0000-4000-8000-000000000003
        '400':
          description: >-
            Невалидный JSON, неизвестное поле в теле (DisallowUnknownFields) или
            пустой campaign. Сюда же попадает chunked-тело, перешагнувшее лимит
            в 64 KiB уже в процессе чтения.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: API-ключ отсутствует ("missing api key") или невалиден ("invalid api key").
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            У ключа нет scope write ("insufficient scope"), либо кампания
            принадлежит другому workspace ("api key not authorized for this
            campaign").
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: >-
            Кампания не найдена/неактивна, либо ни один флоу не подошёл, либо
            подошедший флоу — терминальное действие без редиректа (do_nothing/
            404/show_text/show_html), которое v3-контракт не умеет выразить.
            Полный набор действий доступен через POST /mclient/v1.
            Сюда же попадает любая внутренняя ошибка резолва (обработчик
            маппит все ошибки на 404 с их текстом), поэтому 500 эндпоинт
            не отдаёт.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          description: >-
            Content-Length больше 64 KiB — тело отклоняется, не читая ни байта
            ({"error": "request body too large"}).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
  /r/{alias}:
    get:
      tags: [Трекинг]
      operationId: serveClick
      summary: Трекинговый редирект клика
      description: >-
        Главный трекинговый вход: ссылка, которую вы отдаёте источнику трафика.
        Определяет гео/устройство/бота, выбирает поток, лендинг и оффер, пишет
        клик и отвечает редиректом (или страницей — в зависимости от типа
        редиректа потока).


        Локальный лендинг отдаётся «на месте»: ответ 200 с HTML лендинга, адрес
        в браузере остаётся ссылкой кампании, а click_id уезжает в cookie
        `_mclick` (и пер-клик токен конверсии — в cookie `_mtoken`). При
        редиректе на внешний лендинг click_id добавляется в URL параметром `_c`,
        а токен конверсии — во фрагмент `#_ct` (фрагмент не уходит ни в Referer,
        ни на домен оффера).


        `/click/{alias}` — тот же самый обработчик под вторым путём, для
        совместимости со ссылками, привыкшими к `/click/`.
      security: []
      parameters:
        - $ref: '#/components/parameters/CampaignAlias'
        - $ref: '#/components/parameters/SubID1'
        - $ref: '#/components/parameters/Fbclid'
        - $ref: '#/components/parameters/Ttclid'
        - $ref: '#/components/parameters/SendCampaignHop'
        - $ref: '#/components/parameters/EdgeCountry'
      responses:
        '200':
          $ref: '#/components/responses/ClickPageResponse'
        '301':
          $ref: '#/components/responses/ClickRedirect'
        '302':
          $ref: '#/components/responses/ClickRedirect'
        '303':
          $ref: '#/components/responses/ClickRedirect'
        '307':
          $ref: '#/components/responses/ClickRedirect'
        '204':
          $ref: '#/components/responses/ClickNoContent'
        '404':
          description: >-
            Alias не найден или кампания не в статусе active. Тот же код отдаёт
            поток со схемой `404`.
        '502':
          description: >-
            Небезопасный URL назначения — после подстановки макросов схема
            получилась не http/https (например javascript:). Тело — text/plain
            "unsafe redirect target".
    head:
      tags: [Трекинг]
      operationId: serveClickHead
      summary: HEAD-проба трекинговой ссылки
      description: >-
        HEAD не запускает пайплайн клика: не тратит уникальность, не крутит
        ротацию, не двигает капы и не пишет клик. Нужен, чтобы превью рекламных
        сетей и аптайм-мониторинги не накручивали статистику.
      security: []
      parameters:
        - $ref: '#/components/parameters/CampaignAlias'
      responses:
        '200':
          description: Кампания существует и активна. Тело пустое, Location не отдаётся.
        '404':
          description: Alias не найден или кампания не активна.
  /click/{alias}:
    get:
      tags: [Трекинг]
      operationId: serveClickAlt
      summary: Трекинговый редирект клика (второй путь)
      description: Полный алиас `GET /r/{alias}` — тот же обработчик, те же параметры и коды ответа.
      security: []
      parameters:
        - $ref: '#/components/parameters/CampaignAlias'
        - $ref: '#/components/parameters/SubID1'
        - $ref: '#/components/parameters/Fbclid'
        - $ref: '#/components/parameters/Ttclid'
        - $ref: '#/components/parameters/SendCampaignHop'
        - $ref: '#/components/parameters/EdgeCountry'
      responses:
        '200':
          $ref: '#/components/responses/ClickPageResponse'
        '301':
          $ref: '#/components/responses/ClickRedirect'
        '302':
          $ref: '#/components/responses/ClickRedirect'
        '303':
          $ref: '#/components/responses/ClickRedirect'
        '307':
          $ref: '#/components/responses/ClickRedirect'
        '204':
          $ref: '#/components/responses/ClickNoContent'
        '404':
          description: Alias не найден или кампания не активна; либо поток со схемой `404`.
        '502':
          description: Небезопасный URL назначения (схема не http/https).
    head:
      tags: [Трекинг]
      operationId: serveClickAltHead
      summary: HEAD-проба трекинговой ссылки (второй путь)
      security: []
      parameters:
        - $ref: '#/components/parameters/CampaignAlias'
      responses:
        '200':
          description: Кампания существует и активна, побочных эффектов нет.
        '404':
          description: Alias не найден или кампания не активна.
  /p/{clickID}:
    get:
      tags: [Трекинг]
      operationId: serveLPClick
      summary: Прокладка с лендинга на оффер
      description: >-
        Кнопка «купить»/«продолжить» на лендинге ведёт сюда. Эндпоинт достаёт
        оффер, УЖЕ выбранный на исходном клике (Redis, при промахе —
        `last_click_meta` в PostgreSQL), и редиректит на URL оффера. Оффер
        здесь НЕ переразыгрывается — посетитель видит тот же маршрут, что был
        решён в момент клика.


        Отдельной статистики LP-кликов нет: ни строки в ClickHouse, ни
        счётчика в Redis/PostgreSQL не пишется — инкрементируется только общий
        Prometheus-счётчик `minik_clicks_total` (HEAD его не трогает).


        В макросах лендинга это `{offer_url}`; ссылку на прокладку удобно
        собирать через макрос или получать через `GET /lp-api/offer`.
      security: []
      parameters:
        - name: clickID
          in: path
          required: true
          description: click_id исходного клика.
          schema:
            type: string
            format: uuid
      responses:
        '302':
          description: Редирект на URL оффера, сохранённый на клике.
          headers:
            Location:
              description: URL оффера с уже подставленными макросами.
              schema:
                type: string
                format: uri
        '400':
          description: click_id не передан ни в пути, ни в query. Тело — text/plain "missing click_id".
        '404':
          description: >-
            Неизвестный или протухший click_id (TTL истёк, click-meta вычищена,
            лендинг подменён). Тело — text/plain "unknown click_id". Редиректа
            «по умолчанию» нет намеренно.
    head:
      tags: [Трекинг]
      operationId: serveLPClickHead
      summary: HEAD-проба прокладки
      description: >-
        Проверяет живой ли click_id, не трогая счётчик `minik_clicks_total`:
        200 — оффер найден, 404 — нет.
      security: []
      parameters:
        - name: clickID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: click_id живой, оффер найден. LP-клик не засчитан.
        '400':
          description: click_id не передан.
        '404':
          description: Неизвестный или протухший click_id.
  /p:
    get:
      tags: [Трекинг]
      operationId: serveLPClickQuery
      summary: Прокладка на оффер с click_id в query
      description: >-
        Тот же обработчик, что `/p/{click_id}`, для шаблонов лендингов, которым
        удобнее фиксированный путь с параметром. click_id берётся из `_c`, при
        его отсутствии — из `subid`.
      security: []
      parameters:
        - name: _c
          in: query
          description: click_id исходного клика (первый по приоритету).
          schema:
            type: string
            format: uuid
        - name: subid
          in: query
          description: Запасное имя параметра для click_id.
          schema:
            type: string
            format: uuid
      responses:
        '302':
          description: Редирект на URL оффера.
          headers:
            Location:
              schema:
                type: string
                format: uri
        '400':
          description: Ни `_c`, ни `subid` не переданы — "missing click_id".
        '404':
          description: Неизвестный или протухший click_id — "unknown click_id".
    head:
      tags: [Трекинг]
      operationId: serveLPClickQueryHead
      summary: HEAD-проба прокладки с click_id в query
      security: []
      parameters:
        - name: _c
          in: query
          schema:
            type: string
            format: uuid
        - name: subid
          in: query
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: click_id живой. LP-клик не засчитан.
        '400':
          description: click_id не передан.
        '404':
          description: Неизвестный или протухший click_id.
  /{postback_key}/postback:
    get:
      tags: [Конверсии]
      operationId: incomingPostback
      summary: Входящий S2S-постбэк (конверсия)
      description: >-
        URL, который вы вставляете в настройки партнёрской сети. Ключ
        воркспейса едет в пути — click_id публичен по своей природе (он в
        ссылках лендинга, в URL офферов и в отчётах сети), поэтому сам по себе
        он ничего не аутентифицирует.


        Готовый URL и пример с макросами отдаёт панель:
        `GET /admin_api/v1/postback-url` (право settings:view).


        Ключ пинит воркспейс: конверсия с чужим click_id будет отклонена ровно
        так же, как неверный ключ (401 "Incorrect postback key") — эндпоинт не
        подсказывает, существует ли click_id в другом воркспейсе.


        Поверх ключа можно включить HMAC-подпись: если у источника трафика этого
        клика задан postback_secret, запрос без валидного заголовка `X-Signature`
        отклоняется. Секрет резолвится server-side по цепочке
        клик → кампания → источник, поэтому отправитель не может отказаться от
        проверки, не передав source_id.


        Обработчик зарегистрирован на все HTTP-методы; параметры читаются из
        query и из тела формы (значение из тела перекрывает query). Тело читается
        не более 8 KiB.
      security:
        - PostbackKey: []
      parameters:
        - $ref: '#/components/parameters/PostbackKeyPath'
        - $ref: '#/components/parameters/PbSubid'
        - $ref: '#/components/parameters/PbClickid'
        - $ref: '#/components/parameters/PbStatus'
        - $ref: '#/components/parameters/PbPayout'
        - $ref: '#/components/parameters/PbCurrency'
        - $ref: '#/components/parameters/PbTxid'
        - $ref: '#/components/parameters/PbEmail'
        - $ref: '#/components/parameters/PbPhone'
        - $ref: '#/components/parameters/PbSignature'
      responses:
        '200':
          $ref: '#/components/responses/PostbackOK'
        '400':
          $ref: '#/components/responses/PostbackBadRequest'
        '401':
          $ref: '#/components/responses/PostbackUnauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/PostbackUnavailable'
    post:
      tags: [Конверсии]
      operationId: incomingPostbackPost
      summary: Входящий S2S-постбэк формой
      description: >-
        Тот же обработчик для сетей, которые шлют постбэк как
        `application/x-www-form-urlencoded`. Параметры из тела перекрывают
        одноимённые из query. Подпись `X-Signature` в этом случае считается по
        телу (а если есть и query — по `query + "\n" + body`).
      security:
        - PostbackKey: []
      parameters:
        - $ref: '#/components/parameters/PostbackKeyPath'
        - $ref: '#/components/parameters/PbSignature'
      requestBody:
        required: false
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/PostbackForm'
      responses:
        '200':
          $ref: '#/components/responses/PostbackOK'
        '400':
          $ref: '#/components/responses/PostbackBadRequest'
        '401':
          $ref: '#/components/responses/PostbackUnauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/PostbackUnavailable'
  /postback:
    get:
      tags: [Конверсии]
      operationId: incomingPostbackLegacy
      summary: Постбэк без ключа (устаревший путь)
      deprecated: true
      description: >-
        Старый бесключевой URL. Роут оставлен намеренно, но конверсию НЕ
        принимает: пустой ключ невалиден, поэтому ответ всегда
        401 "Incorrect postback key", и попытка попадает в лог постбэков —
        интеграция, не переехавшая на `/{postback_key}/postback`, видит понятную
        причину, а не молчаливый 404.


        Переезд: возьмите актуальный URL в панели
        (`GET /admin_api/v1/postback-url`) и замените им URL в партнёрской сети.
      security: []
      parameters:
        - $ref: '#/components/parameters/PbSubid'
        - $ref: '#/components/parameters/PbStatus'
      responses:
        '401':
          description: Всегда. Тело — text/plain "Incorrect postback key".
        '429':
          $ref: '#/components/responses/RateLimited'
  /mclient/v1:
    post:
      tags: [MClient]
      operationId: mclientExecute
      summary: Удалённое исполнение решения кампании
      description: >-
        Богатый аналог Click API для чужого сайта: сайт присылает контекст
        посетителя, сервер возвращает решение кампании, а сайт сам его исполняет
        (редирект, показ лендинга, безопасная страница, готовый HTML/текст).
        В отличие от `/click-api/v3` умеет выразить и нередиректные действия.


        Область видимости — кампания: alias в теле плюс токен кампании
        (`mclient_token`). Токен нужен потому, что alias публичен и без него
        можно было бы подделать ip/country/user_agent, обойти клоаку и залить
        чужой кампании клики. Ограничение честное: JS-клиент вшивает токен в
        код лендинга, так что для канала `mclient_js` он не секрет — он закрывает
        подделку «знаю только alias», но не защищает от того, кто уже открыл
        лендинг.


        Готовые клиенты: `/sdk/mclient.js` и `/sdk/mclient.php`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MClientRequest'
            example:
              campaign: abc123
              token: "mct_9f2c…"
              channel: mclient_php
              ip: 203.0.113.10
              user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
              referrer: "https://news.example.com/article"
              language: en-US
              sub_ids:
                sub_id_1: creative_42
      responses:
        '200':
          description: >-
            Решение кампании. Этим же кодом отвечают отказные случаи — неизвестный
            alias, неактивная кампания, отсутствующий или неверный токен: тело
            `{"action":"do_nothing","reason":"campaign_unavailable"}`, чтобы
            эндпоинт не подсказывал, какие alias существуют и почему отказ.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MClientDecision'
              example:
                action: show_landing
                click_id: b6f1e2b0-8a3a-4c9e-9c9b-8f2e6a1d7c44
                campaign_id: 3a1e2222-0000-4000-8000-000000000001
                flow_id: 9c2f3333-0000-4000-8000-000000000002
                flow_name: "Tier 1 mobile"
                reason: matched
                destination: "https://lp.example.com/?_c=b6f1e2b0-8a3a-4c9e-9c9b-8f2e6a1d7c44"
                offer_url: "https://offer.example.com/?clickid=b6f1e2b0-8a3a-4c9e-9c9b-8f2e6a1d7c44"
                landing_id: 1d4a4444-0000-4000-8000-000000000003
                offer_id: 5e6b5555-0000-4000-8000-000000000004
                redirect_type: "302"
                is_bot: false
                is_unique: true
        '400':
          description: >-
            Невалидный JSON или тело больше 64 KiB ({"error": "invalid json"}),
            либо пустой campaign ({"error": "campaign required"}).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
    options:
      tags: [MClient]
      operationId: mclientPreflight
      summary: CORS-preflight
      description: >-
        CORS открыт — лендинг на любом домене может вызывать эндпоинт.
        Настоящий браузерный preflight (OPTIONS с заголовком
        `Access-Control-Request-Method`) перехватывается CORS-middleware и
        завершается кодом 200 с CORS-заголовками; 204 отдаёт обработчик роута,
        и только на «голый» OPTIONS без этого заголовка.
      security: []
      responses:
        '200':
          description: Ответ браузерного preflight — CORS-заголовки, тела нет.
        '204':
          description: Пустой ответ на OPTIONS без `Access-Control-Request-Method`.
  /lp-api/offer:
    get:
      tags: [Конверсии]
      operationId: lpOffer
      summary: URL оффера для лендинга
      description: >-
        То, что под капотом делает `getOffer()` из `/sdk/minik.js`: возвращает
        URL оффера, выбранный на исходном клике, чтобы кнопка лендинга вела
        сразу на нужный оффер. Альтернатива редиректу через `/p/{click_id}`.
      security: []
      parameters:
        - name: cid
          in: query
          description: click_id. Синонимы (по убыванию приоритета) — `click_id`, `subid`.
          schema:
            type: string
            format: uuid
        - name: click_id
          in: query
          schema:
            type: string
            format: uuid
        - name: subid
          in: query
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: >-
            Найденный URL оффера. Для неизвестного или протухшего click_id код
            остаётся 200, а `offer_url` — пустая строка.
          content:
            application/json:
              schema:
                type: object
                properties:
                  click_id:
                    type: string
                  offer_url:
                    type: string
                    description: URL оффера или пустая строка, если клик неизвестен.
              example:
                click_id: b6f1e2b0-8a3a-4c9e-9c9b-8f2e6a1d7c44
                offer_url: "https://offer.example.com/?clickid=b6f1e2b0-8a3a-4c9e-9c9b-8f2e6a1d7c44"
        '400':
          description: 'click_id не передан ({"error": "cid required"}).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
  /action:
    post:
      tags: [Конверсии]
      operationId: browserAction
      summary: Браузерная конверсия (JSON)
      description: >-
        JSON-приём конверсий для JS SDK (`trackAction` / `trackConversion` из
        `/sdk/minik.js`). Лендинг на любом домене может отчитаться о лиде или
        продаже прямо из браузера; CORS открыт.


        **Пер-клик токен.** click_id публичен, поэтому запрос аутентифицируется
        полем `t` — токеном, который трекер выпускает на КОНКРЕТНЫЙ клик
        (HMAC от click_id на серверном секрете `TRACKER_SECRET_KEY`) и отдаёт
        только браузеру этого клика: во фрагменте `#_ct` редиректа на внешний
        лендинг либо в same-origin cookie `_mtoken` для лендинга, отданного
        трекером на месте. SDK сам подхватывает токен из фрагмента, query или
        cookie — вручную его передавать не нужно.


        Поведение при неверном/отсутствующем токене зависит от
        `MINIK_CONV_TOKEN_ENFORCE`: выключен (по умолчанию) — конверсия
        записывается, но НЕ рассылается наружу (CAPI/TikTok, вебхуки, исходящий
        S2S); включён — запрос отклоняется с 403.


        **Payout берётся из оффера, а не из запроса.** Эндпоинт публичный и
        неаутентифицированный на уровне отправителя, поэтому сумма и валюта
        конверсии всегда подставляются из оффера, привязанного к клику; поля
        `payout` и `currency` в теле принимаются парсером, но на записанную
        выручку не влияют. Клик без оффера даёт 0/USD. Доверять сумме
        отправителя может только аутентифицированный S2S-постбэк.


        Антидубль: ключ — `txid`, а при его отсутствии `action:{status}`, так что
        перезагрузка thank-you страницы не удваивает конверсию.


        Тело ограничено 16 KiB.
      security:
        - ConversionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ActionRequest'
            example:
              click_id: b6f1e2b0-8a3a-4c9e-9c9b-8f2e6a1d7c44
              action: sale
              txid: "order-10025"
              t: "9c1f0a2b3c4d5e6f7a8b9c0d1e2f3a4b"
      responses:
        '200':
          description: >-
            Конверсия принята. `recorded: false` — дубль, подавленный
            антидублем (это не ошибка).
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  recorded:
                    type: boolean
        '400':
          description: >-
            Невалидный JSON ({"error": "invalid json"}), пустой click_id
            ({"error": "click_id required"}) или неизвестный action
            ({"error": "invalid action (lead|sale|signup|custom)"}).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            Токен отсутствует или невалиден при включённом
            `MINIK_CONV_TOKEN_ENFORCE`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                status: error
                error: "invalid or missing conversion token"
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: >-
            Конверсия не записана в надёжное хранилище — заявка антидубля
            откачена, SDK должен повторить.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                status: error
                error: "not stored, retry"
    options:
      tags: [Конверсии]
      operationId: browserActionPreflight
      summary: CORS-preflight
      description: >-
        Настоящий браузерный preflight (OPTIONS с заголовком
        `Access-Control-Request-Method`) перехватывается CORS-middleware и
        завершается кодом 200 с CORS-заголовками; 204 отдаёт обработчик роута,
        и только на «голый» OPTIONS без этого заголовка.
      security: []
      responses:
        '200':
          description: Ответ браузерного preflight — CORS-заголовки, тела нет.
        '204':
          description: Пустой ответ на OPTIONS без `Access-Control-Request-Method`.
  /px.gif:
    get:
      tags: [Конверсии]
      operationId: conversionPixel
      summary: Браузерная конверсия без JS (пиксель)
      description: >-
        Запасной вариант `/action` для лендингов без JS: `<img>` на
        thank-you странице. Всегда отдаёт прозрачный GIF 1×1 — даже при неверных
        параметрах или отклонённом токене, чтобы страница не показывала битую
        картинку. Кода ошибки у пикселя нет: результат смотрят в логе постбэков.


        Правила те же, что у `/action`: сумма конверсии берётся из оффера
        (`payout`/`currency` в query на выручку не влияют), запрос
        аутентифицируется пер-клик токеном `t`. Написанный руками пиксель токена
        не несёт: при выключенном `MINIK_CONV_TOKEN_ENFORCE` такая конверсия
        записывается без рассылки наружу, при включённом — отбрасывается.


        Антидубль: `txid`, а при его отсутствии `pixel:{status}`.
      security:
        - ConversionToken: []
      parameters:
        - name: cid
          in: query
          description: click_id. Синонимы — `click_id`, `subid`.
          schema:
            type: string
            format: uuid
        - name: action
          in: query
          description: lead | sale | signup | custom. По умолчанию lead.
          schema:
            type: string
            enum: [lead, sale, signup, custom]
            default: lead
        - name: status
          in: query
          description: Произвольный статус, когда `action=custom`.
          schema:
            type: string
        - name: payout
          in: query
          description: Принимается, но игнорируется — сумма берётся из оффера.
          schema:
            type: string
        - name: currency
          in: query
          description: Принимается, но игнорируется — валюта берётся из оффера.
          schema:
            type: string
        - name: txid
          in: query
          description: Идентификатор транзакции, ключ антидубля.
          schema:
            type: string
        - name: t
          in: query
          description: Пер-клик токен конверсии (то же, что поле `t` в /action).
          schema:
            type: string
      responses:
        '200':
          description: >-
            Прозрачный GIF 1×1 с `Cache-Control: no-store`. Отдаётся всегда,
            независимо от того, была ли записана конверсия.
          content:
            image/gif: {}
        '429':
          $ref: '#/components/responses/RateLimited'
  /sdk/minik.js:
    get:
      tags: [SDK]
      operationId: sdkMinikJS
      summary: JS SDK лендинга
      description: >-
        Браузерный SDK: читает click_id (`?_c` → cookie `_mclick`) и пер-клик
        токен (`#_ct` → `?_ct` → cookie `_mtoken`), умеет `trackAction`,
        `trackConversion`, `getOffer` и декларативную разметку через
        `data-minik-*`. Версионируется вместе с бинарём.
      security: []
      responses:
        '200':
          description: >-
            Файл SDK. `Content-Type: application/javascript`,
            `Cache-Control: public, max-age=3600` (сбрасывается через `?v=`).
          content:
            application/javascript: {}
  /sdk/minik.php:
    get:
      tags: [SDK]
      operationId: sdkMinikPHP
      summary: PHP-хелпер лендинга
      description: >-
        Серверный хелпер для PHP-лендингов на вашем хосте. Отдаётся как
        `text/plain`, чтобы трекер его никогда не выполнял — файл предназначен
        для копирования к себе.
      security: []
      responses:
        '200':
          description: >-
            Исходник хелпера. `Content-Type: text/plain`,
            `Content-Disposition: inline; filename="minik.php"`,
            `Cache-Control: public, max-age=3600`.
          content:
            text/plain: {}
  /sdk/mclient.js:
    get:
      tags: [SDK]
      operationId: sdkMClientJS
      summary: JS-клиент MClient
      description: Браузерный клиент удалённого исполнения — вызывает `POST /mclient/v1` и исполняет решение.
      security: []
      responses:
        '200':
          description: '`Content-Type: application/javascript`, `Cache-Control: public, max-age=3600`.'
          content:
            application/javascript: {}
  /sdk/mclient.php:
    get:
      tags: [SDK]
      operationId: sdkMClientPHP
      summary: PHP-класс MClient
      description: >-
        PHP-класс удалённого исполнения для вашего сайта. Отдаётся как
        `text/plain` (трекер его не выполняет).
      security: []
      responses:
        '200':
          description: >-
            `Content-Type: text/plain`,
            `Content-Disposition: inline; filename="mclient.php"`,
            `Cache-Control: public, max-age=3600`.
          content:
            text/plain: {}
  /healthz:
    get:
      tags: [Служебные]
      operationId: healthz
      summary: Health-check
      description: >-
        Liveness-проба процесса: отвечает, как только HTTP-сервер поднялся.
        Состояние PostgreSQL/ClickHouse/Redis не проверяет.
      security: []
      responses:
        '200':
          description: Тело — `ok`.
          content:
            text/plain:
              schema:
                type: string
                example: ok
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: "ak_..."
      description: >-
        API-ключ вида "ak_...", выпущенный через
        POST /admin_api/v1/api-keys со scope "write".
    PostbackKey:
      type: apiKey
      in: query
      name: postback_key
      description: >-
        Ключ воркспейса. Реально едет НЕ в query, а первым сегментом пути —
        `https://tr.example.com/{postback_key}/postback` (OpenAPI 3.0 не умеет
        описать секрет в path, поэтому схема объявлена как apiKey; настоящее
        место ключа — параметр пути `postback_key`). Ключ выдаёт
        `GET /admin_api/v1/postback-url`, он же пинит воркспейс, в который
        разрешено писать конверсии.
    ConversionToken:
      type: apiKey
      in: query
      name: t
      description: >-
        Пер-клик токен конверсии: HMAC(TRACKER_SECRET_KEY, click_id), выпускается
        в момент клика и доставляется только браузеру этого клика — во фрагменте
        `#_ct` или в cookie `_mtoken`. В `/px.gif` передаётся параметром `t`, в
        `/action` — одноимённым полем JSON-тела.
  parameters:
    CampaignAlias:
      name: alias
      in: path
      required: true
      description: >-
        Alias кампании — то же значение, что в её tracking_url. Все остальные
        query-параметры ссылки свободные: значение попадает в sub_id по
        настройке `parameters` кампании (маппинг «имя параметра источника → имя
        sub_id»), а параметр с именем `sub_id_1`…`sub_id_30` кладётся в
        соответствующую колонку напрямую. Управляющие символы вырезаются.
      schema:
        type: string
      example: abc123
    SubID1:
      name: sub_id_1
      in: query
      required: false
      description: >-
        Пример прямого sub_id. Поддерживаются `sub_id_1`…`sub_id_30` — они
        кладутся в одноимённые колонки и в первоклассные колонки отчётов НЕ
        нормализуются. Нормализация идёт по каноническому ИМЕНИ параметра,
        взятому из настройки `parameters` кампании или совпадающему с именем
        самого query-параметра: ad_campaign_id/campaign_id → ad_campaign_id;
        adset_id/ad_set_id/adgroup_id/ad_group_id/group_id → adset_id;
        creative_id/ad_id/banner_id/creative → creative_id;
        source_site/site/site_id/zone_id/zone/placement/publisher →
        source_site.
      schema:
        type: string
    Fbclid:
      name: fbclid
      in: query
      required: false
      description: >-
        Клик-идентификатор Facebook. Сохраняется как `fbc` в формате
        `fb.1.{unix_ms}.{fbclid}` и уходит в Conversions API вместе с cookie
        `_fbp`, если она есть у посетителя.
      schema:
        type: string
    Ttclid:
      name: ttclid
      in: query
      required: false
      description: >-
        Клик-идентификатор TikTok. Сохраняется как есть и уходит в Events API
        вместе с cookie `_ttp`.
      schema:
        type: string
    SendCampaignHop:
      name: _sc
      in: query
      required: false
      description: >-
        Служебный счётчик переходов схемы `send_to_campaign`. Трекер ставит его
        сам при внутреннем 302 на `/r/{target}`; максимум 3 перехода. Руками
        передавать не нужно.
      schema:
        type: integer
    EdgeCountry:
      name: X-Country
      in: header
      required: false
      description: >-
        Страна от CDN. Учитывается ТОЛЬКО при `TRUST_EDGE_HEADERS=1` — иначе
        заголовок вырезается на входе, чтобы посетитель не выбирал сам, в какую
        гео-ветку кампании попасть. Включайте, лишь когда перед трекером
        действительно стоит CDN, перезаписывающий заголовок.
      schema:
        type: string
      example: US
    PostbackKeyPath:
      name: postback_key
      in: path
      required: true
      description: Ключ воркспейса из `GET /admin_api/v1/postback-url`.
      schema:
        type: string
    PbSubid:
      name: subid
      in: query
      required: true
      description: >-
        click_id, по которому атрибуцируется конверсия. Синоним — `clickid`
        (используется, если `subid` пуст). Обязателен: без него строку конверсии
        не с чем сопоставить.
      schema:
        type: string
    PbClickid:
      name: clickid
      in: query
      required: false
      description: Синоним `subid`.
      schema:
        type: string
    PbStatus:
      name: status
      in: query
      required: true
      description: >-
        Статус конверсии — lead, sale, rejected, trash или произвольный
        собственный. Обязателен.
      schema:
        type: string
      example: sale
    PbPayout:
      name: payout
      in: query
      required: false
      description: >-
        Выплата. Пустое значение легитимно (лид без выплаты). Принимается и
        европейская десятичная запятая («10,50»), но только однозначной формы —
        ровно одна запятая, цифры по обе стороны, без точки. Нечисловое значение
        («$10.50», «10.50 USD») — 400, конверсия не пишется, заявка антидубля не
        берётся, поэтому исправленный повтор с тем же txid запишет верную сумму.
      schema:
        type: string
      example: "10.50"
    PbCurrency:
      name: currency
      in: query
      required: false
      description: Валюта ISO-4217. По умолчанию USD.
      schema:
        type: string
        default: USD
      example: USD
    PbTxid:
      name: txid
      in: query
      required: false
      description: >-
        Идентификатор транзакции сети. Ключ антидубля: точный повтор
        (txid + status + payout + currency) подавляется, а изменение статуса или
        суммы по тому же txid (lead → sale, возврат, коррекция) проходит и
        ЗАМЕНЯЕТ прежнюю запись. Пустой txid отключает антидубль для этого
        запроса.
      schema:
        type: string
    PbEmail:
      name: em
      in: query
      required: false
      description: >-
        E-mail покупателя для матчинга в CAPI. Синоним — `email`.
      schema:
        type: string
    PbPhone:
      name: ph
      in: query
      required: false
      description: Телефон покупателя для матчинга в CAPI. Синоним — `phone`.
      schema:
        type: string
    PbSignature:
      name: X-Signature
      in: header
      required: false
      description: >-
        HMAC-SHA256 в виде `sha256=<hex>` (голый hex тоже принимается).
        Обязателен, если у источника трафика этого клика задан postback_secret.
        Подписывается query-строка для GET, тело — для POST без query, и
        `query + "\n" + body`, когда есть и то и другое.
      schema:
        type: string
  responses:
    RateLimited:
      description: >-
        Превышен per-IP rate limit. Конверсии (`/postback`,
        `/{postback_key}/postback`, `/action`, `/px.gif`, `/lp-api/offer`) —
        100 rps, burst 500; `/click-api/v3` и `/mclient/v1` делят общий лимитер
        200 rps, burst 1000. У `/r/{alias}`, `/p` и `/sdk/*` собственного
        лимитера нет. Тело — text/plain "rate limit exceeded".
      headers:
        Retry-After:
          description: Секунды до повторной попытки.
          schema:
            type: string
            example: "1"
      content:
        text/plain:
          schema:
            type: string
    ClickRedirect:
      description: >-
        Редирект на лендинг или оффер. Конкретный код задаётся типом редиректа
        потока: 302 (по умолчанию и для `no_referrer`), 301, 303, 307. Тем же
        302 отвечает схема `send_to_campaign` — переход на `/r/{target}` с
        инкрементом `_sc`.
      headers:
        Location:
          description: URL назначения с подставленными макросами.
          schema:
            type: string
            format: uri
        Set-Cookie:
          description: >-
            Ставится при уникальности по cookie (`_mv`); для лендинга, отданного
            на месте, — ещё `_mclick` (click_id) и `_mtoken` (токен конверсии).
          schema:
            type: string
        Referrer-Policy:
          description: '`no-referrer` для типов редиректа `no_referrer` и `double_meta`.'
          schema:
            type: string
    ClickPageResponse:
      description: >-
        Страница вместо 30x. Так отвечают HTML-типы редиректа (`meta`,
        `double_meta`, `js`, `iframe`, `direct`), локальный лендинг, отданный на
        месте, и терминальные схемы потока `show_text` (text/plain) и
        `show_html` (text/html). HTML из `show_html` и лендинги отдаются под
        CSP-песочницей — их JS изолирован от админ-origin.
      headers:
        Set-Cookie:
          description: '`_mclick` и `_mtoken` — когда лендинг отдан на месте.'
          schema:
            type: string
        Content-Security-Policy:
          description: Sandbox-политика для лендингов и `show_html`.
          schema:
            type: string
      content:
        text/html: {}
        text/plain: {}
    ClickNoContent:
      description: >-
        Клик обработан, показывать нечего: ни один поток не подошёл
        (`no_match`), сработал поток-стоппер или схема `do_nothing`,
        `send_to_campaign` без валидной цели либо пустой URL назначения. Клик при
        этом попадает в лог с соответствующим значением `outcome`, так что
        отказные клики видны в клик-логе фильтром.
    PostbackOK:
      description: Конверсия принята. Тело — text/plain "ok".
      content:
        text/plain:
          schema:
            type: string
            example: ok
    PostbackBadRequest:
      description: >-
        Не хватает обязательного параметра ("missing required parameter:
        subid/clickid", "missing required parameter: status") или payout не
        парсится ("unparseable payout …"). Конверсия не записывается, попытка
        попадает в лог постбэков.
      content:
        text/plain:
          schema:
            type: string
    PostbackUnauthorized:
      description: >-
        "Incorrect postback key" — ключ отсутствует/не существует ИЛИ click_id
        принадлежит другому воркспейсу (ответ намеренно одинаковый).
        "invalid signature" — у источника задан postback_secret, а подпись
        отсутствует или неверна.
      content:
        text/plain:
          schema:
            type: string
    PostbackUnavailable:
      description: >-
        Временный отказ, повтор уместен: "postback key lookup failed, retry"
        (недоступна БД во время проверки ключа) или "conversion not stored,
        retry" (конверсия не легла в хранилище; заявка антидубля откачена).
      content:
        text/plain:
          schema:
            type: string
  schemas:
    ClickRequest:
      type: object
      required: [campaign]
      additionalProperties: false
      properties:
        campaign:
          type: string
          description: Alias кампании (как в tracking_url кампании, /r/{alias}).
          example: abc123
        ip:
          type: string
          description: >-
            IP посетителя. Если не передан — берётся IP входящего HTTP-запроса
            (X-Real-IP / X-Forwarded-For / RemoteAddr).
          example: 203.0.113.10
        user_agent:
          type: string
          description: User-Agent посетителя (передавайте явно, иначе используется UA самого вызова к click-api).
        referrer:
          type: string
        language:
          type: string
          example: en-US
        country:
          type: string
          description: Override страны — если задан, отменяет геолокацию по IP.
          example: US
        sub_ids:
          type: object
          additionalProperties:
            type: string
          description: >-
            Произвольные sub-параметры. Ключи `sub_id_1`…`sub_id_30` попадают в
            одноимённые колонки отчётов; любые ключи доступны в макросах
            (`{sub_id_1}` и т.д.).
        parameters:
          type: object
          additionalProperties:
            type: string
          description: Значения кастомных параметров кампании (маппятся в sub_ids по конфигурации campaign.parameters).
    ClickResponse:
      type: object
      properties:
        click_id:
          type: string
          format: uuid
        destination:
          type: string
          format: uri
          description: >-
            Финальный URL для редиректа посетителя (лендинг или оффер). Если
            маршрут идёт через лендинг, к URL уже добавлен `?_c={click_id}` —
            лендинг соберёт по нему ссылку на `/p/{click_id}`.
        campaign_id:
          type: string
          format: uuid
        flow_id:
          type: string
          format: uuid
        landing_id:
          type: string
          format: uuid
          description: >-
            Поле опускается из ответа, если лендинг не участвует в маршруте
            (null не возвращается).
        offer_id:
          type: string
          format: uuid
          description: >-
            Поле опускается из ответа, если оффер не участвует в маршруте
            (null не возвращается).
    MClientRequest:
      type: object
      required: [campaign, token]
      properties:
        campaign:
          type: string
          description: Alias кампании.
          example: abc123
        token:
          type: string
          description: >-
            Токен кампании (`mclient_token`). Пустой токен отвергается всегда.
        ip:
          type: string
          description: >-
            IP посетителя. PHP-клиент подставляет реальный IP запроса;
            JS-клиент его не знает, и тогда берётся IP вызывающего соединения.
        user_agent:
          type: string
          description: Пусто → берётся User-Agent самого вызова.
        referrer:
          type: string
          description: Пусто → берётся заголовок Referer самого вызова.
        language:
          type: string
        country:
          type: string
          description: Override страны.
        sub_ids:
          type: object
          additionalProperties:
            type: string
        parameters:
          type: object
          additionalProperties:
            type: string
        channel:
          type: string
          description: >-
            Канал прихода клика, попадает в клик-лог и трейс. Любое иное
            значение приводится к `mclient_js`.
          enum: [mclient_php, mclient_js]
    MClientDecision:
      type: object
      description: >-
        Решение кампании. `send_to_campaign` разрешается на сервере, поэтому
        клиент видит уже решение целевой кампании.
      properties:
        action:
          type: string
          description: >-
            do_nothing — показать безопасную страницу; redirect — вести на
            destination; show_landing — показать лендинг (destination);
            show_offer — вести на оффер; show_404 — отдать 404;
            show_text / show_html — отрисовать `body`.
          enum: [do_nothing, redirect, show_landing, show_offer, show_404, show_text, show_html]
        click_id:
          type: string
          format: uuid
        campaign_id:
          type: string
          format: uuid
        flow_id:
          type: string
          format: uuid
        flow_name:
          type: string
        reason:
          type: string
          description: Причина выбора (или отказа) — та же метка, что в трейсе клика.
        destination:
          type: string
          description: URL лендинга или оффера; отсутствует у нередиректных действий.
        offer_url:
          type: string
          description: >-
            URL оффера, когда маршрут идёт через лендинг — чтобы CTA лендинга
            вела сразу на оффер.
        body:
          type: string
          description: Готовое содержимое страницы для show_text / show_html (макросы уже подставлены).
        landing_id:
          type: string
          format: uuid
          description: >-
            Если лендинг не участвует в маршруте, поле ОТСУТСТВУЕТ в ответе
            (сериализуется с omitempty) — `null` не приходит.
        offer_id:
          type: string
          format: uuid
          description: >-
            Если оффер не участвует в маршруте, поле ОТСУТСТВУЕТ в ответе
            (сериализуется с omitempty) — `null` не приходит.
        redirect_type:
          type: string
          description: Тип редиректа, настроенный в потоке (302, 301, meta, js, iframe и т.д.).
        is_bot:
          type: boolean
        is_unique:
          type: boolean
    ActionRequest:
      type: object
      required: [click_id, action]
      properties:
        click_id:
          type: string
          description: click_id клика. Синоним — `subid`.
        subid:
          type: string
          description: Синоним `click_id`.
        action:
          type: string
          description: >-
            lead, sale, signup — фиксированные статусы; custom берёт статус из
            поля `status`. Иное значение — 400.
          enum: [lead, sale, signup, custom]
        status:
          type: string
          description: Собственный статус конверсии; обязателен при `action=custom`.
        payout:
          type: number
          description: >-
            Принимается, но игнорируется — выручка берётся из оффера,
            привязанного к клику.
        currency:
          type: string
          description: Принимается, но игнорируется — валюта берётся из оффера.
        txid:
          type: string
          description: Идентификатор транзакции; ключ антидубля.
        email:
          type: string
        phone:
          type: string
        t:
          type: string
          description: >-
            Пер-клик токен конверсии. SDK подставляет его сам из `#_ct` / cookie
            `_mtoken`.
    PostbackForm:
      type: object
      required: [subid, status]
      properties:
        subid:
          type: string
          description: click_id (синоним — `clickid`).
        clickid:
          type: string
        status:
          type: string
        payout:
          type: string
        currency:
          type: string
        txid:
          type: string
        em:
          type: string
        ph:
          type: string
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
