openapi: 3.1.0

info:
  title: Doslano Integration API
  version: 1.0.0
  summary: Публичный API для интеграции (CRM и др.) с сервисом заказных писем doslano.
  description: |
    Публичный REST API doslano: отправка заказных писем одним запросом, проверка
    статуса и трек-номеров (наш РПО + Почты России), баланс, колбеки на финальный
    статус письма.

    **Авторизация** — API-ключ в заголовке `Authorization: Bearer dl_live_…`.
    Ключи и журнал активности — в ЛК, раздел «API». Ключ имеет набор прав (scopes)
    и (опционально) IP-allowlist.

    **Оплата** — только с баланса аккаунта (предоплата). Пополнение — в ЛК.

    **Колбеки** — необязательный `callback_url` указывается при отправке каждого
    письма; иначе используйте поллинг `GET /v1/letters/{id}`. Тело колбека
    подписывается HMAC-SHA256 секретом аккаунта (заголовок `X-Doslano-Signature`).

    Это дополнительный интерфейс доступа к тем же услугам; использование
    регулируется действующей офертой личного кабинета doslano.
  contact:
    name: doslano
    url: https://doslano.ru
  termsOfService: https://doslano.ru/terms-of-use
  license:
    name: Proprietary — doslano
    url: https://doslano.ru/terms-of-use

servers:
  - url: https://integration.doslano.ru
    description: Production

security:
  - apiKey: []

tags:
  - name: Letters
    description: Отправка писем, статусы, трек-номера.
  - name: Balance
    description: Баланс аккаунта.

