openapi: 3.0.3
info:
  title: AdFlux Backend API
  version: v1
servers:
  - url: /
tags:
  - name: auth
  - name: users
  - name: uploads
  - name: projects
  - name: campaigns
  - name: placements
  - name: billing
  - name: payments
  - name: image-generations
paths:
  /api/v1/auth/register:
    post:
      tags: [auth]
      operationId: authRegister
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/confirm-email:
    post:
      tags: [auth]
      operationId: authConfirmEmail
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConfirmEmailRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/confirm-email-change:
    post:
      tags: [auth]
      operationId: authConfirmEmailChange
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConfirmEmailRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/login:
    post:
      tags: [auth]
      operationId: authLogin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Success
          headers:
            Set-Cookie:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/refresh:
    post:
      tags: [auth]
      operationId: authRefresh
      parameters:
        - in: cookie
          name: refresh_token
          schema:
            type: string
      responses:
        '200':
          description: Success
          headers:
            Set-Cookie:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefreshResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/logout:
    post:
      tags: [auth]
      operationId: authLogout
      parameters:
        - in: cookie
          name: refresh_token
          schema:
            type: string
      responses:
        '200':
          description: Success
          headers:
            Set-Cookie:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/forgot-password:
    post:
      tags: [auth]
      operationId: authForgotPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ForgotPasswordRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/reset-password:
    post:
      tags: [auth]
      operationId: authResetPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/change-password:
    post:
      tags: [auth]
      operationId: authChangePassword
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePasswordRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/users/me/security:
    get:
      tags: [users]
      operationId: usersGetSecurity
      security:
        - Bearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSecurity'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/users/me:
    get:
      tags: [users]
      operationId: usersGetMe
      security:
        - Bearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        default:
          $ref: '#/components/responses/DefaultError'
    patch:
      tags: [users]
      operationId: usersPatchMe
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchMeRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/users/me/notifications:
    get:
      tags: [users]
      operationId: usersGetNotifications
      security:
        - Bearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationPreferences'
        default:
          $ref: '#/components/responses/DefaultError'
    patch:
      tags: [users]
      operationId: usersPatchNotifications
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateNotificationPreferencesRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationPreferences'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/login/two-factor:
    post:
      tags: [auth]
      operationId: authLoginTwoFactor
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginTwoFactorRequest'
      responses:
        '200':
          description: Success
          headers:
            Set-Cookie:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/users/me/two-factor/setup:
    post:
      tags: [two-factor]
      operationId: twoFactorSetup
      security:
        - Bearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TwoFactorSetupResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/users/me/two-factor/verify:
    post:
      tags: [two-factor]
      operationId: twoFactorVerify
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFactorCodeRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecoveryCodesResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/users/me/two-factor/disable:
    post:
      tags: [two-factor]
      operationId: twoFactorDisable
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFactorCodeRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/users/me/two-factor/recovery-codes/regenerate:
    post:
      tags: [two-factor]
      operationId: twoFactorRegenerateRecoveryCodes
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFactorCodeRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecoveryCodesResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/oauth/google/initiate:
    get:
      tags: [oauth]
      operationId: oauthGoogleInitiate
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthInitiateResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/oauth/google/callback:
    get:
      tags: [oauth]
      operationId: oauthGoogleCallback
      parameters:
        - in: query
          name: code
          required: true
          schema:
            type: string
        - in: query
          name: state
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Success
          headers:
            Set-Cookie:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/auth/oauth/telegram:
    post:
      tags: [oauth]
      operationId: oauthTelegram
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TelegramAuthRequest'
      responses:
        '200':
          description: Success
          headers:
            Set-Cookie:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/uploads/init:
    post:
      tags: [uploads]
      operationId: uploadsInit
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InitUploadRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadSlot'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/uploads/{uploadId}/content:
    put:
      tags: [uploads]
      operationId: uploadsUploadContent
      security:
        - Bearer: []
      parameters:
        - in: path
          name: uploadId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/assets/{assetId}:
    get:
      tags: [uploads]
      operationId: assetsGetById
      security:
        - Bearer: []
      parameters:
        - in: path
          name: assetId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        default:
          $ref: '#/components/responses/DefaultError'
    delete:
      tags: [uploads]
      operationId: assetsDelete
      security:
        - Bearer: []
      parameters:
        - in: path
          name: assetId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/projects/analyze-url:
    post:
      tags: [projects]
      operationId: projectsAnalyzeUrl
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnalyzeUrlRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UrlAnalysisResult'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/projects/context/generate:
    post:
      tags: [projects]
      operationId: projectsGenerateContext
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateProjectContextRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GenerateProjectContextResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/projects/context/revise-section:
    post:
      tags: [projects]
      operationId: projectsReviseContextSection
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReviseProjectContextRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReviseProjectContextResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/projects:
    post:
      tags: [projects]
      operationId: projectsCreate
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProjectRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        default:
          $ref: '#/components/responses/DefaultError'
    get:
      tags: [projects]
      operationId: projectsList
      security:
        - Bearer: []
      parameters:
        - in: query
          name: sort
          schema:
            $ref: '#/components/schemas/ProjectListSort'
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectListResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/projects/{projectId}:
    get:
      tags: [projects]
      operationId: projectsGetById
      security:
        - Bearer: []
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        default:
          $ref: '#/components/responses/DefaultError'
    patch:
      tags: [projects]
      operationId: projectsPatchById
      security:
        - Bearer: []
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchProjectRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        default:
          $ref: '#/components/responses/DefaultError'
    delete:
      tags: [projects]
      operationId: projectsDelete
      security:
        - Bearer: []
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeletionResult'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/projects/{projectId}/archive:
    post:
      tags: [projects]
      operationId: projectsArchive
      security:
        - Bearer: []
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/generate-ad-copy:
    post:
      tags: [campaigns]
      operationId: campaignsGenerateAdCopy
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateAdCopyRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GenerateAdCopyResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/ad-copy/validate:
    post:
      tags: [campaigns]
      operationId: campaignsValidateAdCopy
      security:
        - Bearer: []
      description: >-
        Checks a marked-up ad text against exactly the rules campaign creation will apply to it, and
        answers with everything that is wrong rather than the first problem found. The WYSIWYG editor on
        the front end serialises its state into our markup and calls this on every pause in typing: there
        is no second parser anywhere, so this endpoint and campaign creation cannot disagree by
        construction. A text that is wrong is still a successful call — the verdict is in the body, and a
        4xx here means the request itself was malformed (an unusable destinationUrl, a missing field).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ValidateAdCopyRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidateAdCopyResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns:
    post:
      tags: [campaigns]
      operationId: campaignsCreate
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCampaignRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/match-channels:
    post:
      tags: [campaigns]
      operationId: campaignsMatchChannels
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CampaignMatchChannelsRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignChannelCandidateListResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/similar-channels:
    post:
      tags: [campaigns]
      operationId: campaignsSimilarChannels
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CampaignSimilarChannelsRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignChannelCandidateListResponse'
        '422':
          description: >-
            error is SEEDS_NOT_READY - none of the seeds is indexed for look-alike search yet, so there is
            nothing to search around. An added channel becomes usable as a seed once
            channels/collection reports it READY.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/match-channels-by-prompt:
    post:
      tags: [campaigns]
      operationId: campaignsMatchChannelsByPrompt
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CampaignMatchChannelsByPromptRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignChannelCandidateListResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/channels/check:
    post:
      tags: [campaigns]
      operationId: campaignsCheckUserChannel
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserChannelRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserChannelResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/channels/add:
    post:
      tags: [campaigns]
      operationId: campaignsAddUserChannel
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserChannelRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserChannelResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/channels/collection:
    get:
      tags: [campaigns]
      operationId: campaignsGetUserChannelCollection
      security:
        - Bearer: []
      parameters:
        - in: query
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: matchToken
          required: true
          schema:
            type: string
        - in: query
          name: channelId
          required: true
          description: >-
            A channel this list got from channels/add. A list that expired, or a channel that is not on it
            or that a search found, is a 404 - there is nothing to poll for.
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserChannelCollectionResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/generate-channel-prompt:
    post:
      tags: [campaigns]
      operationId: campaignsGenerateChannelPrompt
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateChannelPromptRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GenerateChannelPromptResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/distribution/suggest:
    post:
      tags: [campaigns]
      operationId: campaignsSuggestDistribution
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SuggestDistributionRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuggestDistributionResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/projects/{projectId}/campaigns:
    get:
      tags: [campaigns]
      operationId: campaignsListByProject
      security:
        - Bearer: []
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignListResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/{campaignId}:
    get:
      tags: [campaigns]
      operationId: campaignsGetById
      security:
        - Bearer: []
      parameters:
        - in: path
          name: campaignId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        default:
          $ref: '#/components/responses/DefaultError'
    patch:
      tags: [campaigns]
      operationId: campaignsPatchById
      security:
        - Bearer: []
      parameters:
        - in: path
          name: campaignId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchCampaignRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        default:
          $ref: '#/components/responses/DefaultError'
    delete:
      tags: [campaigns]
      operationId: campaignsDelete
      security:
        - Bearer: []
      parameters:
        - in: path
          name: campaignId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeletionResult'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/{campaignId}/archive:
    post:
      tags: [campaigns]
      operationId: campaignsArchive
      security:
        - Bearer: []
      parameters:
        - in: path
          name: campaignId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Campaign'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/{campaignId}/analytics:
    get:
      tags: [campaigns]
      operationId: campaignsGetAnalytics
      security:
        - Bearer: []
      parameters:
        - in: path
          name: campaignId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignAnalytics'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaigns/{campaignId}/placements:
    get:
      tags: [placements]
      operationId: placementsListByCampaign
      security:
        - Bearer: []
      parameters:
        - in: path
          name: campaignId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignPlacementListResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaign-placements/{placementId}:
    get:
      tags: [placements]
      operationId: placementsGetById
      security:
        - Bearer: []
      parameters:
        - in: path
          name: placementId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignPlacement'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaign-placements/{placementId}/messages:
    get:
      tags: [placements]
      operationId: placementsGetMessages
      security:
        - Bearer: []
      parameters:
        - in: path
          name: placementId
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignPlacementMessagePage'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaign-placements/{placementId}/approve:
    post:
      tags: [placements]
      operationId: placementsApprove
      security:
        - Bearer: []
      parameters:
        - in: path
          name: placementId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignPlacement'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaign-placements/{placementId}/creative-change/reject:
    post:
      description: >-
        Keeps the campaign's original text on a placement in AWAITING_CREATIVE_APPROVAL and lets the agent
        carry on negotiating. Accepting the manager's variant instead is the ordinary approve.
      tags: [placements]
      operationId: placementsRejectCreativeChange
      security:
        - Bearer: []
      parameters:
        - in: path
          name: placementId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignPlacement'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/campaign-placements/{placementId}/cancel:
    post:
      tags: [placements]
      operationId: placementsCancel
      security:
        - Bearer: []
      parameters:
        - in: path
          name: placementId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignPlacement'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/billing/balance:
    get:
      tags: [billing]
      operationId: billingGetBalance
      security:
        - Bearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Balance'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/billing/transactions:
    get:
      tags: [billing]
      operationId: billingListTransactions
      security:
        - Bearer: []
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: type
          description: Comma-separated list of BalanceTransactionType values to filter by.
          schema:
            type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BalanceTransactionPage'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/billing/credits/info:
    get:
      tags: [billing]
      operationId: billingGetCreditsInfo
      security:
        - Bearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditsInfo'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/billing/auto-topup:
    get:
      tags: [billing]
      operationId: billingGetAutoTopup
      security:
        - Bearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AutoTopup'
        default:
          $ref: '#/components/responses/DefaultError'
    put:
      tags: [billing]
      operationId: billingUpdateAutoTopup
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAutoTopupRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AutoTopup'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/billing/auto-topup/card:
    delete:
      tags: [billing]
      operationId: billingDetachAutoTopupCard
      description: Отвязать сохранённую карту и выключить автопополнение. Карты нет — 404.
      security:
        - Bearer: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AutoTopup'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/billing/promo-codes/redeem:
    post:
      tags: [promo-codes]
      operationId: promoCodesRedeem
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RedeemPromoCodeRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RedeemPromoCodeResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/payments/deposit:
    post:
      tags: [payments]
      operationId: paymentsCreateDeposit
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDepositRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentCreateResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/payments:
    get:
      tags: [payments]
      operationId: paymentsList
      security:
        - Bearer: []
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: currency
          required: false
          description: В чём был депозит — USDT, USD, RUB.
          schema:
            $ref: '#/components/schemas/DepositCurrency'
        - in: query
          name: initiator
          required: false
          schema:
            $ref: '#/components/schemas/PaymentInitiator'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentPage'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/payments/{paymentId}:
    get:
      tags: [payments]
      operationId: paymentsGetById
      security:
        - Bearer: []
      parameters:
        - in: path
          name: paymentId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/payments/{paymentId}/action:
    post:
      tags: [payments]
      operationId: paymentsStartAuthentication
      description: >-
        Секрет для формы Stripe.js по неоплаченному платежу Stripe: подтвердить автопополнение, которому банк
        запросил 3-D Secure (stripe.handleNextAction), или вернуться к оплате депозита, если секрет потерялся.
        Платёж не Stripe, уже оплачен, отменён или ещё не выставлен — 409.
      security:
        - Bearer: []
      parameters:
        - in: path
          name: paymentId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Секрет для Stripe.js.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StripeBrowserSecret'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/payments/webhook:
    post:
      tags: [payments]
      operationId: paymentsWebhook
      description: >-
        xRocket Pay callback. The three signature headers are declared optional so a call without them is
        answered 401 by the handler rather than 400 by request validation, and the body is taken as raw
        bytes because the HMAC is computed over exactly what was sent.
      parameters:
        - in: header
          name: Signature
          required: false
          schema:
            type: string
        - in: header
          name: Signature-Version
          required: false
          schema:
            type: string
        - in: header
          name: Signature-Timestamp
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/payments/cloudpayments/{kind}:
    post:
      tags: [payments]
      operationId: paymentsCloudPaymentsWebhook
      description: Уведомления CloudPayments
      parameters:
        - in: path
          name: kind
          required: true
          schema:
            type: string
            enum: [check, pay, fail, refund, receipt, kkt]
        - in: header
          name: Content-HMAC
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CloudPaymentsResult'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/payments/stripe/webhook:
    post:
      tags: [payments]
      operationId: paymentsStripeWebhook
      description: Уведомления от Stripe.
      parameters:
        - in: header
          name: Stripe-Signature
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/image-generations:
    post:
      tags: [image-generations]
      operationId: imageGenerationsCreate
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateImageRequest'
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImageGenerationAcceptedResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/image-generations/{requestId}:
    get:
      tags: [image-generations]
      operationId: imageGenerationsGetStatus
      security:
        - Bearer: []
      parameters:
        - in: path
          name: requestId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImageGenerationStatusResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/image-generations/{requestId}/regenerate:
    post:
      tags: [image-generations]
      operationId: imageGenerationsRegenerate
      security:
        - Bearer: []
      parameters:
        - in: path
          name: requestId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImageGenerationAcceptedResponse'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/api-keys:
    get:
      tags: [api-keys]
      operationId: apiKeysList
      security:
        - Bearer: []
      parameters:
        - in: query
          name: search
          schema:
            type: string
        - in: query
          name: status
          schema:
            $ref: '#/components/schemas/ApiKeyStatus'
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
        - in: query
          name: cursor
          schema:
            type: string
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyPage'
        default:
          $ref: '#/components/responses/DefaultError'
    post:
      tags: [api-keys]
      operationId: apiKeysCreate
      security:
        - Bearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateApiKeyRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedApiKey'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/api-keys/{apiKeyId}:
    patch:
      tags: [api-keys]
      operationId: apiKeysPatch
      security:
        - Bearer: []
      parameters:
        - in: path
          name: apiKeyId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchApiKeyRequest'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKey'
        default:
          $ref: '#/components/responses/DefaultError'
    delete:
      tags: [api-keys]
      operationId: apiKeysDelete
      security:
        - Bearer: []
      parameters:
        - in: path
          name: apiKeyId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/api-keys/{apiKeyId}/enable:
    post:
      tags: [api-keys]
      operationId: apiKeysEnable
      security:
        - Bearer: []
      parameters:
        - in: path
          name: apiKeyId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKey'
        default:
          $ref: '#/components/responses/DefaultError'
  /api/v1/api-keys/{apiKeyId}/disable:
    post:
      tags: [api-keys]
      operationId: apiKeysDisable
      security:
        - Bearer: []
      parameters:
        - in: path
          name: apiKeyId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKey'
        default:
          $ref: '#/components/responses/DefaultError'
