{
  "openapi": "3.1.0",
  "info": {
    "title": "Smmly API",
    "version": "1.0.0",
    "summary": "Автопостинг в российские соцсети: телеграм, ВКонтакте, MAX, X.",
    "description": "Внешний вход в очередь публикаций Smmly. Пост, положенный сюда, живёт как\nлюбой другой: его разносит тот же отправщик, ошибки и повторы общие с кабинетом.\n\nОпознание — ключ пользователя (`Authorization: Bearer smk_…` или `X-Api-Key`),\nключ заводится в кабинете на https://smmly.ru/keys. Кап — 60 запросов в минуту на ключ.\n\nДля ИИ-агентов у того же ключа есть MCP-канал (Streamable HTTP):\n`POST https://smmly.ru/api/mcp`. Он даёт девять инструментов постинга;\nинструкция по подключению — https://smmly.ru/mcp.",
    "contact": {
      "name": "Smmly",
      "url": "https://smmly.ru/mcp",
      "email": "hello@smmly.ru"
    },
    "termsOfService": "https://smmly.ru/terms"
  },
  "externalDocs": {
    "description": "Подключение ИИ-агента по MCP",
    "url": "https://smmly.ru/mcp"
  },
  "servers": [
    {
      "url": "https://smmly.ru"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "channels",
      "description": "Куда можно постить"
    },
    {
      "name": "posts",
      "description": "Постановка в очередь и статус"
    },
    {
      "name": "media",
      "description": "Вложения"
    }
  ],
  "paths": {
    "/api/v1/channels": {
      "get": {
        "tags": [
          "channels"
        ],
        "operationId": "listChannels",
        "summary": "Каналы, доступные этому ключу",
        "description": "С этого начинает любой клиент: узнаёт id, площадку и ярлыки каналов. Токенов и настроек площадок не отдаёт — ключ даёт право постить, а не читать секреты.",
        "parameters": [
          {
            "name": "all",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Вместе с выключенными каналами"
          }
        ],
        "responses": {
          "200": {
            "description": "Список каналов",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "channels": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Channel"
                      }
                    }
                  },
                  "required": [
                    "channels"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v1/posts": {
      "post": {
        "tags": [
          "posts"
        ],
        "operationId": "createPost",
        "summary": "Положить пост в очередь",
        "description": "Один вызов = один замысел на несколько каналов: адресация ярлыком (`tags`),\nплощадкой (`providers`) или списком id (`channelIds`) — нужно хотя бы одно.\n\nВалидация идёт по ВСЕМ выбранным площадкам до записи: не прошло по одной —\nне создаётся ничего (400 со списком в `issues`). С `partial: true` пост\nуходит в те каналы, что прошли, а отвергнутые возвращаются в `skipped`\nответа 201; если не прошёл никто — всё тот же 400.\n\nПара `source`+`externalKey` — защита от повтора: тот же ключ вернёт 200\nс `duplicate: true` и ничего не отправит. Кроны обязаны её задавать,\nиначе перезапуск опубликует пост дважды.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NewPost"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Повтор по паре source+externalKey — ничего не отправлено",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/PostGroup"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "duplicate": {
                          "type": "boolean",
                          "const": true
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "201": {
            "description": "Создан. С `partial: true` — возможно, не во все каналы: смотрите `skipped`",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/PostGroup"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "skipped": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          },
                          "description": "Только при `partial: true`: каналы, не прошедшие проверку, — они не получили НИЧЕГО"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Не прошла валидация",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "issues": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          },
                          "description": "Что именно не прошло, по площадкам"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "Нет действующей подписки — очередь закрыта. Черновик (`state: draft`) принимается и без неё.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Почта аккаунта не подтверждена",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "get": {
        "tags": [
          "posts"
        ],
        "operationId": "getPostGroup",
        "summary": "Что стало с замыслом",
        "description": "По строке на канал: состояние, время публикации, ссылка на опубликованное, ошибка.",
        "parameters": [
          {
            "name": "group",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "`groupId` из ответа на создание"
          }
        ],
        "responses": {
          "200": {
            "description": "Состояние построчно",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "groupId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "posts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PostRow"
                      }
                    }
                  },
                  "required": [
                    "groupId",
                    "posts"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v1/media": {
      "post": {
        "tags": [
          "media"
        ],
        "operationId": "uploadMedia",
        "summary": "Залить вложение",
        "description": "Нужен, когда публичной ссылки на файл нет. У кого ссылка есть — шлёт её\nпрямо в `mediaUrls` поста, тогда запрос один.\n\nПринимает multipart (поле `file`) или голое тело с `Content-Type` файла\nи именем в `X-File-Name`. Пределы: 10 МБ картинка, 50 МБ видео или pdf.\n\nЗагруженный и никуда не приложенный файл живёт сутки — приложить его\nк посту ЗАВТРА нельзя.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                },
                "required": [
                  "file"
                ]
              }
            },
            "application/octet-stream": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Загружено",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "media": {
                      "$ref": "#/components/schemas/MediaRef"
                    },
                    "url": {
                      "type": "string",
                      "format": "uri"
                    }
                  },
                  "required": [
                    "media"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "413": {
            "description": "Файл больше предела",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Ключ пользователя: `Authorization: Bearer smk_…`. Заводится на https://smmly.ru/keys"
      },
      "apiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "Тот же ключ отдельным заголовком"
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Кривой запрос",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Ключ не передан или недействителен",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Не найдено",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Больше 60 запросов в минуту на ключ",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string",
            "description": "Человекочитаемая причина отказа"
          }
        },
        "required": [
          "error"
        ]
      },
      "Provider": {
        "type": "string",
        "enum": [
          "telegram",
          "vk",
          "max",
          "x"
        ],
        "description": "Площадка канала"
      },
      "Channel": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "provider": {
            "$ref": "#/components/schemas/Provider"
          },
          "name": {
            "type": "string",
            "description": "Имя канала, как его видит владелец"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ярлыки — ими же адресуются посты. До шести на канал."
          },
          "disabled": {
            "type": "boolean"
          },
          "needsReauth": {
            "type": "boolean",
            "description": "Токен площадки протух, канал молчит до переподключения"
          }
        },
        "required": [
          "id",
          "provider",
          "name"
        ]
      },
      "MediaRef": {
        "type": "object",
        "description": "Блок из ответа `/api/v1/media` — кладётся в поле `media` поста как есть",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "image",
              "video",
              "document"
            ]
          },
          "rel": {
            "type": "string",
            "description": "Ключ файла в нашем хранилище"
          }
        },
        "required": [
          "type",
          "rel"
        ]
      },
      "Button": {
        "type": "object",
        "description": "Кнопка-ссылка под постом. Вешает только телеграм: ВК и MAX получают пост без кнопок.",
        "properties": {
          "text": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "description": "Схема http, https или tg"
          }
        },
        "required": [
          "text",
          "url"
        ]
      },
      "NewPost": {
        "type": "object",
        "description": "Куда постить — хотя бы одно из `channelIds`, `tags`, `providers`.",
        "properties": {
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ярлыки каналов. Основной способ адресации: подключённый завтра канал с тем же ярлыком подхватится без правки клиента."
          },
          "providers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Provider"
            },
            "description": "Все каналы этих площадок"
          },
          "channelIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Точный список id. Запасной путь: неизвестный или выключенный id отбивает весь запрос."
          },
          "content": {
            "description": "Строка на все площадки или объект с вариантами: `{ default, telegram, vk, max, x }`",
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                }
              }
            ]
          },
          "format": {
            "type": "string",
            "enum": [
              "plain",
              "html"
            ],
            "default": "plain",
            "description": "html понимают телеграм и MAX; ВК получит текст без тегов"
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "media": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MediaRef"
            },
            "description": "Уже загруженные файлы. Вместе с `mediaUrls` — не больше десяти."
          },
          "mediaUrls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            },
            "description": "Файлы по публичным ссылкам — заберём сами"
          },
          "buttons": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Button"
            },
            "maxItems": 8
          },
          "firstComment": {
            "type": [
              "string",
              "null"
            ],
            "description": "Второе сообщение ответом на пост. Уходит только туда, где площадка умеет ответ (телеграм)."
          },
          "partial": {
            "type": "boolean",
            "description": "Пустить пост в каналы, прошедшие проверку, вместо отказа по всему замыслу из-за одного несогласного. По умолчанию false: не проходит один — не уходит никуда. При true отвергнутые вернутся в поле skipped ответа 201."
          },
          "publishAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO 8601 СО СМЕЩЕНИЕМ ЗОНЫ. Без смещения время читается как UTC. Пусто или прошедшее — публикуем ближайшим тиком."
          },
          "state": {
            "type": "string",
            "enum": [
              "queued",
              "draft"
            ],
            "default": "queued",
            "description": "`draft` — положить, но не отправлять"
          },
          "source": {
            "type": "string",
            "maxLength": 64,
            "description": "Кто принёс пост. Задаётся только вместе с `externalKey`."
          },
          "externalKey": {
            "type": "string",
            "maxLength": 255,
            "description": "Ключ события у источника. Пара `source`+`externalKey` — защита от повтора."
          },
          "occurredAt": {
            "type": "string",
            "format": "date-time",
            "description": "Когда событие случилось у источника"
          },
          "payload": {
            "type": "object",
            "description": "Ляжет в журнал событий как есть"
          }
        },
        "required": [
          "content"
        ]
      },
      "PostGroup": {
        "type": "object",
        "properties": {
          "groupId": {
            "type": "string",
            "format": "uuid",
            "description": "Замысел целиком — им же спрашивают статус"
          },
          "posts": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "integer"
                },
                "channelId": {
                  "type": "integer"
                },
                "provider": {
                  "$ref": "#/components/schemas/Provider"
                },
                "name": {
                  "type": "string"
                },
                "state": {
                  "type": "string"
                }
              }
            }
          }
        },
        "required": [
          "groupId",
          "posts"
        ]
      },
      "PostRow": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "channelId": {
            "type": "integer"
          },
          "provider": {
            "$ref": "#/components/schemas/Provider"
          },
          "name": {
            "type": "string"
          },
          "state": {
            "type": "string",
            "enum": [
              "draft",
              "queued",
              "published",
              "failed",
              "canceled"
            ]
          },
          "publishAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "publishedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "externalUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "У ВК есть всегда, у телеграма — если канал публичный. У MAX и приватного канала может не быть вовсе."
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "attempts": {
            "type": "integer"
          }
        }
      }
    }
  }
}