paths:
  /v1/letters:
    post:
      operationId: createLetter
      tags: [Letters]
      summary: Отправить письмо
      description: |
        Создаёт и (по умолчанию) сразу оплачивает с баланса одно заказное письмо
        одним запросом. Опись формируется автоматически на нашей стороне.

        Требуется scope `letters:write`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateLetterRequest'
      responses:
        '201':
          description: Письмо принято. Тело — состояние письма (включая цену и результат промокода).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Letter'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '402': { $ref: '#/components/responses/InsufficientFunds' }
        '422': { $ref: '#/components/responses/ValidationError' }
    get:
      operationId: listLetters
      tags: [Letters]
      summary: Список писем
      description: >-
        Письма аккаунта (все каналы — и API, и личный кабинет; канал указан в
        поле `channel`). Требуется scope `letters:read`.
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/LetterStatus' } }
        - { name: created_from, in: query, schema: { type: string, format: date-time } }
        - { name: created_to, in: query, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: Страница писем.
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Letter' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/letters/{id}:
    get:
      operationId: getLetter
      tags: [Letters]
      summary: Статус письма
      description: Полное состояние письма с разбивкой по получателям и трек-номерами. Требуется scope `letters:read`.
      parameters:
        - $ref: '#/components/parameters/LetterId'
      responses:
        '200':
          description: Состояние письма.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Letter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/letters/{id}/recipients/{recipient_id}/tracking:
    get:
      operationId: getRecipientTracking
      tags: [Letters]
      summary: Трек-события получателя
      description: События трекинга Почты России по конкретному получателю (если трек-номер присвоен). Требуется scope `letters:read`.
      parameters:
        - $ref: '#/components/parameters/LetterId'
        - { name: recipient_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: События трекинга.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrackingInfo' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/balance:
    get:
      operationId: getBalance
      tags: [Balance]
      summary: Баланс аккаунта
      description: Текущий баланс в копейках. Требуется scope `balance:read`.
      responses:
        '200':
          description: Баланс.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Balance' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

webhooks:
  letterStatusChanged:
    post:
      operationId: onLetterStatusChanged
      security: []
      summary: Колбек на финальный статус
      description: |
        Если при отправке письма указан `callback_url`, мы шлём на него POST при
        достижении финального статуса — по каждому получателю
        (`recipient.sent` / `recipient.failed`) и агрегатно по письму
        (`letter.sent` / `letter.partial_failure` / `letter.failed` / `letter.cancelled`).

        **Подпись.** Заголовок `X-Doslano-Signature: t=<unix>,v1=<hex>` — где
        `v1 = HMAC_SHA256(secret, "<t>." + raw_body)`, `secret` — секрет аккаунта
        из ЛК (раздел «API»). Проверьте подпись и свежесть `t` (±5 мин).

        **Доставка.** At-least-once: ждём ответ `2xx`; иначе ретраим по
        нарастающему графику (несколько попыток в течение ~24 ч), затем помечаем
        как недоставленный (видно в ЛК, можно переотправить). Дедуп — по `id`
        события. Порядок не гарантируется — сверяйтесь со `status` в теле.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200':
          description: Принято (любой 2xx останавливает ретраи).

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: |
        API-ключ вида `dl_live_…` в заголовке `Authorization: Bearer …`.
        Права (scopes): `letters:write`, `letters:read`, `balance:read`.
        Ключ может быть ограничен IP-allowlist'ом (настраивается в ЛК).

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Ключ идемпотентности. Повтор с тем же ключом в течение TTL вернёт исходный результат, не создавая письмо повторно.
      schema: { type: string, maxLength: 255 }
    LetterId:
      name: id
      in: path
      required: true
      description: Идентификатор письма.
      schema: { type: string }

  responses:
    BadRequest:
      description: Некорректный запрос.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: Нет/неверный API-ключ, либо IP не в allowlist ключа.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: У ключа нет нужного scope.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: Ресурс не найден (или не принадлежит аккаунту).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    InsufficientFunds:
      description: Недостаточно средств на балансе (при `on_insufficient_funds=reject`).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    ValidationError:
      description: Ошибка валидации данных письма (адрес, файл, получатели и т.п.).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    CreateLetterRequest:
      type: object
      required: [recipients, content]
      properties:
        sender:
          $ref: '#/components/schemas/SenderInput'
          description: Отправитель. Если не указан — берётся отправитель по умолчанию из профиля аккаунта.
        recipients:
          type: array
          minItems: 1
          maxItems: 20
          items: { $ref: '#/components/schemas/RecipientInput' }
        content:
          $ref: '#/components/schemas/LetterContent'
        letter_class:
          $ref: '#/components/schemas/LetterClass'
        promo_code:
          type: string
          description: Промокод (опционально).
        on_promo_invalid:
          type: string
          enum: [ignore, reject]
          default: ignore
          description: |
            Что делать, если промокод не применился: `ignore` — отправить без
            скидки и вернуть пометку в `pricing.promo`; `reject` — отклонить (422).
        on_insufficient_funds:
          type: string
          enum: [reject, hold]
          default: reject
          description: |
            Что делать при нехватке средств: `reject` — отклонить (402), письмо не
            создаётся; `hold` — создать письмо в статусе `awaiting_payment`, оно
            оплатится автоматически при пополнении баланса.
        callback_url:
          type: string
          format: uri
          description: HTTPS-URL для колбеков по этому письму. Если не указан — используйте поллинг.
      additionalProperties: false

    LetterContent:
      type: object
      required: [main_file]
      description: Контент письма. Опись формируется автоматически.
      properties:
        main_file:
          $ref: '#/components/schemas/FileSource'
        attachments:
          type: array
          maxItems: 10
          items: { $ref: '#/components/schemas/FileSource' }
      additionalProperties: false

    FileSource:
      type: object
      description: |
        Источник PDF-файла. Укажите **ровно один** способ:
        - `base64` — содержимое файла в base64 (для небольших файлов);
        - `url` — HTTPS-ссылка, файл скачивается потоково (для крупных файлов,
          чтобы не передавать тело в base64).
        Лимиты по размеру/страницам — см. документацию; тип файла — только PDF.
      properties:
        base64:
          type: string
          contentEncoding: base64
          description: PDF в base64.
        url:
          type: string
          format: uri
          description: HTTPS-ссылка на PDF (скачиваем потоково).
        filename:
          type: string
          description: Имя файла (опционально, для отображения).
      additionalProperties: false

    SenderInput:
      type: object
      required: [name, address]
      properties:
        name: { type: string, description: ФИО или название отправителя. }
        address: { type: string, description: Адрес отправителя (строкой; нормализуется на нашей стороне). }
        email: { type: string, format: email }
        party_type:
          $ref: '#/components/schemas/PartyType'
        inn: { type: string, description: ИНН (для юр. лиц/ИП). }
      additionalProperties: false

    RecipientInput:
      type: object
      required: [name, address]
      properties:
        name: { type: string, description: ФИО или название получателя. }
        address: { type: string, description: Адрес получателя (строкой; нормализуется на нашей стороне). }
        party_type:
          $ref: '#/components/schemas/PartyType'
        inn: { type: string }
      additionalProperties: false

    PartyType:
      type: string
      enum: [individual, organization]
      description: Тип лица — физическое или юридическое.

    LetterClass:
      type: string
      enum: [registered]
      default: registered
      description: Тип отправления. Пока поддерживается только заказное (`registered`).

    Letter:
      type: object
      required: [id, channel, status, created_at, recipients, pricing]
      properties:
        id: { type: string }
        channel:
          type: string
          enum: [web, api]
          description: Канал создания письма — `api` (этот API) или `web` (личный кабинет).
        status: { $ref: '#/components/schemas/LetterStatus' }
        created_at: { type: string, format: date-time }
        sender: { $ref: '#/components/schemas/Party' }
        recipients:
          type: array
          items: { $ref: '#/components/schemas/Recipient' }
        pricing: { $ref: '#/components/schemas/Pricing' }
        payment: { $ref: '#/components/schemas/PaymentResult' }
        callback_url: { type: string, format: uri }

    Recipient:
      type: object
      required: [id, name, status]
      properties:
        id: { type: string }
        name: { type: string }
        address: { type: string }
        status: { $ref: '#/components/schemas/RecipientStatus' }
        shipment_number:
          type: string
          description: Наш трек-номер (РПО), присваивается сразу при создании.
        tracking_number:
          type: string
          description: Трек-номер Почты России (ШПИ). Появляется после регистрации отправления.
        price_minor:
          type: integer
          format: int64
          description: Стоимость отправления этому получателю, в копейках.
        error:
          type: string
          description: Причина ошибки (для `failed`).

    Party:
      type: object
      properties:
        name: { type: string }
        address: { type: string }
        party_type: { $ref: '#/components/schemas/PartyType' }
        inn: { type: string }

    Pricing:
      type: object
      required: [total_minor, currency]
      properties:
        total_minor:
          type: integer
          format: int64
          description: Итоговая списанная сумма, в копейках.
        currency: { type: string, enum: [RUB] }
        promo:
          $ref: '#/components/schemas/PromoResult'

    PromoResult:
      type: object
      description: Результат применения промокода (если передан).
      required: [code, applied]
      properties:
        code: { type: string }
        applied: { type: boolean, description: Применён ли промокод. }
        discount_minor:
          type: integer
          format: int64
          description: Размер скидки в копейках (если применён).
        message:
          type: string
          description: Пояснение, если промокод не применён (письмо при этом отправлено).

    PaymentResult:
      type: object
      required: [charged]
      properties:
        charged: { type: boolean, description: Списаны ли средства с баланса. }
        status:
          type: string
          enum: [paid, awaiting_payment]
          description: '`awaiting_payment` — при `on_insufficient_funds=hold` до пополнения.'

    Balance:
      type: object
      required: [balance_minor, currency]
      properties:
        balance_minor: { type: integer, format: int64, description: Баланс в копейках. }
        currency: { type: string, enum: [RUB] }

    LetterStatus:
      type: string
      enum: [forming, awaiting_payment, paid, sending, sent, partial_failure, error, cancelled]
      description: |
        Жизненный цикл письма. Финальные: `sent`, `partial_failure`, `error`, `cancelled`.

    RecipientStatus:
      type: string
      enum: [pending, queued, sending, sent, delivered, failed]
      description: |
        Статус отправления получателю. `sent` — присвоен трек-номер Почты;
        финальные: `delivered`, `failed`.

    TrackingInfo:
      type: object
      required: [recipient_id, events]
      properties:
        recipient_id: { type: string }
        shipment_number: { type: string }
        tracking_number: { type: string }
        events:
          type: array
          items:
            type: object
            properties:
              code: { type: string }
              name: { type: string }
              occurred_at: { type: string, format: date-time }
              location: { type: string }

    WebhookEvent:
      type: object
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          description: Уникальный идентификатор события (для дедупа).
        type:
          type: string
          enum:
            - letter.sent
            - letter.partial_failure
            - letter.failed
            - letter.cancelled
            - recipient.sent
            - recipient.failed
            - recipient.delivered
        created_at: { type: string, format: date-time }
        data:
          type: object
          required: [letter_id, status]
          properties:
            letter_id: { type: string }
            recipient_id: { type: string }
            status: { type: string }
            shipment_number: { type: string }
            tracking_number: { type: string }
            error: { type: string }

    Problem:
      type: object
      description: Ошибка в формате RFC 9457 (problem+json).
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code:
          type: string
          description: Машиночитаемый код ошибки (напр. `insufficient_funds`, `validation_failed`, `forbidden_scope`).
