# API автора

Author API — HTTP-интерфейс «Дизайн-кабака», через который вы создаёте, обновляете и публикуете свои статьи из скрипта, n8n, собственного продукта или AI-агента. Доступ ограничен вашими статьями: публичную ленту через этот API читать нельзя.

Вызывайте API с сервера: из backend-приложения, worker, cron-задачи или локального скрипта. Запрос из браузера на стороннем сайте — не поддерживаемый способ интеграции. Не передавайте секрет `dp_live_…` в браузерный JavaScript и не встраивайте его в мобильное приложение.

## Содержание

- [Быстрый старт](#быстрый-старт)
- [Авторизация и токены](#авторизация-и-токены)
- [Общие соглашения](#общие-соглашения)
- [Лимиты](#лимиты)
- [Жизненный цикл публикации](#жизненный-цикл-публикации)
- [Медиа](#медиа)
- [Тело статьи](#тело-статьи)
- [Справочник маршрутов](#справочник-маршрутов)
  - [`GET /me`](#get-me)
  - [`GET /posts`](#get-posts)
  - [`GET /posts/{id}`](#get-posts-id)
  - [`POST /posts`](#post-posts)
  - [`PATCH /posts/{id}`](#patch-posts-id)
  - [`POST /posts/{id}/publish`](#post-posts-id-publish)
  - [`POST /posts/{id}/unpublish`](#post-posts-id-unpublish)
  - [`DELETE /posts/{id}`](#delete-posts-id)
  - [`POST /media`](#post-media)
  - [`POST /media/from-url`](#post-media-from-url)
- [Полный сценарий](#полный-сценарий-черновик-с-изображением-и-публикация)
- [Ограничения и частые вопросы](#важные-ограничения-и-ответы-на-частые-вопросы)

## Быстрый старт

1. Откройте [настройки аккаунта](/settings) и создайте токен с понятным именем, например `n8n publishing`.
2. Скопируйте секрет сразу: он показывается один раз.
3. Сохраните секрет в переменной окружения или в хранилище секретов.
4. Сделайте первый запрос `GET /me`. Он проверит авторизацию и вернёт ваш handle и действующие лимиты.
5. Создайте статью через `POST /posts`, сохраните поле `id`, затем вызовите `POST /posts/{id}/publish`.

Примеры на Python используют пакет `requests`: установите его командой `python3 -m pip install requests`. Для JavaScript нужен Node.js 18+ с глобальным `fetch`.

### Первый запрос: `GET /me`

```bash tab=quickstart-me
TOKEN=dp_live_YOUR_TOKEN
BASE=https://designpub.ru/api/v1/author
curl -sS "$BASE/me" \
  -H "Authorization: Bearer $TOKEN"
```
```javascript tab=quickstart-me
const baseUrl = 'https://designpub.ru/api/v1/author';
const token = 'dp_live_YOUR_TOKEN';

const response = await fetch(`${baseUrl}/me`, {
  headers: { Authorization: `Bearer ${token}` },
});
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body);
```
```python tab=quickstart-me
import requests

BASE_URL = 'https://designpub.ru/api/v1/author'
TOKEN = 'dp_live_YOUR_TOKEN'

response = requests.get(
    f'{BASE_URL}/me',
    headers={'Authorization': f'Bearer {TOKEN}'},
)
response.raise_for_status()
print(response.json())
```

### Первый черновик и публикация

`POST /posts` всегда создаёт `draft`. После `publish` статья получает статус `published` или `pending_moderation`.

```bash tab=quickstart-publish
TOKEN=dp_live_YOUR_TOKEN
BASE=https://designpub.ru/api/v1/author

DRAFT_JSON=$(
  curl -sS -X POST "$BASE/posts" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"title":"Первая статья через API","markdown":"Текст статьи из интеграции.\n"}'
)
POST_ID=$(
  printf '%s' "$DRAFT_JSON" |
    python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])'
)
curl -sS -X POST "$BASE/posts/$POST_ID/publish" \
  -H "Authorization: Bearer $TOKEN"
```
```javascript tab=quickstart-publish
const baseUrl = 'https://designpub.ru/api/v1/author';
const token = 'dp_live_YOUR_TOKEN';
const headers = {
  Authorization: `Bearer ${token}`,
  'Content-Type': 'application/json',
};

const createResponse = await fetch(`${baseUrl}/posts`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    title: 'Первая статья через API',
    markdown: 'Текст статьи из интеграции.\n',
  }),
});
const draft = await createResponse.json();
if (!createResponse.ok) {
  throw new Error(`${createResponse.status}: ${JSON.stringify(draft)}`);
}

const publishResponse = await fetch(`${baseUrl}/posts/${draft.id}/publish`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}` },
});
const published = await publishResponse.json();
if (!publishResponse.ok) {
  throw new Error(`${publishResponse.status}: ${JSON.stringify(published)}`);
}
console.log(published);
```
```python tab=quickstart-publish
import requests

BASE_URL = 'https://designpub.ru/api/v1/author'
TOKEN = 'dp_live_YOUR_TOKEN'
auth = {'Authorization': f'Bearer {TOKEN}'}

create_response = requests.post(
    f'{BASE_URL}/posts',
    headers={**auth, 'Content-Type': 'application/json'},
    json={
        'title': 'Первая статья через API',
        'markdown': 'Текст статьи из интеграции.\n',
    },
)
create_response.raise_for_status()
draft = create_response.json()

publish_response = requests.post(
    f'{BASE_URL}/posts/{draft["id"]}/publish',
    headers=auth,
)
publish_response.raise_for_status()
print(publish_response.json())
```

## Авторизация и токены

Каждый маршрут под `https://designpub.ru/api/v1/author` требует заголовок:

```http
Authorization: Bearer dp_live_YOUR_TOKEN
```

Принимается только токен с префиксом `dp_live_`. Сессионная кука сайта и её JWT — не токен Author API: с ними сервер отвечает `401` и `code: "unauthorized"`.

### Где создать токен

Токены создаются, перевыпускаются и отзываются в [настройках аккаунта](/settings).

- Секрет показывается один раз. Потом в интерфейсе видны только `dp_live_` и последние четыре символа.
- На аккаунт можно иметь максимум пять активных токенов.
- У токена нет срока действия: он работает, пока вы его не отзовёте или не перевыпустите.
- Отзыв мгновенно ломает старый секрет.
- Перевыпуск выдаёт новый секрет для той же записи: `id` и `name` токена не меняются.
- Сброс пароля токены не отзывает. Удаление аккаунта отзывает все его токены.
- Лимиты считаются на аккаунт, а не на каждый токен в отдельности.

Храните секрет как пароль: не записывайте его в git, логи, URL, issue и в данные, доступные браузеру. Если секрет утёк, перевыпустите токен в настройках.

## Общие соглашения

### Базовый URL и формат

Относительный путь каждого маршрута ниже добавляется к:

```text
https://designpub.ru/api/v1/author
```

Например, `GET /posts` означает полный URL `https://designpub.ru/api/v1/author/posts`.

Запросы и ответы с JSON используют `Content-Type: application/json`. У `GET` тела нет. `POST /media` использует `multipart/form-data`, поэтому boundary выставляет HTTP-клиент. Ответы `204 No Content` не содержат тела.

Даты — ISO-8601 с часовым поясом, обычно UTC:

```text
2026-08-20T17:00:00Z
```

`public_id` и `id` — разные сущности. `id` у статей и медиафайлов — UUID в виде строки. `public_id` — отдельный короткий публичный идентификатор, который используется в `canonical_path` (добавляется в конце URL опубликованной страницы).

### Ресурс `AuthorPost`

Статья, возвращённая API, имеет следующие поля:

| Поле | Тип | Значение |
| --- | --- | --- |
| `id` | string (UUID) | Идентификатор статьи в маршрутах API. |
| `public_id` | string | Публичный короткий идентификатор. |
| `canonical_path` | string | Канонический путь публичной статьи, например `/my-article-a1b2c3d4e5f6`. |
| `title` | string | Заголовок, отдельное JSON-поле. |
| `subtitle` | string или `null` | Подзаголовок. |
| `slug` | string | Часть канонической ссылки до добавления `public_id`. |
| `status` | string | `draft`, `pending_moderation` или `published`. |
| `tags` | array | Объекты `{ "slug": "...", "name": "..." }`. |
| `cover_asset_id` | string (UUID) или `null` | Медиафайл обложки. |
| `cover_url` | string или `null` | Обычно `/api/media/{uuid}` для сохранённой обложки. |
| `cover_width` | integer или `null` | Ширина обложки в пикселях. |
| `cover_height` | integer или `null` | Высота обложки в пикселях. |
| `published_at` | string или `null` | Время текущей публикации. |
| `created_at` | string | Время создания статьи. |
| `updated_at` | string | Время последнего изменения рабочей версии. |
| `has_unpublished_changes` | boolean | Рабочая версия новее опубликованной. |
| `like_count` | integer | Число лайков. |
| `comment_count` | integer | Число комментариев. |
| `save_count` | integer | Число закладок. |
| `popularity` | integer | Индекс популярности. |
| `impression_count` | integer | Показы в ленте. |
| `open_count` | integer | Открытия статьи. |
| `read_count` | integer | Дочитывания до порога чтения. |
| `complete_count` | integer | Полные дочитывания. |
| `html`<span class="docs-field-note">только `GET /posts/{id}`</span> | string | HTML рабочей версии статьи. |
| `rehost_failed`<span class="docs-field-note">после записи, если есть ошибки</span> | array of strings | Внешние URL изображений, которые не удалось сохранить. Поле опускается, если массив пуст. |
| `warnings`<span class="docs-field-note">после записи, если есть предупреждения</span> | array of strings | Например, остановка рехоста из-за лимита. Поле опускается, если массив пуст. |

Пример полного ответа с деталями:

```json
{
  "id": "2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f",
  "public_id": "a1b2c3d4e5f6",
  "canonical_path": "/kak-peredat-statyu-a1b2c3d4e5f6",
  "title": "Как передать статью через API",
  "subtitle": "Рабочий пример",
  "slug": "kak-peredat-statyu",
  "status": "draft",
  "tags": [
    { "slug": "api", "name": "API" },
    { "slug": "automation", "name": "Автоматизация" }
  ],
  "cover_asset_id": null,
  "cover_url": null,
  "cover_width": null,
  "cover_height": null,
  "published_at": null,
  "created_at": "2026-08-20T17:00:00Z",
  "updated_at": "2026-08-20T17:00:00Z",
  "has_unpublished_changes": false,
  "like_count": 0,
  "comment_count": 0,
  "save_count": 0,
  "popularity": 0,
  "impression_count": 0,
  "open_count": 0,
  "read_count": 0,
  "complete_count": 0,
  "html": "<h1>Как передать статью через API</h1><p>Текст статьи.</p>",
  "rehost_failed": [
    "https://cdn.example.com/image-that-is-temporarily-unavailable.png"
  ],
  "warnings": [
    "Рехост остановился: бюджет 50 МиБ на запись"
  ]
}
```

В списке `html` никогда не возвращается. Пустые `rehost_failed` и `warnings` не приходят как пустые массивы: поля просто отсутствуют.

### Ошибки

Тело ошибки имеет форму `{ "error": "...", "code": "..." }`. Поле `code` необязательное: если для ошибки нет стабильного машинного кода, сервер его не отправляет.

| HTTP | `code` | Когда |
| --- | --- | --- |
| `401 Unauthorized` | `unauthorized` | Заголовок отсутствует, токен неверен или отозван, аккаунт удалён, либо вместо токена передан JWT сайта. |
| `400 Bad Request` | отсутствует | Невалидный JSON, обязательное поле не передано, пустой документ, неверный query-параметр или другая ошибка валидации. |
| `404 Not Found` | `not_found` | Статья или медиа не найдены. Чужая статья тоже выглядит как `not_found`. |
| `409 Conflict` | отсутствует | Запрошенный `slug` уже занят у вас. |
| `422 Unprocessable Entity` | зависит от маршрута | Для некоторых проверок медиа сервер различает тип содержимого, например `animated_image`. |
| `429 Too Many Requests` | `rate_limited` | Исчерпан минутный лимит или суточная квота медиа. |
| `503 Service Unavailable` | `service_unavailable` | Временно недоступна внутренняя зависимость API. |

Примеры:

```json
{
  "error": "Нужен токен API",
  "code": "unauthorized"
}
```

```json
{
  "error": "Нужен текст статьи"
}
```

```json
{
  "error": "У вас уже есть пост с такой ссылкой"
}
```

```json
{
  "error": "Слишком много запросов. Подождите немного",
  "code": "rate_limited"
}
```

На ответе `429` смотрите заголовки:

| Заголовок | Значение |
| --- | --- |
| `Retry-After` | Сколько секунд подождать перед новой попыткой. |
| `RateLimit-Limit` | Размер лимита в текущем окне. |
| `RateLimit-Remaining` | Сколько запросов осталось в текущем окне. |
| `RateLimit-Reset` | Unix-время сброса окна. |

Минутные лимиты работают фиксированным окном в 60 секунд: счётчик обнуляется целиком в начале следующего окна, а не пополняется постепенно.

После сетевого таймаута не повторяйте изменяющий запрос вслепую: сначала проверьте статью через `GET /posts/{id}`. Заголовок `Idempotency-Key` не поддерживается.

## Лимиты

Лимиты считаются на аккаунт, не на каждый токен. Текущие значения и расход суточной квоты возвращает `GET /me`.

| Действия | Ограничение |
| --- | --- |
| Чтение<span class="docs-field-note">`GET /me`,<br>`GET /posts`,<br>`GET /posts/{id}`</span> | 60/мин |
| Запись<span class="docs-field-note">`POST /posts`,<br>`PATCH /posts/{id}`,<br>`POST /posts/{id}/unpublish`,<br>`DELETE /posts/{id}`</span> | 30/мин |
| Публикация<span class="docs-field-note">`POST /posts/{id}/publish`</span> | 1/мин |
| Медиа<span class="docs-field-note">`POST /media`,<br>`POST /media/from-url`</span> | 10/мин<br>200 МиБ в UTC-день |

`POST /posts` и `PATCH /posts/{id}` расходуют лимит записи, а каждое загруженное внешнее изображение или обложка — дополнительно лимит медиа и суточную квоту. На одну запись действуют ещё два ограничения:

- не больше 100 внешних URL изображений;
- не больше 50 МиБ.

Если минутный лимит исчерпан, запрос получает `429` с `code: "rate_limited"` и заголовками сброса. Суточная квота медиа обнуляется в полночь UTC.

При рехосте во время записи API останавливает только рехост: черновик всё равно сохраняется, необработанный URL остаётся внешним, а причина попадает в `rehost_failed` или `warnings`.

## Жизненный цикл публикации

Статья проходит такие состояния:

```text
draft
  └─ POST /posts/{id}/publish
       ├─ published
       └─ pending_moderation
```

- `POST /posts` всегда создаёт `draft`.
- После `POST /posts/{id}/publish` статья получает `published` или `pending_moderation`. Второй статус означает, что статья ушла на модерацию; следите за результатом через `GET /posts/{id}`.
- `PATCH /posts/{id}` никогда не публикует статью. Для опубликованной статьи он меняет рабочую версию.
- `has_unpublished_changes: true` означает, что рабочая версия новее опубликованной. После успешного `publish` поле станет `false`.
- `POST /posts/{id}/unpublish` снимает публикацию и оставляет рабочий текст как черновик; ответ — `204`.
- `DELETE /posts/{id}` мягко удаляет статью. Восстановление через Author API не предусмотрено.

В каждом `AuthorPost` есть статистика, видимая только вам: `impression_count`, `open_count`, `read_count` и `complete_count`. Отдельного маршрута статистики нет, чужие статьи через этот API не видны.

## Медиа

Есть три способа добавить изображение в статью. Во всех случаях сохранённый у «Дизайн-кабака» файл доступен по пути `/api/media/{uuid}`.

Рехост — это когда API сам скачивает внешнюю картинку и сохраняет её копию у себя.

### Вариант A: внешние URL в тексте

Передайте обычный `https://` URL в Markdown или HTML:

```markdown
![Схема процесса](https://cdn.example.com/diagram.png)
```

При `POST /posts` или `PATCH /posts/{id}` API рехостит такое изображение и заменяет `src` на `/api/media/{uuid}`. Это расходует лимит медиа и суточную квоту. В одной записи разрешено максимум 100 внешних URL и 50 МиБ. Повторяющийся URL достаточно указать в тексте как есть — второй раз он не загружается.

Если источник не отвечает, файл слишком большой или загрузка не удалась, статья не теряется. Для изображения в тексте внешний `src` остаётся в сохранённом документе, а URL добавляется в `rehost_failed`. Если рехост остановился из-за минутного лимита или суточной квоты, причина дополнительно попадает в `warnings`. Для `cover_url` при неудаче обложка просто не создаётся.

Та же обработка применяется к `cover_url`:

```json
{
  "title": "Статья с внешней обложкой",
  "markdown": "Текст статьи.",
  "cover_url": "https://cdn.example.com/cover.jpg"
}
```

### Вариант B: загрузить локальный файл

Вызовите `POST /media` с multipart-полем `file`. Маршрут принимает изображения размером не больше 50 МиБ. В ответе вы получите `id` и `url`; вставьте `url` в Markdown или HTML:

```markdown
![Схема](/api/media/2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f)
```

В `cover_asset_id` передаётся именно `id`, а не полный `url`:

```json
{
  "title": "Статья с локальной обложкой",
  "markdown": "![Иллюстрация](/api/media/2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f)\n",
  "cover_asset_id": "2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f"
}
```

Ширину и высоту в запросе указывать не нужно: API берёт их из самого медиафайла, заполняет `cover_width` и `cover_height` и отклоняет неподходящий или чужой файл.

### Вариант C: заранее сохранить один URL

Когда нужно получить сохранённый медиафайл до сборки текста, вызовите `POST /media/from-url` с одним URL. Так интеграция сначала сохраняет картинки, а потом формирует Markdown.

### Обложка

Выберите один из вариантов:

- `cover_asset_id` — UUID уже загруженного изображения из `POST /media`;
- `cover_url` — внешний `https://` URL, который API рехостит при записи.

`cover_asset_id: null` в `PATCH /posts/{id}` очищает обложку. Ширина и высота отдельно не передаются.

### Что не поддерживается

- Локальный видеофайл нельзя загрузить через Author API.
- Прямой `mp4` или другой `video src` из внешнего URL не становится медиафайлом статьи: санитайзер удаляет такой `src`.
- Разрешены встраивания YouTube, Vimeo, VK, Rutube и других хостов из allowlist; это iframe, а не загрузка видео.
- Для GIF используйте сохранённый медиафайл `/api/media/{uuid}` и HTML-блок `data-gif`, описанный ниже.

## Тело статьи

### Запись и чтение используют разные форматы

На запись `POST /posts` и `PATCH /posts/{id}` принимают строковое поле `markdown`. Это GitHub Flavored Markdown (GFM) с HTML-островами для узлов, которых нет в обычном Markdown.

На чтение `GET /posts/{id}` возвращает строковое поле `html`.

`html` всегда отражает рабочую версию. Если после публикации вы сделали `PATCH`, ответ будет отличаться от того, что видит читатель, пока вы не вызвали `publish`.

HTML из `GET /posts/{id}` можно отправить обратно как значение `markdown`: конвертер сохраняет raw HTML перед санитизацией. Это удобно для round-trip, но не делает разрешённым произвольный HTML — неизвестные теги и атрибуты всё равно удаляются.

`title` — отдельное JSON-поле запроса, а не первый заголовок Markdown. Не дублируйте `title` первым `#` в теле: либо оставьте H1 API, либо напишите один `#` сами.

### Абзац

```markdown tab=body-paragraph
Один абзац текста с переносом строки.

Это уже второй абзац.
```
```html tab=body-paragraph
<p>Один абзац текста с переносом строки.</p>
<p>Это уже второй абзац.</p>
```

Пустая строка отделяет абзацы. Одинарный перенос внутри абзаца может быть нормализован.

### Заголовки `##`–`######`

```markdown tab=body-headings
## Раздел
### Подраздел
###### Самый глубокий уровень
```
```html tab=body-headings
<h2>Раздел</h2>
<h3>Подраздел</h3>
<h6>Самый глубокий уровень</h6>
```

В статье один H1. Если в Markdown нет `#`, API ставит H1 из `title` в начало. Если `#` один — его не трогает, где бы он ни стоял. Если `#` несколько, первый остаётся H1, остальные становятся H2. Для структуры разделов используйте `##`–`######`.

### Жирный, курсив, зачёркнутый текст и inline code

```markdown tab=body-inline
**жирный текст**, *курсив*, ~~зачёркнутый текст~~ и `inline code`.
```
```html tab=body-inline
<p><strong>жирный текст</strong>, <em>курсив</em>, <s>зачёркнутый текст</s> и <code>inline code</code>.</p>
```

Начертания можно комбинировать и вкладывать друг в друга.

### Ссылки

```markdown tab=body-links
Откройте [документацию API](https://designpub.ru/handbook/api).
```
```html tab=body-links
<p>Откройте <a href="https://designpub.ru/handbook/api">документацию API</a>.</p>
```

Используйте абсолютные `http://` или `https://` URL. Ссылки с опасными схемами и неподдерживаемыми атрибутами проходят санитизацию.

### Списки

```markdown tab=body-lists
- Первый пункт
- Второй пункт
  - Вложенный пункт

1. Шаг один
2. Шаг два
```
```html tab=body-lists
<ul>
  <li>Первый пункт</li>
  <li>Второй пункт
    <ul><li>Вложенный пункт</li></ul>
  </li>
</ul>
<ol>
  <li>Шаг один</li>
  <li>Шаг два</li>
</ol>
```

Нумерация в исходном Markdown может начинаться с любого числа, но в HTML приходит обычный `<ol>`.

### Цитата

```markdown tab=body-blockquote
> Цитата автора.
>
> Второй абзац внутри цитаты.
```
```html tab=body-blockquote
<blockquote>
  <p>Цитата автора.</p>
  <p>Второй абзац внутри цитаты.</p>
</blockquote>
```

### Блок кода

```markdown tab=body-code
~~~javascript
const answer = 42;
console.log(answer);
~~~
```
```html tab=body-code
<pre><code class="language-javascript">const answer = 42;
console.log(answer);
</code></pre>
```

Язык после открывающего fence сохраняется как подсказка для подсветки.

### Горизонтальная линия

```markdown tab=body-hr
Текст до линии.

---

Текст после линии.
```
```html tab=body-hr
<p>Текст до линии.</p>
<hr>
<p>Текст после линии.</p>
```

### Изображение

```markdown tab=body-image
![Схема процесса](https://cdn.example.com/diagram.png)
![Уже сохранённое изображение](/api/media/2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f)
```
```html tab=body-image
<img src="/api/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" alt="Схема процесса">
<img src="/api/media/2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f" alt="Уже сохранённое изображение">
```

Внешний `https://` URL API рехостит при записи. Путь `/api/media/{uuid}` уже локальный и повторно не загружается. `alt` сохраняется и нужен для доступности.

### Изображение с подписью

Markdown передаёт только `alt`. Для подписи используйте HTML-остров:

```markdown tab=body-figure
![График](https://cdn.example.com/chart.png)
```
```html tab=body-figure
<figure class="dp-figure">
  <img src="/api/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" alt="График">
  <figcaption class="dp-figure__caption">Подпись под графиком</figcaption>
</figure>
```

Классы `dp-figure` и `dp-figure__caption` обязательны. Внутри `figure` должен быть один основной `img` и, при необходимости, один `figcaption`.

### Layout: `full` и `mid`

Ширину одиночной картинки задаёт атрибут `data-layout` на `figure`. Разрешены только значения `full` и `mid`:

```html tab=body-layout
<figure class="dp-figure" data-layout="full">
  <img src="/api/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" alt="Широкая схема">
</figure>
<figure class="dp-figure" data-layout="mid">
  <img src="/api/media/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" alt="Схема средней ширины">
</figure>
```

Другие значения `data-layout` будут удалены.

### Галерея

Галерея — это `figure.dp-gallery` с контейнером `div.dp-gallery__items` и вложенными `figure.dp-figure`. Изображений может быть сколько угодно в пределах лимитов записи: 100 внешних URL и 50 МиБ на запрос. Подпись опциональна: без неё галерея тоже валидна. Если подпись нужна, она одна на всю галерею — `figcaption` сразу после контейнера с изображениями, не внутри каждого изображения.

```html tab=body-gallery
<figure class="dp-gallery" data-layout="full">
  <div class="dp-gallery__items">
    <figure class="dp-figure">
      <img src="/api/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" alt="Первое изображение">
    </figure>
    <figure class="dp-figure">
      <img src="/api/media/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" alt="Второе изображение">
    </figure>
  </div>
  <figcaption class="dp-figure__caption">Подпись ко всей галерее</figcaption>
</figure>
```

`data-layout="mid"` можно поставить на корневой `figure.dp-gallery`. Не вставляйте `img` напрямую рядом с `div.dp-gallery__items`. `figcaption` можно опустить. Если ставите — класс `dp-figure__caption` тот же, что у одиночной картинки; без него подпись не распознается. `figcaption` внутри изображения в галерее не становится подписью галереи.

### GFM-таблица

```markdown tab=body-table
| Инструмент | Назначение |
| --- | --- |
| API | Создание статьи |
| n8n | Автоматизация |
```
```html tab=body-table
<table>
  <tbody>
    <tr><th>Инструмент</th><th>Назначение</th></tr>
    <tr><td>API</td><td>Создание статьи</td></tr>
    <tr><td>n8n</td><td>Автоматизация</td></tr>
  </tbody>
</table>
```

Строка заголовка приходит в HTML первой строкой внутри `<tbody>`; отдельного `<thead>` не будет.

### Видео-встраивание

Видео передаётся только HTML-островом. Пример для YouTube:

```html tab=body-video
<div data-video data-provider="youtube" data-source="embed">
  <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe>
</div>
```

Так же оформляются Vimeo, VK и Rutube. Не меняйте `data-source="embed"` на путь к файлу и не подставляйте прямой `mp4` в `iframe` или `<video>`.

### Универсальный iframe и `dp-embed`

Для Figma и других разрешённых встраиваний используйте контейнер `dp-embed`:

```html tab=body-embed
<div class="dp-embed" data-dp-embed>
  <iframe
    src="https://www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com%2Fdesign%2Fexample"
    loading="lazy"
    allowfullscreen
    title="Figma design">
  </iframe>
</div>
```

Сервер разрешает только хосты из allowlist: Figma, YouTube, Vimeo, VK, Rutube и несколько специализированных embed-хостов. Iframe с неизвестного домена будет удалён. Одиночный `<iframe>` с разрешённым хостом сервер может обернуть в `dp-embed` сам, но контейнер лучше задавать явно.

### Link card

Карточка ссылки — HTML-only узел. Адрес храните в `href`, видимый текст — в этих классах:

```html tab=body-link-card
<a class="dp-link-card" href="https://example.com/article" target="_blank" rel="noopener noreferrer" data-dp-link-card>
  <div class="dp-link-card__body">
    <div class="dp-link-card__title">Title</div>
    <div class="dp-link-card__desc">Description</div>
    <div class="dp-link-card__domain">
      <img class="dp-link-card__favicon" src="/api/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" alt="" width="16" height="16">
      <span class="dp-link-card__domain-label">example.com</span>
    </div>
  </div>
  <div class="dp-link-card__media" style="background-image:url(/api/media/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb)" role="presentation"></div>
</a>
```

`img.dp-link-card__favicon` и `div.dp-link-card__media` необязательны. Если сохранённого медиафайла нет, уберите элемент целиком, а не оставляйте внешний адрес. Атрибуты `data-dp-link-card`, `target` и `rel` сохраняют поведение карточки.

### Спойлер

Используйте стандартные `details` и `summary`:

```markdown tab=body-spoiler
<details>
  <summary>Показать ответ</summary>
  <p>Скрытый текст появится после раскрытия.</p>
</details>
```
```html tab=body-spoiler
<details>
  <summary>Показать ответ</summary>
  <p>Скрытый текст появится после раскрытия.</p>
</details>
```

Внутри спойлера используйте обычные разрешённые узлы. JavaScript-обработчики удаляются санитизацией.

### GIF

GIF хранится как сохранённый медиафайл и доставляется через `<video>`:

```html tab=body-gif
<div class="dp-gif" data-gif="2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f">
  <video
    src="/api/media/2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f"
    autoplay
    loop
    muted
    playsinline>
  </video>
</div>
```

`src` должен быть относительным `/api/media/{uuid}`. Внешний GIF сначала сохраните через `POST /media/from-url` или загрузите файл через `POST /media`.

### Неизвестный HTML

Неизвестные теги, атрибуты, опасные схемы и неразрешённые iframe удаляются санитайзером:

```html tab=body-unknown-html
<marquee onclick="alert('removed')">Этот HTML не является узлом статьи.</marquee>
```

Если после конвертации документ оказался пуст, API отвечает `400 Bad Request` без `code`. Прямой `<video src="https://cdn.example.com/movie.mp4">` тоже не поддерживается: его `src` будет снят. Используйте embed-провайдера или сохранённый GIF.

## Справочник маршрутов

Все маршруты этого раздела требуют токен в заголовке `Authorization`. Если не указано иное, тело ошибки имеет формат `{ "error": "...", "code": "..." }`, а ответ `429` содержит заголовки сброса лимита.

### `GET /me`

Возвращает ваш handle и действующие лимиты. Вызывайте его в начале интеграции и перед большими медиа-операциями, чтобы знать расход `daily_media_bytes_used`.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** query-параметров и тела нет. `Content-Type` не требуется.

**Успех:** `200 OK`. Ответ:

```json
{
  "handle": "anton",
  "limits": {
    "read_per_min": 60,
    "write_per_min": 30,
    "publish_per_min": 1,
    "media_per_min": 10,
    "daily_media_bytes": 209715200,
    "daily_media_bytes_used": 0
  }
}
```

`daily_media_bytes` — суточная квота медиа в байтах, те же 200 МиБ. `daily_media_bytes_used` — израсходованные байты с начала текущего UTC-дня, а не процент.

**Ошибки:**

- `401 Unauthorized`, `code: "unauthorized"` — токен отсутствует, неверен, отозван или заменён перевыпуском.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит чтения.
- `503 Service Unavailable`, `code: "service_unavailable"` — временно недоступна зависимость API.

```bash tab=endpoint-me
TOKEN=dp_live_YOUR_TOKEN
curl -sS "https://designpub.ru/api/v1/author/me" \
  -H "Authorization: Bearer $TOKEN"
```
```javascript tab=endpoint-me
const response = await fetch('https://designpub.ru/api/v1/author/me', {
  headers: { Authorization: 'Bearer dp_live_YOUR_TOKEN' },
});
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body);
```
```python tab=endpoint-me
import requests

response = requests.get(
    'https://designpub.ru/api/v1/author/me',
    headers={'Authorization': 'Bearer dp_live_YOUR_TOKEN'},
)
response.raise_for_status()
print(response.json())
```

### `GET /posts`

Возвращает страницы ваших статей без поля `html`. Чужие и публичные статьи этим маршрутом не ищутся.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** query-параметры:

| Параметр | Значения | По умолчанию |
| --- | --- | --- |
| `status` | `draft`, `pending_moderation`, `published` | Все статусы |
| `limit` | Целое число, максимум 50 | 20 |
| `cursor` | Непрозрачная строка из `next_cursor` | Первая страница |

Тела нет. Сортировка идёт по `(created_at, id)` в порядке `DESC`, поэтому после `PATCH` статья не поднимается наверх. Курсор не декодируйте и не собирайте сами.

**Успех:** `200 OK`. У каждого элемента `items` тот же набор полей `AuthorPost`, кроме `html`; пустые `rehost_failed` и `warnings` опущены:

```json
{
  "items": [
    {
      "id": "2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f",
      "public_id": "a1b2c3d4e5f6",
      "canonical_path": "/kak-peredat-statyu-a1b2c3d4e5f6",
      "title": "Как передать статью через API",
      "subtitle": null,
      "slug": "kak-peredat-statyu",
      "status": "draft",
      "tags": [{ "slug": "api", "name": "API" }],
      "cover_asset_id": null,
      "cover_url": null,
      "cover_width": null,
      "cover_height": null,
      "published_at": null,
      "created_at": "2026-08-20T17:00:00Z",
      "updated_at": "2026-08-20T17:00:00Z",
      "has_unpublished_changes": false,
      "like_count": 0,
      "comment_count": 0,
      "save_count": 0,
      "popularity": 0,
      "impression_count": 0,
      "open_count": 0,
      "read_count": 0,
      "complete_count": 0
    }
  ],
  "next_cursor": "opaque-cursor-from-server"
}
```

Когда следующей страницы нет, `next_cursor` равен `null`.

**Ошибки:**

- `400 Bad Request` без `code` — неизвестное значение `status` или испорченный `cursor`.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит чтения.
- `503 Service Unavailable`, `code: "service_unavailable"` — временная ошибка сервиса.

```bash tab=list-posts
TOKEN=dp_live_YOUR_TOKEN
curl -sS --get "https://designpub.ru/api/v1/author/posts" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "status=published" \
  --data-urlencode "limit=20"
```
```javascript tab=list-posts
const url = new URL('https://designpub.ru/api/v1/author/posts');
url.searchParams.set('status', 'published');
url.searchParams.set('limit', '20');

const response = await fetch(url, {
  headers: { Authorization: 'Bearer dp_live_YOUR_TOKEN' },
});
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body);
```
```python tab=list-posts
import requests

response = requests.get(
    'https://designpub.ru/api/v1/author/posts',
    params={'status': 'published', 'limit': 20},
    headers={'Authorization': 'Bearer dp_live_YOUR_TOKEN'},
)
response.raise_for_status()
print(response.json())
```

Чтобы получить следующую страницу, передайте `next_cursor` как есть:

```bash
curl -sS --get "https://designpub.ru/api/v1/author/posts" \
  -H "Authorization: Bearer dp_live_YOUR_TOKEN" \
  --data-urlencode "cursor=opaque-cursor-from-server"
```

### `GET /posts/{id}`

Возвращает одну вашу статью вместе с рабочим HTML. Это основной маршрут, чтобы проверить результат записи, восстановить текст после сетевого таймаута и получить HTML для round-trip.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** в path передайте UUID статьи. Query-параметров и тела нет.

**Успех:** `200 OK`. В отличие от списка, ответ содержит `html`:

```json
{
  "id": "2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f",
  "public_id": "a1b2c3d4e5f6",
  "canonical_path": "/kak-peredat-statyu-a1b2c3d4e5f6",
  "title": "Как передать статью через API",
  "subtitle": "Рабочий пример",
  "slug": "kak-peredat-statyu",
  "status": "draft",
  "tags": [
    { "slug": "api", "name": "API" },
    { "slug": "automation", "name": "Автоматизация" }
  ],
  "cover_asset_id": null,
  "cover_url": null,
  "cover_width": null,
  "cover_height": null,
  "published_at": null,
  "created_at": "2026-08-20T17:00:00Z",
  "updated_at": "2026-08-20T17:00:00Z",
  "has_unpublished_changes": false,
  "like_count": 0,
  "comment_count": 0,
  "save_count": 0,
  "popularity": 0,
  "impression_count": 12,
  "open_count": 9,
  "read_count": 6,
  "complete_count": 4,
  "html": "<h1>Как передать статью через API</h1><p>Текст статьи.</p>"
}
```

`html` строится из рабочей версии: если статья опубликована, но после публикации был `PATCH`, вы увидите новый рабочий HTML.

**Ошибки:**

- `400 Bad Request` без `code` — path не является UUID.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `404 Not Found`, `code: "not_found"` — UUID не существует, статья удалена или принадлежит другому автору.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит чтения.
- `503 Service Unavailable`, `code: "service_unavailable"` — временно недоступна зависимость API.

```bash tab=get-post
TOKEN=dp_live_YOUR_TOKEN
POST_ID=2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f
curl -sS "https://designpub.ru/api/v1/author/posts/$POST_ID" \
  -H "Authorization: Bearer $TOKEN"
```
```javascript tab=get-post
const postId = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f';
const response = await fetch(
  `https://designpub.ru/api/v1/author/posts/${postId}`,
  { headers: { Authorization: 'Bearer dp_live_YOUR_TOKEN' } },
);
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body.html);
```
```python tab=get-post
import requests

post_id = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f'
response = requests.get(
    f'https://designpub.ru/api/v1/author/posts/{post_id}',
    headers={'Authorization': 'Bearer dp_live_YOUR_TOKEN'},
)
response.raise_for_status()
print(response.json()['html'])
```

### `POST /posts`

Создаёт новую статью как `draft`. Маршрут не публикует статью.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** `Content-Type: application/json`, query-параметров нет. Поля:

| Поле | Обязательность | Тип | Описание |
| --- | --- | --- | --- |
| `title` | обязательно | string | Непустой заголовок. |
| `markdown` | обязательно | string | Непустой документ: GFM и HTML-острова. |
| `subtitle` | нет | string или `null` | Подзаголовок. |
| `tags` | нет | array of strings | Названия тегов, например `["api", "automation"]`. Сервер находит существующие или создаёт новые по имени. |
| `slug` | нет | string | Явный slug. Если не передать, API выведет его из `title`. |
| `cover_asset_id` | нет | UUID или `null` | Уже загруженное изображение. Размеры берутся из медиафайла. |
| `cover_url` | нет | string или `null` | Внешний URL для рехоста обложки. |

Тело запроса:

```json
{
  "title": "Как передать статью через API",
  "markdown": "## Введение\n\nТекст статьи.\n\n![Схема](https://cdn.example.com/diagram.png)\n",
  "subtitle": "Рабочий пример",
  "tags": ["api", "automation"],
  "slug": "kak-peredat-statyu",
  "cover_asset_id": null,
  "cover_url": null
}
```

`title` и `markdown` не могут быть пустыми после trim. Занятый `slug` даёт `409`. Не отправляйте `cover_width` и `cover_height`: их вычисляет сервер.

**Успех:** `201 Created`, тело — `AuthorPost` со статусом `draft`. Поля `html` в ответе нет; если рехост прошёл частично, добавляются `rehost_failed` и `warnings`.

```json
{
  "id": "2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f",
  "public_id": "a1b2c3d4e5f6",
  "canonical_path": "/kak-peredat-statyu-a1b2c3d4e5f6",
  "title": "Как передать статью через API",
  "subtitle": "Рабочий пример",
  "slug": "kak-peredat-statyu",
  "status": "draft",
  "tags": [
    { "slug": "api", "name": "API" },
    { "slug": "automation", "name": "Автоматизация" }
  ],
  "cover_asset_id": null,
  "cover_url": null,
  "cover_width": null,
  "cover_height": null,
  "published_at": null,
  "created_at": "2026-08-20T17:00:00Z",
  "updated_at": "2026-08-20T17:00:00Z",
  "has_unpublished_changes": false,
  "like_count": 0,
  "comment_count": 0,
  "save_count": 0,
  "popularity": 0,
  "impression_count": 0,
  "open_count": 0,
  "read_count": 0,
  "complete_count": 0
}
```

**Ошибки:**

- `400 Bad Request` без `code` — нет `title` или `markdown`, поле пустое, slug некорректен, обложка не найдена либо документ стал пустым после конвертации.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `409 Conflict` без `code` — slug уже занят у этого автора.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит записи. Если во время записи закончился лимит медиа или суточная квота, рехост останавливается, но статья сохраняется: причина будет в `warnings`, а URL — в `rehost_failed`.
- `503 Service Unavailable`, `code: "service_unavailable"` — временно недоступна зависимость API.

```bash tab=create-draft
TOKEN=dp_live_YOUR_TOKEN
curl -sS -X POST "https://designpub.ru/api/v1/author/posts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Как передать статью через API",
    "markdown": "## Введение\n\nТекст статьи.\n\n![Схема](https://cdn.example.com/diagram.png)\n",
    "subtitle": "Рабочий пример",
    "tags": ["api", "automation"],
    "slug": "kak-peredat-statyu"
  }'
```
```javascript tab=create-draft
const response = await fetch('https://designpub.ru/api/v1/author/posts', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer dp_live_YOUR_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: 'Как передать статью через API',
    markdown: '## Введение\n\nТекст статьи.\n\n![Схема](https://cdn.example.com/diagram.png)\n',
    subtitle: 'Рабочий пример',
    tags: ['api', 'automation'],
    slug: 'kak-peredat-statyu',
  }),
});
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body.id);
```
```python tab=create-draft
import requests

response = requests.post(
    'https://designpub.ru/api/v1/author/posts',
    headers={
        'Authorization': 'Bearer dp_live_YOUR_TOKEN',
        'Content-Type': 'application/json',
    },
    json={
        'title': 'Как передать статью через API',
        'markdown': '## Введение\n\nТекст статьи.\n\n![Схема](https://cdn.example.com/diagram.png)\n',
        'subtitle': 'Рабочий пример',
        'tags': ['api', 'automation'],
        'slug': 'kak-peredat-statyu',
    },
)
response.raise_for_status()
print(response.json()['id'])
```

### `PATCH /posts/{id}`

Частично обновляет рабочую версию вашей статьи. Маршрут не публикует изменения.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** `Content-Type: application/json`, в path — UUID статьи. Query-параметров нет. Все поля тела необязательны, но передайте хотя бы одно:

```json
{
  "title": "Обновлённый заголовок",
  "markdown": "Новый текст.\n",
  "subtitle": null,
  "tags": ["api", "markdown"],
  "slug": "obnovlennyi-zagolovok",
  "cover_asset_id": null
}
```

Семантика полей:

- отсутствующее поле не меняется;
- `subtitle: null` очищает подзаголовок;
- `tags` заменяет текущий список тегов;
- `cover_asset_id: null` очищает обложку;
- новое значение `cover_asset_id` должно ссылаться на доступное вам изображение;
- `markdown` конвертируется заново; пустая строка отклоняется;
- `cover_url` запускает рехост внешнего URL.

**Успех:** `200 OK`, тело — обновлённый `AuthorPost` без `html`. Поля `rehost_failed` и `warnings` добавляются только при необходимости.

```json
{
  "id": "2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f",
  "public_id": "a1b2c3d4e5f6",
  "canonical_path": "/obnovlennyi-zagolovok-a1b2c3d4e5f6",
  "title": "Обновлённый заголовок",
  "subtitle": null,
  "slug": "obnovlennyi-zagolovok",
  "status": "draft",
  "tags": [
    { "slug": "api", "name": "API" },
    { "slug": "markdown", "name": "Markdown" }
  ],
  "cover_asset_id": null,
  "cover_url": null,
  "cover_width": null,
  "cover_height": null,
  "published_at": null,
  "created_at": "2026-08-20T17:00:00Z",
  "updated_at": "2026-08-20T17:10:00Z",
  "has_unpublished_changes": false,
  "like_count": 0,
  "comment_count": 0,
  "save_count": 0,
  "popularity": 0,
  "impression_count": 0,
  "open_count": 0,
  "read_count": 0,
  "complete_count": 0
}
```

После `PATCH` опубликованной статьи ждите `has_unpublished_changes: true` и вызывайте publish отдельно.

**Ошибки:**

- `400 Bad Request` без `code` — некорректный UUID, невалидное поле, пустой Markdown, отсутствующая обложка или пустой документ после конвертации.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `404 Not Found`, `code: "not_found"` — статья удалена, не существует или принадлежит другому автору.
- `409 Conflict` без `code` — новый slug уже занят.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит записи; рехост дополнительно расходует лимит медиа.
- `503 Service Unavailable`, `code: "service_unavailable"` — временно недоступна зависимость API.

```bash tab=patch-post
TOKEN=dp_live_YOUR_TOKEN
POST_ID=2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f
curl -sS -X PATCH "https://designpub.ru/api/v1/author/posts/$POST_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Обновлённый заголовок",
    "markdown": "Новый текст.\n",
    "subtitle": null,
    "tags": ["api", "markdown"],
    "cover_asset_id": null
  }'
```
```javascript tab=patch-post
const postId = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f';
const response = await fetch(
  `https://designpub.ru/api/v1/author/posts/${postId}`,
  {
    method: 'PATCH',
    headers: {
      Authorization: 'Bearer dp_live_YOUR_TOKEN',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: 'Обновлённый заголовок',
      markdown: 'Новый текст.\n',
      subtitle: null,
      tags: ['api', 'markdown'],
      cover_asset_id: null,
    }),
  },
);
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body);
```
```python tab=patch-post
import requests

post_id = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f'
response = requests.patch(
    f'https://designpub.ru/api/v1/author/posts/{post_id}',
    headers={
        'Authorization': 'Bearer dp_live_YOUR_TOKEN',
        'Content-Type': 'application/json',
    },
    json={
        'title': 'Обновлённый заголовок',
        'markdown': 'Новый текст.\n',
        'subtitle': None,
        'tags': ['api', 'markdown'],
        'cover_asset_id': None,
    },
)
response.raise_for_status()
print(response.json())
```

### `POST /posts/{id}/publish`

Отправляет рабочую версию статьи в публикацию. Статья должна принадлежать владельцу токена.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** в path — UUID статьи. Query-параметров и тела нет. `Content-Type` не требуется.

**Успех:** `200 OK`, тело — `AuthorPost` без `html`. В поле `status` придёт `published` с заполненным `published_at` либо `pending_moderation`:

```json
{
  "id": "2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f",
  "public_id": "a1b2c3d4e5f6",
  "canonical_path": "/kak-peredat-statyu-a1b2c3d4e5f6",
  "title": "Как передать статью через API",
  "subtitle": "Рабочий пример",
  "slug": "kak-peredat-statyu",
  "status": "pending_moderation",
  "tags": [{ "slug": "api", "name": "API" }],
  "cover_asset_id": null,
  "cover_url": null,
  "cover_width": null,
  "cover_height": null,
  "published_at": null,
  "created_at": "2026-08-20T17:00:00Z",
  "updated_at": "2026-08-20T17:10:00Z",
  "has_unpublished_changes": false,
  "like_count": 0,
  "comment_count": 0,
  "save_count": 0,
  "popularity": 0,
  "impression_count": 0,
  "open_count": 0,
  "read_count": 0,
  "complete_count": 0
}
```

**Ошибки:**

- `400 Bad Request` без `code` — статья не может быть опубликована в текущем состоянии или её рабочий документ невалиден.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `404 Not Found`, `code: "not_found"` — статья отсутствует или принадлежит другому автору.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит публикации: 1/мин.
- `503 Service Unavailable`, `code: "service_unavailable"` — временно недоступна зависимость API.

```bash tab=publish-post
TOKEN=dp_live_YOUR_TOKEN
POST_ID=2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f
curl -sS -X POST "https://designpub.ru/api/v1/author/posts/$POST_ID/publish" \
  -H "Authorization: Bearer $TOKEN"
```
```javascript tab=publish-post
const postId = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f';
const response = await fetch(
  `https://designpub.ru/api/v1/author/posts/${postId}/publish`,
  {
    method: 'POST',
    headers: { Authorization: 'Bearer dp_live_YOUR_TOKEN' },
  },
);
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body.status);
```
```python tab=publish-post
import requests

post_id = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f'
response = requests.post(
    f'https://designpub.ru/api/v1/author/posts/{post_id}/publish',
    headers={'Authorization': 'Bearer dp_live_YOUR_TOKEN'},
)
response.raise_for_status()
print(response.json()['status'])
```

### `POST /posts/{id}/unpublish`

Снимает статью с публикации и оставляет её текст как черновик. Маршрут не удаляет статью.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** в path — UUID статьи. Query-параметров и тела нет.

**Успех:** `204 No Content`. Тело пустое; не вызывайте `response.json()` без проверки статуса.

**Ошибки:**

- `400 Bad Request` без `code` — некорректный UUID или недопустимое состояние.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `404 Not Found`, `code: "not_found"` — статья отсутствует или принадлежит другому автору.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит записи.
- `503 Service Unavailable`, `code: "service_unavailable"` — временно недоступна зависимость API.

```bash tab=unpublish-post
TOKEN=dp_live_YOUR_TOKEN
POST_ID=2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f
curl -i -sS -X POST "https://designpub.ru/api/v1/author/posts/$POST_ID/unpublish" \
  -H "Authorization: Bearer $TOKEN"
```
```javascript tab=unpublish-post
const postId = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f';
const response = await fetch(
  `https://designpub.ru/api/v1/author/posts/${postId}/unpublish`,
  {
    method: 'POST',
    headers: { Authorization: 'Bearer dp_live_YOUR_TOKEN' },
  },
);
if (!response.ok) {
  const body = await response.json();
  throw new Error(`${response.status}: ${JSON.stringify(body)}`);
}
console.log(response.status);
```
```python tab=unpublish-post
import requests

post_id = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f'
response = requests.post(
    f'https://designpub.ru/api/v1/author/posts/{post_id}/unpublish',
    headers={'Authorization': 'Bearer dp_live_YOUR_TOKEN'},
)
response.raise_for_status()
print(response.status_code)
```

### `DELETE /posts/{id}`

Мягко удаляет вашу статью: она перестаёт возвращаться из Author API и с публичных страниц. Восстановление этим API не предусмотрено.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** в path — UUID статьи. Query-параметров и тела нет.

**Успех:** `204 No Content`, без тела.

**Ошибки:**

- `400 Bad Request` без `code` — некорректный UUID.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `404 Not Found`, `code: "not_found"` — статья отсутствует, уже удалена или принадлежит другому автору.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит записи.
- `503 Service Unavailable`, `code: "service_unavailable"` — временно недоступна зависимость API.

```bash tab=delete-post
TOKEN=dp_live_YOUR_TOKEN
POST_ID=2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f
curl -i -sS -X DELETE "https://designpub.ru/api/v1/author/posts/$POST_ID" \
  -H "Authorization: Bearer $TOKEN"
```
```javascript tab=delete-post
const postId = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f';
const response = await fetch(
  `https://designpub.ru/api/v1/author/posts/${postId}`,
  {
    method: 'DELETE',
    headers: { Authorization: 'Bearer dp_live_YOUR_TOKEN' },
  },
);
if (!response.ok) {
  const body = await response.json();
  throw new Error(`${response.status}: ${JSON.stringify(body)}`);
}
console.log(response.status);
```
```python tab=delete-post
import requests

post_id = '2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f'
response = requests.delete(
    f'https://designpub.ru/api/v1/author/posts/{post_id}',
    headers={'Authorization': 'Bearer dp_live_YOUR_TOKEN'},
)
response.raise_for_status()
print(response.status_code)
```

### `POST /media`

Загружает локальное изображение и создаёт принадлежащий вам медиафайл. Это единственный маршрут Author API для загрузки файла; видео он не принимает.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** `multipart/form-data`, query-параметров нет. Передайте поле `file` с непустым изображением не больше 50 МиБ. В `curl` используйте `-F`; в Node `fetch` не задавайте `Content-Type` вручную, иначе сломается multipart boundary.

**Успех:** `200 OK`. `url` можно вставить в Markdown, а `id` — в `cover_asset_id`:

```json
{
  "id": "2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f",
  "url": "/api/media/2f1c9a0e-4b3d-4e8a-9c11-0a7b6d5e4c3f",
  "kind": "image",
  "mime_type": "image/jpeg",
  "byte_size": 245760,
  "width": 1600,
  "height": 900,
  "status": "ready",
  "poster_url": null
}
```

Для изображения `kind` равен `image`, `status` — `ready`, `poster_url` — всегда `null`.

**Ошибки:**

- `400 Bad Request` без `code` — поле `file` отсутствует, файл пуст, больше 50 МиБ, повреждён или имеет неподдерживаемый формат.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `422 Unprocessable Entity`, `code: "animated_image"` — файл распознан как анимированное изображение, которое этот маршрут не принимает.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит медиа или суточная квота медиа.
- `503 Service Unavailable`, `code: "service_unavailable"` — временно недоступно хранилище.

```bash tab=upload-media
TOKEN=dp_live_YOUR_TOKEN
curl -sS -X POST "https://designpub.ru/api/v1/author/media" \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@./cover.jpg"
```
```javascript tab=upload-media
import { readFile } from 'node:fs/promises';

const bytes = await readFile('./cover.jpg');
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/jpeg' }), 'cover.jpg');

const response = await fetch('https://designpub.ru/api/v1/author/media', {
  method: 'POST',
  headers: { Authorization: 'Bearer dp_live_YOUR_TOKEN' },
  body: form,
});
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body);
```
```python tab=upload-media
import requests

with open('./cover.jpg', 'rb') as image_file:
    response = requests.post(
        'https://designpub.ru/api/v1/author/media',
        headers={'Authorization': 'Bearer dp_live_YOUR_TOKEN'},
        files={'file': ('cover.jpg', image_file, 'image/jpeg')},
    )
response.raise_for_status()
print(response.json())
```

### `POST /media/from-url`

Скачивает один внешний URL и создаёт принадлежащий вам медиафайл. Используйте маршрут, когда нужно получить UUID заранее и проверить результат до сборки статьи.

**Авторизация:** `Authorization: Bearer dp_live_YOUR_TOKEN`.

**Запрос:** `Content-Type: application/json`, query-параметров нет:

```json
{
  "url": "https://cdn.example.com/diagram.png"
}
```

Принимается только HTTP(S)-URL; локальные и внутренние адреса запрещены. Уже сохранённый `/api/media/{uuid}` отправлять повторно не нужно. Прямой URL на видеофайл маршрут не принимает.

**Успех:** `200 OK`:

```json
{
  "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  "url": "/api/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  "kind": "image",
  "mime_type": "image/png",
  "byte_size": 184320,
  "width": 1200,
  "height": 800,
  "status": "ready",
  "poster_url": null
}
```

**Ошибки:**

- `400 Bad Request` без `code` — `url` отсутствует или пуст, схема не HTTP(S), адрес запрещён, это уже локальный путь медиа либо содержимое не является поддерживаемым изображением.
- `401 Unauthorized`, `code: "unauthorized"` — проблема с токеном.
- `429 Too Many Requests`, `code: "rate_limited"` — исчерпан лимит медиа или суточная квота медиа.
- `503 Service Unavailable`, `code: "service_unavailable"` — хранилище или загрузка временно недоступны.

```bash tab=media-from-url
TOKEN=dp_live_YOUR_TOKEN
curl -sS -X POST "https://designpub.ru/api/v1/author/media/from-url" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://cdn.example.com/diagram.png"}'
```
```javascript tab=media-from-url
const response = await fetch(
  'https://designpub.ru/api/v1/author/media/from-url',
  {
    method: 'POST',
    headers: {
      Authorization: 'Bearer dp_live_YOUR_TOKEN',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ url: 'https://cdn.example.com/diagram.png' }),
  },
);
const body = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(body)}`);
console.log(body.url);
```
```python tab=media-from-url
import requests

response = requests.post(
    'https://designpub.ru/api/v1/author/media/from-url',
    headers={
        'Authorization': 'Bearer dp_live_YOUR_TOKEN',
        'Content-Type': 'application/json',
    },
    json={'url': 'https://cdn.example.com/diagram.png'},
)
response.raise_for_status()
print(response.json()['url'])
```

## Полный сценарий: черновик с изображением и публикация

Надёжная последовательность из четырёх шагов:

1. Загрузите локальное изображение через `POST /media` или сохраните внешнее через `POST /media/from-url`.
2. Возьмите из ответа `url`, например `/api/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa`.
3. Вставьте путь в `markdown` и создайте статью через `POST /posts`.
4. Проверьте `GET /posts/{id}` и вызовите `POST /posts/{id}/publish`.

Ниже один сценарий на curl, Node.js 18+ и Python: загрузить `cover.jpg`, создать черновик и опубликовать. Ширину и высоту отправлять не нужно, а в `cover_asset_id` передаётся `id` из ответа медиа.

```bash tab=end-to-end
TOKEN=dp_live_YOUR_TOKEN
BASE=https://designpub.ru/api/v1/author

MEDIA_JSON=$(
  curl -sS -X POST "$BASE/media" \
    -H "Authorization: Bearer $TOKEN" \
    -F "file=@./cover.jpg"
)
MEDIA_ID=$(
  printf '%s' "$MEDIA_JSON" |
    python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])'
)
MEDIA_URL=$(
  printf '%s' "$MEDIA_JSON" |
    python3 -c 'import json, sys; print(json.load(sys.stdin)["url"])'
)

DRAFT_JSON=$(
  curl -sS -X POST "$BASE/posts" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$(python3 -c "import json; print(json.dumps({
      'title': 'Статья с локальным изображением',
      'markdown': '## Изображение\\n\\n![Обложка]($MEDIA_URL)\\n\\nТекст статьи.\\n',
      'cover_asset_id': '$MEDIA_ID',
      'tags': ['api'],
    }))")"
)
POST_ID=$(
  printf '%s' "$DRAFT_JSON" |
    python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])'
)

curl -sS "$BASE/posts/$POST_ID" -H "Authorization: Bearer $TOKEN"
curl -sS -X POST "$BASE/posts/$POST_ID/publish" \
  -H "Authorization: Bearer $TOKEN"
```
```javascript tab=end-to-end
import { readFile } from 'node:fs/promises';

const baseUrl = 'https://designpub.ru/api/v1/author';
const token = 'dp_live_YOUR_TOKEN';
const auth = { Authorization: `Bearer ${token}` };

const bytes = await readFile('./cover.jpg');
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/jpeg' }), 'cover.jpg');

const mediaResponse = await fetch(`${baseUrl}/media`, {
  method: 'POST',
  headers: auth,
  body: form,
});
const media = await mediaResponse.json();
if (!mediaResponse.ok) {
  throw new Error(`${mediaResponse.status}: ${JSON.stringify(media)}`);
}

const postResponse = await fetch(`${baseUrl}/posts`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: 'Статья с локальным изображением',
    markdown: `## Изображение\n\n![Обложка](${media.url})\n\nТекст статьи.\n`,
    cover_asset_id: media.id,
    tags: ['api'],
  }),
});
const draft = await postResponse.json();
if (!postResponse.ok) {
  throw new Error(`${postResponse.status}: ${JSON.stringify(draft)}`);
}