components:
  securitySchemes:
    Bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
  responses:
    DefaultError:
      description: Error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
    SuccessResponse:
      type: object
      required: [success]
      properties:
        success:
          type: boolean
    AccountType:
      type: string
      description: >-
        Кто стоит за учётной записью. PERSONAL — частное лицо. CORPORATE — компания: тогда
        companyName и licenseId обязательны, и именно они попадают в счёт.
      enum:
        - PERSONAL
        - CORPORATE
    User:
      type: object
      required: [id, email, displayName, accountType, emailVerified, referralCode, language, createdAt]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
        displayName:
          type: string
        accountType:
          $ref: '#/components/schemas/AccountType'
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
          description: ISO 3166-1 alpha-2.
        companyName:
          type: string
          nullable: true
        licenseId:
          type: string
          nullable: true
          description: Идентификатор юрлица в его стране — ИНН, VAT, Licence No.
        emailVerified:
          type: boolean
        referralCode:
          type: string
        language:
          type: string
        createdAt:
          type: string
          format: date-time
    RegisterRequest:
      type: object
      required: [email, password, accountType, firstName]
      properties:
        email:
          type: string
        password:
          type: string
        displayName:
          type: string
          nullable: true
          description: Отображаемое имя. Не передано — складывается из firstName и lastName.
        accountType:
          $ref: '#/components/schemas/AccountType'
        firstName:
          type: string
          minLength: 1
        lastName:
          type: string
          minLength: 1
        country:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2.
        companyName:
          type: string
          nullable: true
          description: Обязательно при accountType=CORPORATE, иначе 400.
        licenseId:
          type: string
          nullable: true
          description: Обязательно при accountType=CORPORATE, иначе 400.
        referralCode:
          type: string
          nullable: true
    ChangePasswordRequest:
      type: object
      required: [currentPassword, newPassword]
      properties:
        currentPassword:
          type: string
        newPassword:
          type: string
          minLength: 8
        logOutAllDevices:
          type: boolean
          default: true
    UserSecurity:
      type: object
      required: [passwordLastChangedAt, twoFactorEnabled]
      properties:
        passwordLastChangedAt:
          type: string
          format: date-time
        twoFactorEnabled:
          type: boolean
    NotificationPreferences:
      type: object
      required: [balanceChanges, campaignCompletion, campaignStart, adPublished, deliveryEmail]
      properties:
        balanceChanges:
          type: boolean
        campaignCompletion:
          type: boolean
        campaignStart:
          type: boolean
        adPublished:
          type: boolean
        deliveryEmail:
          type: string
    UpdateNotificationPreferencesRequest:
      type: object
      properties:
        balanceChanges:
          type: boolean
        campaignCompletion:
          type: boolean
        campaignStart:
          type: boolean
        adPublished:
          type: boolean
    LoginTwoFactorRequest:
      type: object
      required: [mfaToken, code]
      properties:
        mfaToken:
          type: string
        code:
          type: string
    TwoFactorSetupResponse:
      type: object
      required: [otpauthUri, secretBase32]
      properties:
        otpauthUri:
          type: string
        secretBase32:
          type: string
    TwoFactorCodeRequest:
      type: object
      required: [code]
      properties:
        code:
          type: string
    RecoveryCodesResponse:
      type: object
      required: [recoveryCodes]
      properties:
        recoveryCodes:
          type: array
          items:
            type: string
    OAuthInitiateResponse:
      type: object
      required: [authorizationUrl, state]
      properties:
        authorizationUrl:
          type: string
        state:
          type: string
    TelegramAuthRequest:
      type: object
      required: [initData]
      properties:
        initData:
          type: string
    RegisterResponse:
      type: object
      required: [userId, email, displayName, emailVerified, requiresEmailConfirmation]
      properties:
        userId:
          type: string
          format: uuid
        email:
          type: string
        displayName:
          type: string
        emailVerified:
          type: boolean
        requiresEmailConfirmation:
          type: boolean
    ConfirmEmailRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
        password:
          type: string
    LoginResponse:
      type: object
      required: [requiresTwoFactor]
      properties:
        requiresTwoFactor:
          type: boolean
        accessToken:
          type: string
        user:
          $ref: '#/components/schemas/User'
        mfaToken:
          type: string
    RefreshResponse:
      type: object
      required: [accessToken]
      properties:
        accessToken:
          type: string
    ForgotPasswordRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
    ResetPasswordRequest:
      type: object
      required: [token, newPassword]
      properties:
        token:
          type: string
        newPassword:
          type: string
    PatchMeRequest:
      type: object
      required: [displayName]
      properties:
        displayName:
          type: string
        language:
          type: string
        accountType:
          $ref: '#/components/schemas/AccountType'
          nullable: true
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        companyName:
          type: string
          nullable: true
        licenseId:
          type: string
          nullable: true
    AssetKind:
      type: string
      description: >
        Purpose of the asset; determines the allowed contentType and max size on upload
        (see InitUploadRequest.contentType). PROJECT_LOGO — project logo (light/dark variants).
        CAMPAIGN_MEDIA — campaign creative.
      enum:
        - PROJECT_LOGO
        - CAMPAIGN_MEDIA
    UploadState:
      type: string
      enum:
        - INITIATED
        - UPLOADING
        - CLEANING
        - COMPLETED
        - FAILED
        - EXPIRED
    MoneyAmount:
      type: string
      pattern: '^-?\d+(\.\d{1,6})?$'
      example: "125.500000"
    DecimalValue:
      type: string
      pattern: '^-?\d+(\.\d+)?$'
      example: "0.91"
    AssetSource:
      type: string
      enum:
        - USER_UPLOAD
        - AI_GENERATED
    Asset:
      type: object
      required: [assetId, assetKind, contentType, sizeBytes, publicUrl, source, createdAt]
      properties:
        assetId:
          type: string
          format: uuid
        assetKind:
          $ref: '#/components/schemas/AssetKind'
        contentType:
          type: string
        sizeBytes:
          type: integer
          format: int64
        originalName:
          type: string
          nullable: true
        publicUrl:
          type: string
        description:
          type: string
          nullable: true
        prompt:
          type: string
          nullable: true
        source:
          $ref: '#/components/schemas/AssetSource'
        createdAt:
          type: string
          format: date-time
    AssetRef:
      type: object
      required: [assetId, publicUrl]
      properties:
        assetId:
          type: string
          format: uuid
        publicUrl:
          type: string
    InitUploadRequest:
      type: object
      required: [assetKind, contentType, sizeBytes]
      properties:
        assetKind:
          $ref: '#/components/schemas/AssetKind'
        originalName:
          type: string
          nullable: true
        contentType:
          type: string
          description: >
            MIME type of the file. Allowed values depend on assetKind:
            PROJECT_LOGO — image/png, image/jpeg, image/svg+xml (max 5 MB);
            CAMPAIGN_MEDIA — image/png, image/jpeg, image/gif, video/mp4 (max 25 MB).
            The server also verifies the actual file bytes against the declared contentType and sizeBytes.
          example: image/png
        sizeBytes:
          type: integer
          format: int64
    UploadSlot:
      type: object
      required: [uploadId, assetKind, contentType, sizeBytes, state, expiresAt]
      properties:
        uploadId:
          type: string
          format: uuid
        assetKind:
          $ref: '#/components/schemas/AssetKind'
        originalName:
          type: string
          nullable: true
        contentType:
          type: string
        sizeBytes:
          type: integer
          format: int64
        state:
          $ref: '#/components/schemas/UploadState'
        expiresAt:
          type: string
          format: date-time
    ProjectSourceType:
      type: string
      enum:
        - WEBSITE
        - SOCIAL
    AnalyzeUrlRequest:
      type: object
      required: [url, sourceType]
      properties:
        url:
          type: string
        sourceType:
          $ref: '#/components/schemas/ProjectSourceType'
    UrlAnalysisLogoCandidate:
      type: object
      required: [url]
      properties:
        url:
          type: string
        contentType:
          type: string
          nullable: true
    UrlAnalysisResult:
      type: object
      required: [sourceUrl, sourceType, suggestedColors]
      properties:
        sourceUrl:
          type: string
        sourceType:
          $ref: '#/components/schemas/ProjectSourceType'
        name:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        suggestedColors:
          type: array
          items:
            type: string
        logoCandidate:
          $ref: '#/components/schemas/UrlAnalysisLogoCandidate'
          nullable: true
    ProjectAiContext:
      type: object
      required: [businessOverview, customerDemographics]
      properties:
        businessOverview:
          type: string
        customerDemographics:
          type: string
    ProjectAiContextSection:
      type: string
      enum:
        - BUSINESS_OVERVIEW
        - CUSTOMER_DEMOGRAPHICS
    GenerateProjectContextRequest:
      type: object
      required: [name, description, logoLightAssetId, brandColors]
      properties:
        sourceUrl:
          type: string
          nullable: true
        name:
          type: string
        description:
          type: string
        targetAudience:
          type: string
          nullable: true
        logoLightAssetId:
          type: string
          format: uuid
        brandColors:
          type: array
          items:
            type: string
        visualStyleDescription:
          type: string
          nullable: true
    GenerateProjectContextResponse:
      type: object
      required: [context]
      properties:
        context:
          $ref: '#/components/schemas/ProjectAiContext'
    ReviseProjectContextRequest:
      type: object
      required: [currentContext, section, instruction]
      properties:
        currentContext:
          $ref: '#/components/schemas/ProjectAiContext'
        section:
          $ref: '#/components/schemas/ProjectAiContextSection'
        instruction:
          type: string
    ReviseProjectContextResponse:
      type: object
      required: [section, content]
      properties:
        section:
          $ref: '#/components/schemas/ProjectAiContextSection'
        content:
          type: string
    ProjectStatus:
      type: string
      enum:
        - ACTIVE
        - ARCHIVED
    CreateProjectRequest:
      type: object
      required: [sourceType, name, description, logoLightAssetId, brandColors, aiContext]
      properties:
        sourceType:
          $ref: '#/components/schemas/ProjectSourceType'
        sourceUrl:
          type: string
          nullable: true
        name:
          type: string
        description:
          type: string
        targetAudience:
          type: string
          nullable: true
        logoLightAssetId:
          type: string
          format: uuid
        logoDarkAssetId:
          type: string
          format: uuid
          nullable: true
        brandColors:
          type: array
          items:
            type: string
        visualStyleDescription:
          type: string
          nullable: true
        aiContext:
          $ref: '#/components/schemas/ProjectAiContext'
    PatchProjectRequest:
      type: object
      required: [name, description, brandColors, aiContext, status]
      properties:
        name:
          type: string
        description:
          type: string
        targetAudience:
          type: string
          nullable: true
        logoLightAssetId:
          type: string
          format: uuid
        logoDarkAssetId:
          type: string
          format: uuid
          nullable: true
        brandColors:
          type: array
          items:
            type: string
        visualStyleDescription:
          type: string
          nullable: true
        aiContext:
          $ref: '#/components/schemas/ProjectAiContext'
        status:
          $ref: '#/components/schemas/ProjectStatus'
    Project:
      type: object
      required:
        [id, sourceType, name, description, logoLightAsset, brandColors, aiContext, status, createdAt, updatedAt, lastActivityAt]
      properties:
        id:
          type: string
          format: uuid
        sourceType:
          $ref: '#/components/schemas/ProjectSourceType'
        sourceUrl:
          type: string
          nullable: true
        name:
          type: string
        description:
          type: string
        targetAudience:
          type: string
          nullable: true
        logoLightAsset:
          $ref: '#/components/schemas/AssetRef'
        logoDarkAsset:
          $ref: '#/components/schemas/AssetRef'
          nullable: true
        brandColors:
          type: array
          items:
            type: string
        visualStyleDescription:
          type: string
          nullable: true
        aiContext:
          $ref: '#/components/schemas/ProjectAiContext'
        status:
          $ref: '#/components/schemas/ProjectStatus'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        lastActivityAt:
          type: string
          format: date-time
    ProjectListItem:
      type: object
      required:
        [id, sourceType, name, description, status, createdAt, lastActivityAt, logoLightAsset, campaignsTotal, campaignsActive]
      properties:
        id:
          type: string
          format: uuid
        sourceType:
          $ref: '#/components/schemas/ProjectSourceType'
        sourceUrl:
          type: string
          nullable: true
        name:
          type: string
        description:
          type: string
        status:
          $ref: '#/components/schemas/ProjectStatus'
        createdAt:
          type: string
          format: date-time
        lastActivityAt:
          type: string
          format: date-time
        logoLightAsset:
          $ref: '#/components/schemas/AssetRef'
        logoDarkAsset:
          $ref: '#/components/schemas/AssetRef'
          nullable: true
        campaignsTotal:
          type: integer
          format: int64
        campaignsActive:
          type: integer
          format: int64
    ProjectListSort:
      type: string
      enum:
        - BY_ACTIVITY
        - BY_CREATED_AT
    ProjectListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ProjectListItem'
        nextCursor:
          type: string
          nullable: true
    GenerateAdCopyRequest:
      type: object
      required: [projectContext, userNotes]
      properties:
        projectContext:
          $ref: '#/components/schemas/ProjectAiContext'
        userNotes:
          type: string
          minLength: 1
          maxLength: 2000
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
        destinationUrl:
          type: string
          nullable: true
          maxLength: 2048
          description: >-
            The campaign's destination URL, so the generated copy can link to it. Placeholders (see
            CreateCampaignRequest.destinationUrl) are carried into the generated text character for
            character: the generator is told they are load-bearing and must not be renamed, completed or
            tidied away with the rest of the query string.
    GenerateAdCopyResponse:
      type: object
      required: [adCopy]
      properties:
        adCopy:
          type: string
    ValidateAdCopyRequest:
      type: object
      required: [text, destinationUrl]
      properties:
        text:
          type: string
          maxLength: 8000
          description: >-
            The ad text as the advertiser wrote it, markup included. Formatting is written the way
            Telegram writes it — *bold*, _italic_, __underline__, ~strikethrough~, ||spoiler||, `monospace`,
            >quote to the end of the line — plus two of ours: `--` becomes an em dash, and {these words}
            become the post's link to destinationUrl. A premium custom emoji is written
            `![😀](tg://emoji?id=5368324170671202286)`: the emoji with that document id is drawn over the
            character in the brackets, which has to be a single emoji itself — it is what a reader whose
            client cannot resolve the id goes on seeing, and it is what the visible length counts. Only the
            ten characters that mean something need escaping (\* \_ \~ \` \| \{ \} \> \- \\); full stops,
            brackets and percent signs are plain text, unlike Bot API MarkdownV2. An exclamation mark is
            plain text too, everywhere except in front of a `[`, where it would begin the custom-emoji form:
            `\!` there is the character itself, and it is how a text carrying `![😀](tg://emoji?id=...)` as
            literal characters is written down. Inside a recognised web address none of them mean anything
            at all: `mysite.com/my_page_2` keeps both underscores,
            `/a--b` keeps both hyphens and `?utm_campaign={campaign}` keeps its braces, so nobody has to
            escape his own URL. The cap here is a bound on work per request, an order of magnitude above any
            text that could pass the 700-character visible limit; exceeding it is a 400, not a validation
            error.
        destinationUrl:
          type: string
          minLength: 1
          maxLength: 2048
          description: >-
            Where the campaign's link goes. Required because it is what defines the advertiser's own
            domain, and the rule that no other domain may appear in the text cannot be evaluated without
            it. Accepted with or without a scheme while the advertiser is still typing the form field;
            anything that is not a URL either way is a 400.
    ValidateAdCopyResponse:
      type: object
      required: [valid, visibleLength, errors]
      properties:
        valid:
          type: boolean
          description: True only when errors is empty. Campaign creation will accept exactly the texts this is true for
        visibleLength:
          type: integer
          format: int32
          description: >-
            Length of what the reader will see — markup characters removed, `--` already an em dash —
            counted in UTF-16 code units, so an emoji counts as 2. This is the number the 700 limit applies
            to and the number an editor should put in its character counter
        errors:
          type: array
          items:
            $ref: '#/components/schemas/AdCopyError'
    AdCopyError:
      type: object
      required: [offset, length, code, message]
      description: >-
        One problem, positioned so an editor can underline it. offset and length address the marked-up
        string that was sent, NOT the visible text, because that is where the mistake is: the visible text
        does not contain the '*' that was never closed. Both are in UTF-16 code units.
      properties:
        offset:
          type: integer
          format: int32
        length:
          type: integer
          format: int32
        code:
          $ref: '#/components/schemas/AdCopyErrorCode'
        message:
          type: string
          description: English prose for a human. Branch on code, never on this — the wording is free to change
    AdCopyErrorCode:
      type: string
      description: >-
        UNCLOSED_ENTITY - a delimiter was opened and the text ended first.
        AMBIGUOUS_DELIMITER - '___', which is '__'+'_' or '_'+'__' with nothing to choose between them.
        OVERLAPPING_ENTITIES - a closer belongs to a section that is not the innermost open one; styles must nest.
        EMPTY_ENTITY - a delimiter pair with nothing between it.
        EMPTY_LINK - '{}' with no anchor text.
        INVALID_ESCAPE - a backslash before a character that is not escapable.
        NESTED_LINK - '{' inside another '{...}'.
        UNMATCHED_LINK_CLOSE - '}' with no '{' open.
        INVALID_CUSTOM_EMOJI_FALLBACK - the '[x]' of '![x](tg://emoji?id=...)' is not a single emoji. The
        custom emoji is drawn over that character, and every reader whose client cannot resolve it goes on
        seeing exactly what stands there, so a letter, a digit, a word, a space or nothing at all is refused.
        INVALID_CUSTOM_EMOJI_ID - the id of '![x](tg://emoji?id=...)' is not an unsigned whole number that
        fits in 64 bits.
        LINK_REQUIRED - the text contains no '{...}' at all.
        FOREIGN_LINK - a URL or domain that is not the advertiser's own or a subdomain of it.
        TEXT_TOO_LONG - the visible text is over 700 units.
      enum:
        - UNCLOSED_ENTITY
        - AMBIGUOUS_DELIMITER
        - OVERLAPPING_ENTITIES
        - EMPTY_ENTITY
        - EMPTY_LINK
        - INVALID_ESCAPE
        - NESTED_LINK
        - UNMATCHED_LINK_CLOSE
        - INVALID_CUSTOM_EMOJI_FALLBACK
        - INVALID_CUSTOM_EMOJI_ID
        - LINK_REQUIRED
        - FOREIGN_LINK
        - TEXT_TOO_LONG
    GenerateChannelPromptRequest:
      type: object
      required: [projectId, adCopy]
      properties:
        projectId:
          type: string
          format: uuid
        adCopy:
          type: string
          minLength: 1
          maxLength: 8000
          description: >-
            The ad text, markup and all, used only as material for writing the channel-search prompt. Same
            cap as everywhere else the ad text is accepted, so a text the editor considers valid can never
            be refused by a request-size rule on one endpoint and not another.
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
    GenerateChannelPromptResponse:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          maxLength: 300
          description: >-
            A ready-to-edit channel search request written on the advertiser's behalf, in the same language
            as the ad copy: what the ideal channels post about, who reads them, how big they should be and
            what language they publish in. Meant to be dropped straight into the prompt field of
            match-channels-by-prompt, so it is capped at 300 characters — a search box, not a brief.
    DailyDistributionItem:
      type: object
      required: [date, amount]
      properties:
        date:
          type: string
          format: date
        amount:
          $ref: '#/components/schemas/MoneyAmount'
    CampaignFilters:
      type: object
      description: >-
        The channel filters a search actually applied. Reported back by match-channels-by-prompt so the
        advertiser can see how his own words were read — "20K+ subscribers" comes back as
        minSubscribers=20000 — and correct them if they were read wrong. Every field is optional on
        purpose: only what the prompt actually stated is filled, and an absent field means no such filter
        narrowed the search. `ranking` and `minManagerContactConfidence` always come back, because the
        search always ranks somehow and always applies a contact requirement.
      properties:
        minSubscribers:
          type: integer
          format: int64
          nullable: true
        maxSubscribers:
          type: integer
          format: int64
          nullable: true
        maxFraudScore:
          type: number
          format: double
          nullable: true
        minAvgReach:
          type: integer
          format: int64
          nullable: true
        language:
          type: string
          nullable: true
          description: >-
            ISO 639-1 code of the audience language the search filtered on ("English-speaking traders" comes
            back as "en"). Absent when the language was not pinned by us and the search instead filtered on
            the language of the query text itself.
        ranking:
          type: string
          enum: [balanced, max_reach, max_engagement]
          nullable: true
        minManagerContactConfidence:
          type: string
          enum: [any, low, medium, high]
          nullable: true
    CampaignStatus:
      type: string
      enum:
        - ACTIVE
        - COMPLETED
        - ARCHIVED
    DeletionOutcome:
      type: string
      enum:
        - DELETED
        - ARCHIVED
    DeletionResult:
      type: object
      required: [outcome]
      properties:
        outcome:
          $ref: '#/components/schemas/DeletionOutcome'
    CampaignAnalytics:
      type: object
      required: [budget, spent, reserved, remaining, currency, perChannel, performance]
      description: >-
        Campaign money and campaign-level totals. Per-channel audience facts live on CampaignPlacement,
        not here. spent + reserved + remaining always equals budget, where spent is what actually left
        the account (PAID and every status after it), reserved is committed on approval but not yet paid
        out, and remaining is the uncommitted budget a further approval can still spend.
      properties:
        budget:
          $ref: '#/components/schemas/MoneyAmount'
        spent:
          $ref: '#/components/schemas/MoneyAmount'
        reserved:
          $ref: '#/components/schemas/MoneyAmount'
        remaining:
          $ref: '#/components/schemas/MoneyAmount'
        currency:
          type: string
        perChannel:
          type: array
          items:
            $ref: '#/components/schemas/CampaignChannelAnalytics'
        performance:
          $ref: '#/components/schemas/CampaignPerformance'
    CampaignChannelAnalytics:
      type: object
      required: [placementId, status, amount]
      description: >-
        One placement's money. amount is the price agreed with the manager; status says whether it was
        paid, is merely held, or came to nothing. Placements with no agreed price are absent.
      properties:
        placementId:
          type: string
          format: uuid
        channelUsername:
          type: string
          nullable: true
        channelTitle:
          type: string
          nullable: true
        status:
          $ref: '#/components/schemas/CampaignPlacementStatus'
        amount:
          $ref: '#/components/schemas/MoneyAmount'
    CampaignPerformance:
      type: object
      required: [dataAvailable, measuredPlacements]
      description: >-
        What the campaign delivered, against what it was forecast to deliver. views and reactions are
        measured by the parser on the posts that were actually published: they converge to the figure
        reached roughly 48h after each post went up and then stop growing, because tracking retention ends
        there and the placement completes — this is a settled result, not a live counter. clicks are
        measured on our own redirect: every link in a published post points at go.aflux.ai first, so
        following it is counted before the reader is sent on to the advertiser. Those two clocks do NOT run
        together, and ctr is the field that suffers for it — views stop at ~48h while clicks keep accruing
        for as long as the post stays up, so a campaign's ctr drifts DOWNWARD over the weeks after it
        finishes and is only comparable between campaigns of similar age. targetViews is the only forecast
        here (summed from the channels' statistics before the campaign ran); everything else is measured.
      properties:
        dataAvailable:
          type: boolean
          description: Whether anything has been measured yet; false means views/reactions/cpm are all absent
        views:
          type: integer
          format: int64
          nullable: true
          description: >-
            Views measured on the published posts, summed over the measured placements. Null (never 0)
            while nothing has been measured
        reactions:
          type: integer
          format: int64
          nullable: true
          description: Reactions measured on the same posts, summed the same way; null while nothing has been measured
        measuredPlacements:
          type: integer
          format: int32
          description: >-
            How many of the campaign's placements the measured figures cover, so a partial total is not
            read as the whole campaign («по 3 размещениям из 5»). 0 when nothing has been measured
        capturedAt:
          type: string
          format: date-time
          nullable: true
          description: When the newest snapshot behind these figures was taken, i.e. how fresh views is
        cpm:
          $ref: '#/components/schemas/MoneyAmount'
          nullable: true
          description: >-
            Actual cost per 1000 views of the measured placements ONLY — their own payouts divided by their
            own views, not the campaign's whole spent over a measured subset of its audience. Always read it
            together with measuredPlacements: while some placements are still unmeasured this is a partial
            price, not the campaign's final cost per view. Null while nothing has been measured
        clicks:
          type: integer
          format: int64
          nullable: true
          description: >-
            Times a link in one of this campaign's published posts was followed, summed over every
            placement. Counted on our own redirect, so it includes repeat clicks by the same person —
            uniqueClicks is what separates the two. Null (never 0) while nothing has been clicked
        uniqueClicks:
          type: integer
          format: int64
          nullable: true
          description: >-
            The same clicks counted one per IP, over the whole campaign — a reader who clicked in two of
            its channels is one person here, which is why this is not the sum of the placements' own unique
            counts. clicks minus uniqueClicks is the readers who came back. Null (never 0) while nothing
            has been clicked
        ctr:
          type: string
          nullable: true
          description: >-
            Click-through rate as a FRACTION, not a percent: clicks divided by measured views, so 0.0238
            means 2.38%. Uses total clicks (repeat clicks included), matching how the figure is quoted in
            the market. Drifts downward over time — views stop growing ~48h after each post while clicks
            keep arriving — so compare it between campaigns of similar age, not across a fresh one and a
            finished one. Null while there are no clicks or no measured views
        costPerClick:
          type: string
          nullable: true
          description: >-
            What one click cost: the same measured-placements-only money cpm divides, over total clicks.
            Read it together with measuredPlacements for the same reason cpm must be — while placements are
            still unmeasured this is a partial price. Null while nothing has been clicked
        targetViews:
          type: integer
          format: int64
          nullable: true
          description: >-
            Pre-campaign forecast: expected views summed over every placement that can still deliver a post
            or already has. Null when no channel carries usable reach
    ApprovalMode:
      type: string
      enum:
        - OPERATOR
        - USER
    CampaignObjective:
      type: string
      enum:
        - AWARENESS
        - TRAFFIC
        - CONVERSIONS
    CampaignPlatform:
      type: string
      enum:
        - TELEGRAM
    CampaignDistributionType:
      type: string
      enum:
        - RECOMMENDED
        - MANUAL
    DistributionRecommendation:
      type: string
      enum:
        - AI_BEST
        - LOW_TRAFFIC
        - NORMAL
    CampaignPlacementStatus:
      type: string
      enum:
        - NEW
        - AWAITING_CONTACT_VERIFICATION
        - IN_PROGRESS
        - AWAITING_CREATIVE_APPROVAL
        - PENDING_APPROVAL
        - APPROVED
        - PAYMENT_PENDING
        - PAYMENT_FAILED
        - PAID
        - AWAITING_POST
        - PUBLISHED
        - VERIFIED
        - COMPLETED
        - REJECTED
        - ESCALATED
        - CANCELLED
        - BREACH
    CampaignMatchChannelsRequest:
      type: object
      required: [projectId, adCopy, platform]
      properties:
        projectId:
          type: string
          format: uuid
        adCopy:
          type: string
          maxLength: 8000
          description: >-
            The ad text, markup and all, used here only as the brief a channel search is derived from.
            Deliberately NOT judged as publishable copy: this request carries no destinationUrl, so the rule
            about foreign domains cannot be evaluated, and half a verdict would teach the advertiser the
            wrong lesson about what is checked. Campaign create is where the text is judged.
        platform:
          $ref: '#/components/schemas/CampaignPlatform'
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
    CampaignMatchChannelsByPromptRequest:
      type: object
      required: [projectId, platform, prompt]
      properties:
        projectId:
          type: string
          format: uuid
        platform:
          $ref: '#/components/schemas/CampaignPlatform'
        prompt:
          type: string
          minLength: 1
          maxLength: 2000
          description: >-
            The advertiser's own words for the channels he wants, e.g. "TON and DeFI channels, 20K+
            subscribers, English-speaking traders, no giveaway posts". One sentence like this carries three
            different kinds of requirement, and each is routed to where it can actually be enforced: the
            subject matter becomes the semantic query, the audience language becomes the parser's language
            filter, the stated numbers become audience filters (reported back in `filters`), and the
            negations become instructions to the relevance classifier, which is the only step that can
            honour a "no ...". Unlike match-channels this needs no project AI context: the prompt says
            everything. `projectId` is still required because the returned matchToken is bound to it.
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
    CampaignChannelCandidate:
      type: object
      required: [channelId, platform, relevancy, score, avgReach, fraudScore]
      properties:
        channelId:
          type: integer
          format: int64
        platform:
          $ref: '#/components/schemas/CampaignPlatform'
        channelUsername:
          type: string
          nullable: true
        channelTitle:
          type: string
          nullable: true
        subscribers:
          type: integer
          format: int32
          nullable: true
        relevancy:
          type: number
          format: double
        score:
          type: number
          format: double
        avgReach:
          type: integer
          format: int64
        fraudScore:
          type: number
          format: double
        managerUsername:
          type: string
          nullable: true
        channelSource:
          $ref: '#/components/schemas/ChannelSource'
    ChannelSource:
      type: string
      description: >-
        MATCH - the channel came from one of the searches, and its relevancy, score and fraudScore are
        measurements. USER - the advertiser added it himself (channels/add); those three numbers are
        placeholders (1, 0 and 0.5) and say nothing about the channel.
      enum: [MATCH, USER]
    CampaignChannelCandidateListResponse:
      type: object
      required: [matchToken, channels]
      properties:
        matchToken:
          type: string
        channels:
          type: array
          items:
            $ref: '#/components/schemas/CampaignChannelCandidate'
        filters:
          $ref: '#/components/schemas/CampaignFilters'
          nullable: true
          description: >-
            Present only for match-channels-by-prompt, which is the only search that INFERS its filters
            instead of being told them: it reports back what it read out of the prompt. The ad-copy and
            look-alike searches take no filters from the caller, so they leave this absent.
    CampaignSimilarChannelsRequest:
      type: object
      required: [projectId, platform, seedChannelIds]
      properties:
        projectId:
          type: string
          format: uuid
        platform:
          $ref: '#/components/schemas/CampaignPlatform'
        matchToken:
          type: string
          nullable: true
          description: >-
            The token of the search the seeds were picked from. Given it, the seeds are carried over into the
            new list — at its head, with relevancy 1 — so that a look-alike search EXTENDS the advertiser's
            selection instead of replacing it. Campaign create consumes exactly one token and buys exactly the
            channels cached under it, so a seed missing from the newest list cannot be bought at all. Omitted,
            or naming a search that has since expired, the response carries the look-alikes alone. A token
            belonging to another user, project or platform is refused.
        seedChannelIds:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: integer
            format: int64
          description: >-
            The channels to look for look-alikes of. They are averaged into ONE query point, not searched
            for one by one: seeds that sit close to each other describe a single kind of channel and give a
            sharp result, while scattered seeds average out into a vague point in the middle and give a
            vague one. Pick channels that belong together. A seed whose vector the parser does not know is
            skipped. The seeds never come back among the look-alikes themselves — a seed is its own closest
            look-alike and would fill the top of the answer — they are re-added from `matchToken` when one is
            given.
        limit:
          type: integer
          format: int32
          nullable: true
    ManagerContactKind:
      type: string
      description: >-
        Which system a manager contact address belongs to. Same closed vocabulary as everywhere else a
        contact is exchanged. Only TELEGRAM can be negotiated over today, so it is the only kind an
        advertiser may supply; the others can still come back as a contact already known for the channel.
      enum: [TELEGRAM, EMAIL, WHATSAPP, X]
    UserChannelContact:
      type: object
      required: [kind, address]
      properties:
        kind:
          $ref: '#/components/schemas/ManagerContactKind'
        address:
          type: string
          maxLength: 256
          description: "A Telegram handle with or without '@' or a t.me/ prefix, e.g. '@ads_manager'"
    UserChannelRequest:
      type: object
      required: [projectId, matchToken, channelLink]
      properties:
        projectId:
          type: string
          format: uuid
        matchToken:
          type: string
          description: >-
            The token of the channel list being shown — the one campaign create will be called with. It must
            belong to this user and project, like on create.
        channelLink:
          type: string
          maxLength: 512
          description: >-
            The channel as the advertiser wrote it: 't.me/name', 'https://t.me/s/name', a post link
            't.me/name/123', 'telegram.me/name', 'telegram.dog/name', 'name.t.me', 't.me/boost/name',
            'tg://resolve?domain=name', '@name' or the bare 'name'. Private invite links, private channel
            posts, other sites and Telegram's own pages (share, proxy, sticker sets, ...) are reported as
            errors, not refused as a malformed request.
        managerContact:
          $ref: '#/components/schemas/UserChannelContact'
    UserChannelInfo:
      type: object
      required: [channelId, username, title, subscribers]
      properties:
        channelId:
          type: integer
          format: int64
        username:
          type: string
        title:
          type: string
        subscribers:
          type: integer
          format: int64
    UserChannelContactSource:
      type: string
      description: >-
        OPERATOR_VERIFIED - a contact our support has already confirmed for this channel; the placement
        goes straight to negotiation. CHANNEL_DESCRIPTION - read out of the channel's description, by a
        heuristic or a model; support confirms it before anyone is written to. USER - the one the
        advertiser supplied; support confirms it before anyone is written to. NONE - there is no contact,
        and the form must ask for one.
      enum: [OPERATOR_VERIFIED, CHANNEL_DESCRIPTION, USER, NONE]
    UserChannelContactView:
      type: object
      required: [source]
      description: >-
        The contact the form's field should show. When the request carried one, it is echoed back here
        (normalised when it is valid) with source USER, and errors says whether it is usable — unless it
        is exactly the contact already known for the channel, which keeps that contact's source, so a
        prefilled field sent back unchanged is not treated as the advertiser's own. Without one in the
        request it is the contact already known for the channel, if any; for a channel already added to
        this list with channels/add, it is the contact stored on the list. kind and address are absent with
        NONE.
      properties:
        kind:
          $ref: '#/components/schemas/ManagerContactKind'
        address:
          type: string
        source:
          $ref: '#/components/schemas/UserChannelContactSource'
    UserChannelErrorField:
      type: string
      description: >-
        CHANNEL_LINK and MANAGER_CONTACT are the two form fields; REQUEST is about the request as a whole
        (an expired list, a probe that could not run).
      enum: [CHANNEL_LINK, MANAGER_CONTACT, REQUEST]
    UserChannelErrorCode:
      type: string
      description: >-
        INVALID_LINK - not a channel link or username we can read, including a phone link ('t.me/+79...') and Telegram's own pages ('t.me/share/...', 't.me/proxy?...', 't.me/addlist/...').
        NOT_TELEGRAM - a link to another site.
        PRIVATE_LINK - a private invite link ('t.me/+...', 't.me/joinchat/...') or a post of a channel without a username ('t.me/c/...', 'tg://privatepost?...'); only public channels can be added.
        CHANNEL_NOT_FOUND - no public channel under this username: it does not exist, is private, or is a user or a group.
        TOO_FEW_SUBSCRIBERS - the channel is below the subscriber floor we buy from.
        NO_POSTS - the channel has published nothing.
        CHANNEL_INACTIVE - the channel is public but has no recent or usable posts, so it cannot be worked with.
        CHANNEL_UNAVAILABLE - the channel exists but is excluded from buying on our side.
        ALREADY_ADDED - the channel is already in this list, found by a search. A channel added with channels/add is not reported: sending it again edits its manager contact (channels/add replaces it on the list; without managerContact the stored one is kept).
        CONTACT_REQUIRED - the channel has no known manager contact and none was supplied.
        INVALID_CONTACT - the supplied contact is not a valid address of its kind.
        UNSUPPORTED_CONTACT_KIND - a kind other than TELEGRAM was supplied.
        CONTACT_IS_CHANNEL - the contact is the channel's own username, which nobody can be written to at.
        MATCH_EXPIRED - matchToken is unknown or expired; run a search again.
        CHECK_UNAVAILABLE - the channel could not be checked right now; retry later.
        SUBMISSION_LIMIT_REACHED - a limit on the advertiser's own channels was hit, and message says which:
        the account has checked as many distinct channels as it may today (UTC; checking a channel it
        already checked today is free), or, on channels/add only, this list already holds as many added
        channels as it may (editing the contact of one already added is not refused for that). The
        channel was not checked.
      enum:
        - INVALID_LINK
        - NOT_TELEGRAM
        - PRIVATE_LINK
        - CHANNEL_NOT_FOUND
        - TOO_FEW_SUBSCRIBERS
        - NO_POSTS
        - CHANNEL_INACTIVE
        - CHANNEL_UNAVAILABLE
        - ALREADY_ADDED
        - CONTACT_REQUIRED
        - INVALID_CONTACT
        - UNSUPPORTED_CONTACT_KIND
        - CONTACT_IS_CHANNEL
        - MATCH_EXPIRED
        - CHECK_UNAVAILABLE
        - SUBMISSION_LIMIT_REACHED
    UserChannelError:
      type: object
      required: [field, code, message]
      properties:
        field:
          $ref: '#/components/schemas/UserChannelErrorField'
        code:
          $ref: '#/components/schemas/UserChannelErrorCode'
        message:
          type: string
          description: English prose for a human. Branch on code, never on this — the wording is free to change
    UserChannelResponse:
      type: object
      required: [valid, managerContact, errors]
      properties:
        valid:
          type: boolean
          description: True only when errors is empty. channels/add adds exactly the channels this is true for (or, for one it added before, stores the new contact)
        channel:
          $ref: '#/components/schemas/UserChannelInfo'
        managerContact:
          $ref: '#/components/schemas/UserChannelContactView'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/UserChannelError'
    UserChannelCollectionState:
      type: string
      description: >-
        Where our channel database is with an added channel. UNKNOWN - not known and not queued (e.g. the
        add's request to collect it failed). QUEUED - waiting to be collected. COLLECTING - its posts are
        being read. VECTORIZING - collected, being indexed for look-alike search. READY - collected and
        indexed: it can be a seed of similar-channels. NOT_VECTORIZABLE - collected, but it cannot be
        indexed, so it cannot be a seed. INACCESSIBLE - the channel turned out unreachable or banned,
        or it is excluded from buying.
        UNAVAILABLE - the state could not be read right now; poll again. Once READY or NOT_VECTORIZABLE,
        channel carries the measured title, subscribers and avgReach, and the list holds them too.
      enum: [UNKNOWN, QUEUED, COLLECTING, VECTORIZING, READY, NOT_VECTORIZABLE, INACCESSIBLE, UNAVAILABLE]
    UserChannelCollectedChannel:
      type: object
      required: [channelId, username, title, subscribers]
      properties:
        channelId:
          type: integer
          format: int64
        username:
          type: string
        title:
          type: string
        subscribers:
          type: integer
          format: int64
        avgReach:
          type: integer
          format: int64
          description: Absent until the channel has been collected and measured
    UserChannelCollectionResponse:
      type: object
      required: [state, channel]
      properties:
        state:
          $ref: '#/components/schemas/UserChannelCollectionState'
        channel:
          $ref: '#/components/schemas/UserChannelCollectedChannel'
    CreateCampaignRequest:
      type: object
      required:
        [idempotencyKey, matchToken, projectId, name, destinationUrl, adCopy, platform, distributionType, mediaAssetIds, budget, startsAt, endsAt, dailyDistribution, approvalMode]
      properties:
        idempotencyKey:
          type: string
          format: uuid
        matchToken:
          type: string
        projectId:
          type: string
          format: uuid
        name:
          type: string
          minLength: 1
          maxLength: 255
        destinationUrl:
          type: string
          minLength: 1
          maxLength: 2048
          description: >-
            Where the ad sends the reader. May carry placeholders that are substituted per placement, so the
            advertiser's own analytics can tell campaigns and channels apart under the parameter names his
            reporting already reads: `{campaign}` becomes the campaign's uuid, `{channel}` the handle of the
            channel that published the post (no `@`, lower case), `{platform}` the platform in lower case.
            They may sit anywhere in the URL, query string or path alike, including a bot deeplink payload —
            `https://t.me/mybot?start={campaign}-{channel}`. Names are case-insensitive and are stored
            lowercased, so the value returned by the API may differ from the one sent in exactly that way.
            Any OTHER `{name}` is rejected with a 400: an unknown placeholder cannot be filled in and would
            reach the published post as literal text. Deliberately NOT validated as a URI — a URL carrying a
            placeholder is not a well-formed one. Example:
            `https://mysite.com/landing?utm_campaign={campaign}&utm_source={channel}`
        adCopy:
          type: string
          maxLength: 8000
          description: >-
            The ad text as markup, written exactly as ValidateAdCopyRequest.text describes it. Create applies
            the very same rules POST /api/v1/campaigns/ad-copy/validate applies, so a text that validates is
            a text that creates and there is no second verdict to discover here. Two of those rules decide
            most refusals: the text must carry at least one {these words are the link}, and it must contain
            no web address at all beyond one on the advertiser's own domain — the destination is attached to
            the {...} per placement and never written into the text. A refusal is a 400 whose message lists
            every problem with the span of the markup it occupies. The cap here is a bound on request size,
            an order of magnitude above any text that could pass the 700-character visible limit.
        platform:
          $ref: '#/components/schemas/CampaignPlatform'
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
        distributionType:
          $ref: '#/components/schemas/CampaignDistributionType'
        mediaAssetIds:
          type: array
          minItems: 1
          maxItems: 3
          items:
            type: string
            format: uuid
        budget:
          $ref: '#/components/schemas/MoneyAmount'
        startsAt:
          type: string
          format: date
        endsAt:
          type: string
          format: date
        dailyDistribution:
          type: array
          items:
            $ref: '#/components/schemas/DailyDistributionItem'
        approvalMode:
          $ref: '#/components/schemas/ApprovalMode'
        creativeChangesAllowed:
          type: boolean
          nullable: true
          description: >-
            Whether a channel manager may propose his own wording of the ad text (a human then
            approves or rejects it). Absent defaults to true, so deals are not lost over trivial edits.
        excludedChannelIds:
          type: array
          nullable: true
          items:
            type: integer
            format: int64
          description: >-
            Channels from the previewed match set the advertiser struck off while approving the list. They
            become neither placements nor part of the queue behind them, so the campaign can never come
            back to one of them later. Ids that are not in the previewed set are ignored. Rejected with 400
            only if nothing at all is left after the strike-outs.
    PatchCampaignRequest:
      type: object
      properties:
        approvalMode:
          $ref: '#/components/schemas/ApprovalMode'
    Campaign:
      type: object
      required:
        [id, projectId, name, destinationUrl, adCopy, platform, distributionType, mediaAssets, budget, startsAt, endsAt, dailyDistribution, approvalMode, creativeChangesAllowed, status, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        projectId:
          type: string
          format: uuid
        name:
          type: string
        destinationUrl:
          type: string
          description: >-
            As stored, which is as it was sent except that the names of any known placeholders (see
            CreateCampaignRequest.destinationUrl) have been lowercased. Placeholders are never substituted
            here: what each one becomes depends on the placement, and this is the campaign.
        adCopy:
          type: string
        platform:
          $ref: '#/components/schemas/CampaignPlatform'
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
        distributionType:
          $ref: '#/components/schemas/CampaignDistributionType'
        mediaAssets:
          type: array
          items:
            $ref: '#/components/schemas/AssetRef'
        budget:
          $ref: '#/components/schemas/MoneyAmount'
        startsAt:
          type: string
          format: date
        endsAt:
          type: string
          format: date
        dailyDistribution:
          type: array
          items:
            $ref: '#/components/schemas/DailyDistributionItem'
        approvalMode:
          $ref: '#/components/schemas/ApprovalMode'
        creativeChangesAllowed:
          type: boolean
        status:
          $ref: '#/components/schemas/CampaignStatus'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    CampaignListItem:
      type: object
      required: [id, projectId, name, platform, budget, startsAt, endsAt, approvalMode, status, createdAt]
      properties:
        id:
          type: string
          format: uuid
        projectId:
          type: string
          format: uuid
        name:
          type: string
        platform:
          $ref: '#/components/schemas/CampaignPlatform'
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
        budget:
          $ref: '#/components/schemas/MoneyAmount'
        startsAt:
          type: string
          format: date
        endsAt:
          type: string
          format: date
        approvalMode:
          $ref: '#/components/schemas/ApprovalMode'
        status:
          $ref: '#/components/schemas/CampaignStatus'
        createdAt:
          type: string
          format: date-time
    CampaignListResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/CampaignListItem'
    SuggestDistributionRequest:
      type: object
      required: [budget, startsAt, endsAt]
      properties:
        budget:
          $ref: '#/components/schemas/MoneyAmount'
        startsAt:
          type: string
          format: date
        endsAt:
          type: string
          format: date
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
    SuggestDistributionDay:
      type: object
      required: [date, amount, recommendation]
      properties:
        date:
          type: string
          format: date
        amount:
          $ref: '#/components/schemas/MoneyAmount'
        recommendation:
          $ref: '#/components/schemas/DistributionRecommendation'
    SuggestDistributionInsight:
      type: object
      required: [text]
      properties:
        text:
          type: string
    SuggestDistributionResponse:
      type: object
      required: [days]
      properties:
        days:
          type: array
          items:
            $ref: '#/components/schemas/SuggestDistributionDay'
        insight:
          $ref: '#/components/schemas/SuggestDistributionInsight'
          nullable: true
    CampaignPlacement:
      type: object
      required: [id, campaignId, channelId, plannedDate, avgReach, approvalMode, status, originalAdCopy, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        campaignId:
          type: string
          format: uuid
        channelId:
          type: integer
          format: int64
        channelUsername:
          type: string
          nullable: true
        channelTitle:
          type: string
          nullable: true
        subscribers:
          type: integer
          format: int64
          nullable: true
          description: The channel's subscriber count as measured by the matching parser
        avgReach:
          type: integer
          format: int64
          description: Average views of an ordinary post on this channel
        avgAdReach24h:
          type: integer
          format: int64
          nullable: true
          description: >-
            Average views an ad post collects in its first 24h; null for channels with no measured ad
            posts
        expectedViews:
          type: integer
          format: int64
          nullable: true
          description: >-
            Views one ad post here is expected to reach — avgAdReach24h, or avgReach when it was never
            measured. The same number the campaign's expected-views total and the per-deal price cap are
            computed from. Null (never 0) when the channel carries no usable reach at all.
        views:
          type: integer
          format: int64
          nullable: true
          description: >-
            Views this placement's own published post actually collected, as the parser measured it — the
            measured counterpart of expectedViews, which is only a forecast from the channel's statistics.
            Null until the post is published and the first snapshot arrives; it then grows towards the
            figure reached ~48h after publication and stops there.
        reactions:
          type: integer
          format: int64
          nullable: true
          description: >-
            Reactions the same post collected, measured in the same snapshot. Null until the post is
            published and a snapshot arrives; there is no forecast counterpart for this one.
        clicks:
          type: integer
          format: int64
          nullable: true
          description: >-
            Times a link in this placement's own post was followed. Every link in a published post is one
            of ours, so this is measured directly rather than forecast — there is no counterpart to
            expectedViews here. Read next to views: views are the people the post reached, clicks are the
            ones who acted. Null (never 0) while nothing has been clicked.
        uniqueClicks:
          type: integer
          format: int64
          nullable: true
          description: >-
            The same clicks counted one per IP, so clicks minus uniqueClicks is the readers who came back.
            Null (never 0) while nothing has been clicked.
        plannedDate:
          type: string
          format: date
        managerUsername:
          type: string
          nullable: true
        approvalMode:
          $ref: '#/components/schemas/ApprovalMode'
        status:
          $ref: '#/components/schemas/CampaignPlacementStatus'
        agreedCost:
          $ref: '#/components/schemas/MoneyAmount'
          nullable: true
        agreedDate:
          type: string
          format: date
          nullable: true
        agreedSummary:
          type: string
          nullable: true
        rejectionReason:
          type: string
          nullable: true
        escalationReason:
          type: string
          nullable: true
        escalationUrgency:
          type: string
          nullable: true
        originalAdCopy:
          type: string
          description: The campaign's own ad copy, carried here so the card can show the diff on its own
        proposedAdCopy:
          type: string
          nullable: true
          description: >-
            The manager's variant awaiting a decision; set only while AWAITING_CREATIVE_APPROVAL. Ad-copy
            markup, exactly like originalAdCopy: he types plain text into Telegram and the backend writes
            his formatting back into our syntax, so both texts render in the same editor and the diff is
            between two texts of one kind.
        approvedAdCopy:
          type: string
          nullable: true
          description: >-
            The manager's variant once approved — the text actually published for this placement, in ad-copy
            markup like every other ad text
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        channelSource:
          $ref: '#/components/schemas/ChannelSource'
    CampaignQueuedChannel:
      type: object
      required: [channelId]
      properties:
        channelId:
          type: integer
          format: int64
        channelUsername:
          type: string
          nullable: true
        channelTitle:
          type: string
          nullable: true
        subscribers:
          type: integer
          format: int64
          nullable: true
          description: The channel's subscriber count as measured by the matching parser
        expectedViews:
          type: integer
          format: int64
          nullable: true
          description: >-
            Views one ad post here is expected to reach, resolved exactly as it is for a placement
            (avgAdReach24h, or avgReach when it was never measured) so the same channel never reads
            differently on the two screens. Null (never 0) when the channel carries no usable reach at all.
    CampaignPlacementListResponse:
      type: object
      required: [items, queued]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/CampaignPlacement'
        queued:
          type: array
          description: >-
            Channels this campaign will approach as its placement slots free up and the budget still allows
            — the ranked tail of the approved match set that did not fit the active-placement limit. The
            array order IS the order they will be taken in: the next freed slot goes to the first entry.
            None of them is guaranteed; the queue stops being consumed the moment the remaining budget no
            longer covers the next channel or the campaign's date window closes, and whatever is left then
            is simply never bought. Empty for a campaign whose queue has run out (or never had one).
          items:
            $ref: '#/components/schemas/CampaignQueuedChannel'
    CampaignPlacementMessage:
      type: object
      required: [id, role, text, messageAt]
      properties:
        id:
          type: string
        role:
          type: string
          enum: [manager, assistant]
        transportMessageId:
          type: string
          nullable: true
        text:
          type: string
        messageAt:
          type: string
          format: date-time
    CampaignPlacementMessagePage:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/CampaignPlacementMessage'
        nextCursor:
          type: string
          nullable: true
    Balance:
      type: object
      required: [currency, available, total, bonus, bonusSpentFirst]
      properties:
        currency:
          type: string
        available:
          $ref: '#/components/schemas/MoneyAmount'
        total:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          description: Свои деньги вместе с суммами возвратов на карту, которые ещё идут. Разница с available — в обработке возврата.
        bonus:
          $ref: '#/components/schemas/MoneyAmount'
        bonusSpentFirst:
          type: boolean
    CreditsInfo:
      type: object
      required: [platformFeePercent, providerFees]
      properties:
        platformFeePercent:
          allOf:
            - $ref: '#/components/schemas/DecimalValue'
          description: Platform fee on deposits, in percent (e.g. "5").
        providerFees:
          type: array
          description: Оценка комиссии каждого провайдера. Точную провайдер называет при оплате.
          items:
            $ref: '#/components/schemas/ProviderFee'
    ProviderFee:
      type: object
      required: [provider, currency, feePercent, feeFixed, vatPercent]
      properties:
        provider:
          $ref: '#/components/schemas/PaymentProvider'
        currency:
          $ref: '#/components/schemas/DepositCurrency'
        feePercent:
          allOf:
            - $ref: '#/components/schemas/DecimalValue'
          description: Процент от суммы депозита.
        feeFixed:
          allOf:
            - $ref: '#/components/schemas/DecimalValue'
          description: Фиксированная часть с каждой оплаты, в currency.
        vatPercent:
          allOf:
            - $ref: '#/components/schemas/DecimalValue'
          description: НДС внутри оплаты, в процентах; до нашей комиссии из оплаты вычитается сумма × ставка / (100 + ставка). Без НДС — "0".
    BalanceTransactionType:
      type: string
      enum:
        - DEPOSIT
        - CAMPAIGN_CHARGE
        - REFUND
        - PROMO_BONUS
        - PAYMENT_REFUND
    BalanceReferenceType:
      type: string
      enum:
        - PAYMENT
        - CAMPAIGN
        - PROMO
        - MANUAL
        - PAYMENT_REFUND
    BalanceTransaction:
      type: object
      required: [id, type, amount, balanceAfter, referenceType, referenceId, createdAt]
      properties:
        id:
          type: string
          format: uuid
        type:
          $ref: '#/components/schemas/BalanceTransactionType'
        amount:
          $ref: '#/components/schemas/MoneyAmount'
        balanceAfter:
          $ref: '#/components/schemas/MoneyAmount'
        referenceType:
          $ref: '#/components/schemas/BalanceReferenceType'
        referenceId:
          type: string
          format: uuid
        referenceName:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time
        projectId:
          type: string
          format: uuid
          nullable: true
        projectName:
          type: string
          nullable: true
        periodStart:
          type: string
          format: date
          nullable: true
        periodEnd:
          type: string
          format: date
          nullable: true
    BalanceTransactionPage:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/BalanceTransaction'
        nextCursor:
          type: string
          nullable: true
    RedeemPromoCodeRequest:
      type: object
      required: [code]
      properties:
        code:
          type: string
    RedeemPromoCodeResponse:
      type: object
      required: [bonusCreditsCredited, message]
      properties:
        bonusCreditsCredited:
          $ref: '#/components/schemas/MoneyAmount'
        message:
          type: string
    AutoTopupPauseReason:
      type: string
      enum:
        - CARD_DEAD
        - REQUIRES_ACTION
    SavedCard:
      type: object
      required: [provider, brand, last4, expMonth, expYear]
      properties:
        provider:
          $ref: '#/components/schemas/PaymentProvider'
        brand:
          type: string
        last4:
          type: string
        expMonth:
          type: integer
        expYear:
          type: integer
    AutoTopup:
      type: object
      description: Баланс ниже thresholdAmount — сохранённая карта доливает его до upperAmount. Суммы — в USD.
      required: [enabled, failedAttempts]
      properties:
        enabled:
          type: boolean
        thresholdAmount:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
        upperAmount:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
        maxChargesPerDay:
          type: integer
          nullable: true
        maxUsdPerDay:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
        pausedReason:
          allOf:
            - $ref: '#/components/schemas/AutoTopupPauseReason'
          nullable: true
        failedAttempts:
          type: integer
        retryAt:
          type: string
          format: date-time
          nullable: true
        card:
          allOf:
            - $ref: '#/components/schemas/SavedCard'
          nullable: true
        actionPaymentId:
          type: string
          format: uuid
          nullable: true
          description: Автосписание, которое ждёт подтверждения 3-D Secure
    UpdateAutoTopupRequest:
      type: object
      required: [enabled, thresholdAmount, upperAmount]
      properties:
        enabled:
          type: boolean
        thresholdAmount:
          $ref: '#/components/schemas/MoneyAmount'
        upperAmount:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          description: Больше thresholdAmount не меньше чем на 12 % и не меньше чем на минимальное пополнение.
        maxChargesPerDay:
          type: integer
          minimum: 1
          nullable: true
          description: Сколько успешных автопополнений за последние 24 часа. Пусто — без лимита.
        maxUsdPerDay:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
          description: >-
            На сколько долларов всего за последние 24 часа, не меньше минимального пополнения. Списание урезается
            до остатка этой суммы. Пусто — без лимита.
    CreateDepositRequest:
      type: object
      required: [amount, paymentProvider, currency]
      properties:
        amount:
          $ref: '#/components/schemas/MoneyAmount'
        paymentProvider:
          $ref: '#/components/schemas/PaymentProvider'
        currency:
          $ref: '#/components/schemas/DepositCurrency'
        network:
          allOf:
            - $ref: '#/components/schemas/PaymentNetwork'
          nullable: true
          description: Обязателен при XROCKET. У STRIPE и CLOUDPAYMENTS поля нет или null.
        saveCard:
          type: boolean
          nullable: true
          description: >-
            Сохранить карту этим пополнением для автопополнения. Только STRIPE и CLOUDPAYMENTS. Новая карта
            заменяет прежнюю.
    PaymentNetwork:
      type: string
      enum:
        - TON
        - BSC
        - ETH
        - BTC
        - TRX
        - SOL
    PaymentProvider:
      type: string
      enum:
        - XROCKET
        - STRIPE
        - CLOUDPAYMENTS
    PaymentMethod:
      type: string
      enum:
        - CRYPTO
        - CARD
    PaymentInitiator:
      type: string
      enum:
        - CUSTOMER
        - AUTO_TOPUP
    DepositCurrency:
      type: string
      enum:
        - USD
        - USDT
        - RUB
    PaymentStatus:
      type: string
      enum:
        - CREATING
        - PENDING
        - COMPLETED
        - FAILED
        - EXPIRED
    Payment:
      type: object
      required: [paymentId, provider, method, initiator, status, amountInCurrency, currency, documents, createdAt]
      properties:
        paymentId:
          type: string
          format: uuid
        provider:
          $ref: '#/components/schemas/PaymentProvider'
        method:
          $ref: '#/components/schemas/PaymentMethod'
        initiator:
          $ref: '#/components/schemas/PaymentInitiator'
        status:
          $ref: '#/components/schemas/PaymentStatus'
        amountInCurrency:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          description: Сколько просили, в валюте платежа (currency).
        currency:
          $ref: '#/components/schemas/DepositCurrency'
        realPaidInCurrency:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
          description: Сколько реально заплатили, в валюте платежа (currency).
        exchangeRate:
          allOf:
            - $ref: '#/components/schemas/DecimalValue'
          nullable: true
          description: Сколько единиц валюты платежа за 1 USD в момент зачисления. У USD и USDT — 1.
        realPaidUsd:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
          description: realPaidInCurrency в USD по exchangeRate.
        providerFeeUsd:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
          description: Комиссия провайдера, в USD.
        grossReceivedUsd:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
          description: realPaidUsd минус providerFeeUsd.
        serviceFeeUsd:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
          description: Комиссия платформы, в USD.
        nettoReceivedUsd:
          allOf:
            - $ref: '#/components/schemas/MoneyAmount'
          nullable: true
          description: Сколько USD зачислено на баланс. Есть только у завершённого платежа.
        externalId:
          type: string
          nullable: true
        transactionId:
          type: string
          nullable: true
        network:
          type: string
          nullable: true
          description: Только XROCKET.
        depositAddress:
          type: string
          nullable: true
          description: Только XROCKET.
        depositMinAmount:
          type: string
          nullable: true
          description: Только XROCKET.
        depositExpiresAt:
          type: string
          format: date-time
          nullable: true
          description: Только XROCKET.
        invoiceUrl:
          type: string
          nullable: true
          description: Только XROCKET.
        fromAddress:
          type: string
          nullable: true
          description: Только XROCKET.
        failureReason:
          type: string
          nullable: true
        documents:
          type: array
          description: >-
            Закрывающие документы, уже сохранённые у нас: у Stripe — счёт и чек, у CLOUDPAYMENTS — фискальный чек.
            Появляются через минуту-другую после оплаты. Ссылка живёт 15 минут; за свежей перечитайте платёж.
          items:
            $ref: '#/components/schemas/PaymentDocumentLink'
        createdAt:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
          nullable: true
    PaymentDocumentKind:
      type: string
      enum:
        - STRIPE_INVOICE
        - STRIPE_RECEIPT
        - FISCAL_RECEIPT
        - INVOICE_RU
        - ACT_RU
        - UPD_RU
    PaymentDocumentLink:
      type: object
      required: [kind, issuedAt, url]
      properties:
        kind:
          $ref: '#/components/schemas/PaymentDocumentKind'
        issuedAt:
          type: string
          format: date-time
        url:
          type: string
          description: Временная ссылка на PDF.
    PaymentCreateResponse:
      type: object
      required: [payment]
      properties:
        payment:
          $ref: '#/components/schemas/Payment'
        stripe:
          allOf:
            - $ref: '#/components/schemas/StripeBrowserSecret'
          nullable: true
        cloudPayments:
          allOf:
            - $ref: '#/components/schemas/CloudPaymentsWidget'
          nullable: true
    StripeBrowserSecret:
      type: object
      required: [clientSecret, publishableKey]
      properties:
        clientSecret:
          type: string
        publishableKey:
          type: string
    CloudPaymentsWidget:
      type: object
      description: Параметры виджета CloudPayments.
      required: [publicTerminalId, externalId, accountId, amount, currency, description, tokenize, receipt]
      properties:
        publicTerminalId:
          type: string
        externalId:
          type: string
          description: paymentId этого платежа.
        accountId:
          type: string
          description: Идентификатор пользователя.
        amount:
          $ref: '#/components/schemas/MoneyAmount'
        currency:
          $ref: '#/components/schemas/DepositCurrency'
        description:
          type: string
        tokenize:
          type: boolean
          description: Передать в виджет как есть — сохранить карту.
        receipt:
          $ref: '#/components/schemas/CloudPaymentsReceipt'
    CloudPaymentsReceipt:
      type: object
      description: Состав чека.
      required: [items, amounts]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/CloudPaymentsReceiptItem'
        amounts:
          $ref: '#/components/schemas/CloudPaymentsReceiptAmounts'
    CloudPaymentsReceiptItem:
      type: object
      required: [label, price, quantity, amount]
      properties:
        label:
          type: string
        price:
          type: number
          format: double
        quantity:
          type: number
          format: double
        amount:
          type: number
          format: double
        vat:
          type: integer
          nullable: true
          description: Ставка НДС. Поля нет — НДС не облагается.
    CloudPaymentsReceiptAmounts:
      type: object
      required: [electronic]
      properties:
        electronic:
          type: number
          format: double
    CloudPaymentsResult:
      type: object
      required: [code]
      properties:
        code:
          type: integer
          description: 0 — разрешено, 10 — неверный номер заказа.
    PaymentPage:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Payment'
        nextCursor:
          type: string
          nullable: true
    GeneratedAssetKind:
      type: string
      enum:
        - CAMPAIGN_MEDIA
    GeneratedImageAspectRatio:
      type: string
      enum:
        - SQUARE
        - PORTRAIT
    ImageGenerationStatus:
      type: string
      enum:
        - PENDING
        - GENERATING
        - SUCCEEDED
        - FAILED
    GenerateImageRequest:
      type: object
      required: [projectId, assetKind, aspectRatio, userNotes]
      properties:
        projectId:
          type: string
          format: uuid
        assetKind:
          $ref: '#/components/schemas/GeneratedAssetKind'
        aspectRatio:
          $ref: '#/components/schemas/GeneratedImageAspectRatio'
        userNotes:
          type: string
          minLength: 1
          maxLength: 2000
        destinationUrl:
          type: string
          nullable: true
          maxLength: 2048
          description: >-
            A hint about who the advertiser is, and nothing more: only the scheme, host and path reach the
            image model, never the query string. Placeholders (see CreateCampaignRequest.destinationUrl)
            cannot be substituted at this point — the image is generated before the campaign exists, so
            there is no campaign to name — and a URL still carrying one after the query string is cut is
            left out of the prompt altogether.
        objective:
          $ref: '#/components/schemas/CampaignObjective'
          nullable: true
        adCopy:
          type: string
          nullable: true
          maxLength: 4000
    ImageGenerationAcceptedResponse:
      type: object
      required: [requestId]
      properties:
        requestId:
          type: string
          format: uuid
    ImageGenerationStatusResponse:
      type: object
      required: [requestId, status]
      properties:
        requestId:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/ImageGenerationStatus'
        contentUrl:
          type: string
          nullable: true
        error:
          type: string
          nullable: true
    ApiKeyStatus:
      type: string
      enum:
        - ACTIVE
        - DISABLED
        - EXPIRED
    ApiKeyResetPeriod:
      type: string
      enum:
        - NO_LIMIT
        - DAILY
        - WEEKLY
        - MONTHLY
    ApiKeyExpiration:
      type: string
      enum:
        - NO_EXPIRATION
        - H_1
        - D_1
        - D_7
        - D_30
    ApiKey:
      type: object
      required: [id, name, prefix, status, resetPeriod, currentUsage, createdAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        prefix:
          type: string
        status:
          $ref: '#/components/schemas/ApiKeyStatus'
        creditLimit:
          $ref: '#/components/schemas/MoneyAmount'
          nullable: true
        resetPeriod:
          $ref: '#/components/schemas/ApiKeyResetPeriod'
        currentUsage:
          $ref: '#/components/schemas/MoneyAmount'
        expiresAt:
          type: string
          format: date-time
          nullable: true
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
    ApiKeyPage:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ApiKey'
        nextCursor:
          type: string
          nullable: true
    CreatedApiKey:
      type: object
      required: [apiKey, plainKey]
      properties:
        apiKey:
          $ref: '#/components/schemas/ApiKey'
        plainKey:
          type: string
          description: Full secret key shown only once at creation time.
    CreateApiKeyRequest:
      type: object
      required: [name, resetPeriod, expiration]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        creditLimit:
          $ref: '#/components/schemas/MoneyAmount'
          nullable: true
        resetPeriod:
          $ref: '#/components/schemas/ApiKeyResetPeriod'
        expiration:
          $ref: '#/components/schemas/ApiKeyExpiration'
    PatchApiKeyRequest:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        creditLimit:
          $ref: '#/components/schemas/MoneyAmount'
          nullable: true
        resetPeriod:
          $ref: '#/components/schemas/ApiKeyResetPeriod'