const detailResponse = await fetch(`${baseUrl}/posts/${draft.id}`, {
  headers: auth,
});
const detail = await detailResponse.json();
if (!detailResponse.ok) {
  throw new Error(`${detailResponse.status}: ${JSON.stringify(detail)}`);
}
console.log(detail.html);

const publishResponse = await fetch(`${baseUrl}/posts/${draft.id}/publish`, {
  method: 'POST',
  headers: auth,
});
const published = await publishResponse.json();
if (!publishResponse.ok) {
  throw new Error(`${publishResponse.status}: ${JSON.stringify(published)}`);
}
console.log(published.status);
```
```python tab=end-to-end
import requests

BASE_URL = 'https://designpub.ru/api/v1/author'
TOKEN = 'dp_live_YOUR_TOKEN'
auth = {'Authorization': f'Bearer {TOKEN}'}

with open('./cover.jpg', 'rb') as image_file:
    media_response = requests.post(
        f'{BASE_URL}/media',
        headers=auth,
        files={'file': ('cover.jpg', image_file, 'image/jpeg')},
    )
media_response.raise_for_status()
media = media_response.json()

post_response = requests.post(
    f'{BASE_URL}/posts',
    headers={**auth, 'Content-Type': 'application/json'},
    json={
        'title': 'Статья с локальным изображением',
        'markdown': f'## Изображение\n\n![Обложка]({media["url"]})\n\nТекст статьи.\n',
        'cover_asset_id': media['id'],
        'tags': ['api'],
    },
)
post_response.raise_for_status()
draft = post_response.json()

detail_response = requests.get(
    f'{BASE_URL}/posts/{draft["id"]}',
    headers=auth,
)
detail_response.raise_for_status()
print(detail_response.json()['html'])

publish_response = requests.post(
    f'{BASE_URL}/posts/{draft["id"]}/publish',
    headers=auth,
)
publish_response.raise_for_status()
print(publish_response.json()['status'])
```

Если последний ответ вернул `pending_moderation`, это ожидаемый результат, а не ошибка запроса.

## Важные ограничения и ответы на частые вопросы

### Есть ли webhooks?

Нет. Чтобы узнать результат модерации или публикации, периодически вызывайте `GET /posts` или `GET /posts/{id}` в пределах лимита чтения.

### Можно ли загрузить видеофайл?

Нет. YouTube, Vimeo, VK, Rutube и другие разрешённые сервисы подключаются HTML-встраиванием. GIF в статье — сохранённый медиафайл с блоком `data-gif`.

### Есть ли OAuth-приложения?

Нет. Используйте токен `dp_live_…`, созданный в [настройках](/settings). OAuth-клиентов и scope в Author API нет.

### Почему после PATCH публичная статья ещё старая?

`PATCH` меняет рабочую версию, но не публикует её. Проверьте `has_unpublished_changes` и вызовите `POST /posts/{id}/publish`. Публикация может уйти в `pending_moderation`.

### Почему внешний iframe или mp4 исчез?

Санитайзер оставляет только разрешённые HTML-узлы и хосты. Прямой `<video src="https://...mp4">` и iframe с неизвестного домена удаляются. Разрешённое встраивание оформляйте как `data-video` или `div.dp-embed`, а картинку — через рехост внешнего URL или `/api/media/{uuid}`.
