# CONNECT API — документация для интеграции Версия документа: 1.0 · Дата: 2026-10-19 CONNECT — платформа омниканальной переписки: WhatsApp (в т.ч. WABA), Telegram, Viber, Instagram, VK, Avito, MAX. Внешняя система (CRM, helpdesk, бот) работает с диалогами и сообщениями через REST API и получает события через вебхуки. --- ## Содержание 1. [Подключение](#1-подключение) 2. [Аутентификация и права](#2-аутентификация-и-права) 3. [Ограничения и общие правила](#3-ограничения-и-общие-правила) 4. [Ошибки](#4-ошибки) 5. [Объекты и перечисления](#5-объекты-и-перечисления) 6. [Справочник методов](#6-справочник-методов) 7. [Вебхуки](#7-вебхуки) 8. [Типовые сценарии](#8-типовые-сценарии) 9. [Ограничения платформы](#9-ограничения-платформы) 10. [Чек-лист подключения](#10-чек-лист-подключения) --- ## 1. Подключение Базовый адрес: `https://api.aritin-connect.ru` Все публичные методы имеют префикс `/v1`: `https://api.aritin-connect.ru/v1/...`. Другие пути вам не нужны. Требования к клиенту: HTTPS, JSON (`Content-Type: application/json`), UTF-8. Прокси и балансировщик должны передавать заголовки `Authorization` и `Idempotency-Key`. **Ключ доступа выдаёт администратор организации** в личном кабинете в разделе «Интеграции» → «API-ключи». Ключ показывается один раз. Формат: `cnk_` + 43 символа. Пример: `cnk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`. --- ## 2. Аутентификация и права Ключ передаётся одним из двух способов (эквивалентны): ```http Authorization: Bearer cnk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` ```http X-Api-Key: cnk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` Каждый ключ принадлежит **интеграции** (`api_client`) внутри организации (`tenant`). Данные других организаций недоступны: все запросы автоматически ограничены тенантом ключа. ### Области прав (scopes) | Scope | Что разрешает | |---|---| | `channels:read` | Просмотр каналов | | `contacts:read` | Просмотр контактов, проверка собеседника (lookup) | | `conversations:read` | Просмотр диалогов | | `conversations:write` | Создание диалогов | | `messages:read` | Чтение сообщений | | `messages:write` | Отправка сообщений и приём входящих | | `attachments:read` | Скачивание файлов и получение ссылок | | `attachments:write` | Загрузка и удаление файлов | | `stats:read` | Просмотр статистики | | `webhooks:read` | Просмотр подписок на вебхуки | | `webhooks:write` | Создание/изменение/удаление подписок, тестовая доставка | Если ключу выданы все права, вместо перечисления можно использовать `*`. > Области прав фиксируются при создании ключа. Если раньше вы получили ключ > «со всеми правами», но в списке появились новые области > (`attachments:read` / `attachments:write`), попросите выдать их — либо > используйте ключ с `*`. - `401` — ключ отсутствует, отозван, просрочен или неверен. - `403` — у ключа нет нужного scope. ### Проверка ключа ```http GET /v1/me Authorization: Bearer <ключ> ``` ```json { "client_id": "6a3b1f2c-9d55-4c8e-8f31-0a1b2c3d4e5f", "client_name": "amoCRM", "key_id": "b1c2d3e4-5f60-4712-8934-56789abcdef0", "key_name": "production", "tenant_id": "00000000-0000-0000-0000-000000000000", "scopes": ["channels:read", "contacts:read", "conversations:read", "messages:read"] } ``` Поле `tenant_id` — служебное (идентификатор вашей организации); для работы с API его использовать не нужно. --- ## 3. Ограничения и общие правила | Параметр | Значение | |---|---| | Лимит запросов | **600 запросов в минуту на ключ** | | Ответ при превышении | `429` + заголовок `Retry-After: 60` | | Пагинация | `limit` (по умолчанию 50, максимум 200) и `offset` | | Таймаут обработки на нашей стороне | 30 с | | Идемпотентность | заголовок `Idempotency-Key` или поле `idempotency_key` | ### Заголовки | Заголовок | Направление | Назначение | |---|---|---| | `Authorization` / `X-Api-Key` | запрос | ключ доступа | | `Idempotency-Key` | запрос | защита от дублей (рекомендуется для POST) | | `X-Correlation-Id` | запрос/ответ | сквозной идентификатор запроса; если не передан, генерируется | | `Content-Type` | запрос | `application/json` | Все ответы содержат `X-Correlation-Id` — указывайте его при обращении в поддержку. ### Формат страницы Все списочные методы возвращают: ```json { "items": [], "total": 0, "limit": 50, "offset": 0 } ``` --- ## 4. Ошибки Формат: ```json { "detail": "Человекочитаемое описание" } ``` `detail` может быть строкой или массивом (ошибки валидации FastAPI). | Код | Когда | Что делать | |---|---|---| | `400` | Некорректный запрос | Исправить запрос | | `401` | Нет/неверен/отозван ключ | Проверить ключ | | `403` | Нет scope или объект чужой организации | Проверить права | | `404` | Объект не найден | Проверить id | | `409` | Конфликт (канал отключён, объект занят) | Показать причину оператору | | `422` | Ошибка валидации тела | Исправить поля | | `429` | Превышен лимит | Подождать `Retry-After` | | `500` | Внутренняя ошибка | Повторить позже, сообщить `X-Correlation-Id` | | `502` / `503` | Временная недоступность | Повторить с паузой (экспоненциально) | --- ## 5. Объекты и перечисления ### Канал (`channel`) ```json { "id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "tenant_id": "00000000-0000-0000-0000-000000000000", "provider": "default", "channel_type": "whatsapp", "external_channel_id": "300a4d99-389d-49ba-a3f6-10a3e0965427", "name": "WhatsApp продажи", "status": "active", "is_default": true, "settings": {}, "created_at": "2026-10-01T08:00:00Z", "updated_at": "2026-10-01T08:00:00Z" } ``` `channel_type`: `whatsapp`, `whatsapp_waba`, `telegram`, `telegram_bot`, `viber`, `instagram`, `vk`, `avito`, `max`, `max_bot` (+ legacy `whatsapp_web`, `custom`). `provider` — служебное поле (для интеграции не нужно). `status`: `active` (работает), `inactive` (отключён — писать нельзя), `pending`, `error`. ### Контакт (`contact`) ```json { "id": "b191c476-0eda-4cc6-ad47-881e354364c0", "tenant_id": "00000000-0000-0000-0000-000000000000", "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "external_id": "79655385561", "name": "Иван", "username": null, "phone": "79655385561", "avatar_url": null, "is_blocked": false, "profile": {}, "first_seen_at": "2026-09-18T08:29:00Z", "last_seen_at": "2026-10-19T08:52:00Z", "created_at": "2026-09-18T08:29:00Z" } ``` `external_id` — идентификатор собеседника у мессенджера: для WhatsApp/Viber — номер цифрами, для Instagram/VK — ник, для Telegram — chat_id (или ник без `@`). ### Диалог (`conversation`) ```json { "id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "tenant_id": "00000000-0000-0000-0000-000000000000", "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "contact_id": "b191c476-0eda-4cc6-ad47-881e354364c0", "queue_id": null, "assigned_user_id": null, "status": "open", "subject": null, "tags": ["VIP"], "unread_count": 0, "last_message_at": "2026-10-19T08:52:22Z", "last_inbound_at": "2026-10-18T20:10:00Z", "closed_at": null, "created_at": "2026-09-18T08:29:00Z", "updated_at": "2026-10-19T08:52:22Z" } ``` `status`: `open`, `pending`, `closed`. `last_message_at` — **фактическое** время последнего сообщения в истории (по нему же отсортирован список). `unread_count` — число непрочитанных входящих. `GET /v1/conversations/{id}` дополнительно возвращает `contact` и `channel`. ### Сообщение (`message`) ```json { "id": "e3b55006-691e-4abb-b503-267ebd9206ff", "tenant_id": "00000000-0000-0000-0000-000000000000", "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "direction": "outbound", "message_type": "text", "text": "Здравствуйте!", "payload": {}, "external_message_id": "8e074add-828d-4f35-afcc-99ccbfa9d0ae", "status": "read", "error": null, "author_user_id": null, "sent_at": "2026-10-19T08:52:22Z", "delivered_at": "2026-10-19T08:52:23Z", "read_at": "2026-10-19T08:52:40Z", "created_at": "2026-10-19T08:52:22Z", "attachments": [] } ``` `direction`: `inbound`, `outbound`. `message_type`: `text`, `image`, `file`, `audio`, `video`, `location`, `contact`, `template`, `system`. `status`: | Статус | Значение | |---|---| | `received` | входящее принято | | `queued` | исходящее создано, ждёт отправки | | `sending` | отправляется | | `sent` | отправлено в мессенджер | | `delivered` | доставлено получателю | | `read` | прочитано получателем | | `failed` | не отправлено, причина в `error` | ### Вложение (`attachment`) ```json { "id": "0f6c8a51-8b0e-4e0c-9a1e-3f2b1a4c5d6e", "kind": "image", "file_name": "photo.jpg", "mime_type": "image/jpeg", "size_bytes": 14832, "external_url": "https://.../photo.jpg", "meta": {}, "created_at": "2026-10-19T08:52:22Z" } ``` В ответах `GET /v1/attachments/{id}` и `GET /v1/conversations/{id}/messages` вложения приходят в таком виде. Содержимое файла по `external_url` **не отдаётся**: файл лежит в приватном хранилище, скачивайте через `GET /v1/attachments/{id}/url` (временная ссылка на домен CONNECT) или `GET /v1/attachments/{id}/content` (байты через API). У вложений, пришедших из мессенджера, `external_url` — ссылка провайдера (чужой CDN); она доступна, пока её не удалит сам мессенджер. --- ## 6. Справочник методов ### 6.1 Каналы #### `GET /v1/channels` — список каналов Scope: `channels:read` Query: `status`, `channel_type`, `search`, `limit`, `offset`. ```bash curl -s https://api.aritin-connect.ru/v1/channels?status=active \ -H "Authorization: Bearer $TOKEN" ``` Канал, который нужно использовать, определяется его `id`. --- ### 6.2 Контакты #### `GET /v1/contacts` — список контактов Scope: `contacts:read` Query: `channel_id`, `search` (имя, ник, телефон, внешний id), `limit`, `offset`. --- ### 6.3 Диалоги #### `GET /v1/conversations` — список диалогов Scope: `conversations:read` Query: | Параметр | Описание | |---|---| | `status` | `open` / `pending` / `closed` | | `channel_id` | только по каналу | | `assigned_user_id` | только назначенные этому пользователю | | `queue_id` | только из очереди | | `unassigned` | `1` — только ничьи | | `limit`, `offset` | пагинация | Сортировка: по фактическому времени последнего сообщения (свежие сверху), затем по времени создания и `id` — порядок стабилен между запросами. #### `GET /v1/conversations/{conversation_id}` — диалог с контактом и каналом Scope: `conversations:read` #### `GET /v1/conversations/{conversation_id}/messages` — переписка Scope: `messages:read` Query: `search` (по тексту), `limit` (1–200, по умолчанию 50), `offset`, `before` (ISO-время: вернуть сообщения **старше** указанного — курсор для подгрузки истории вверх). Порядок: свежие сверху (по `created_at` DESC). ```bash # первые 50 curl -s "https://api.aritin-connect.ru/v1/conversations/$CONV/messages?limit=50" \ -H "Authorization: Bearer $TOKEN" # ещё 50 более старых (курсор = created_at самого старого из уже загруженных) curl -s "https://api.aritin-connect.ru/v1/conversations/$CONV/messages?limit=50&before=2026-10-13T11:51:52Z" \ -H "Authorization: Bearer $TOKEN" ``` > Для листания истории используйте `before`, а не `offset`: лента пополняется сверху, > и `offset` со временем указывает уже на другие сообщения. #### `GET /v1/conversations/lookup` — есть ли такой собеседник Scope: `contacts:read` Проверяет, существует ли контакт в этом канале, до создания диалога. Query: `channel_id` (обязателен) + хотя бы один из `external_id`, `username`, `phone`. ```json { "exists": true, "contact": { "...": "объект контакта или null" } } ``` Телефон сравнивается по цифрам (`+7 999 123-45-67` = `79991234567`), ник — без `@` и без учёта регистра. #### `POST /v1/conversations` — открыть диалог (создать при необходимости) Scope: `conversations:write` ```json { "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "contact_phone": "79655385561", "contact_name": "Иван" } ``` Поля: | Поле | Обязательно | Описание | |---|---|---| | `channel_id` | да | канал организации | | `contact_id` | нет | существующий контакт | | `contact_external_id` | нет | chat_id/внешний id у мессенджера | | `contact_username` | нет | ник без `@` | | `contact_phone` | нет | телефон в любом формате | | `contact_name` | нет | имя для нового контакта | | `subject` | нет | тема диалога | Нужен `contact_id` **или** хотя бы один из идентификаторов. Отсутствующий контакт создаётся автоматически. Повторный вызов для той же пары «канал + контакт» вернёт тот же диалог (не дубль). Ответ `201`: ```json { "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "contact_id": "b191c476-0eda-4cc6-ad47-881e354364c0", "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "status": "open" } ``` --- ### 6.4 Сообщения #### `POST /v1/conversations/{conversation_id}/messages` — отправить сообщение Scope: `messages:write` ```http POST /v1/conversations/a7f7517a-dc7d-4c59-91d9-e0b522c4dda7/messages Authorization: Bearer <ключ> Idempotency-Key: 6f1c9d7e-8a2b-4c3d-9e0f-1a2b3c4d5e6f Content-Type: application/json { "text": "Здравствуйте! Чем можем помочь?", "message_type": "text" } ``` Тело: | Поле | Обязательно | Описание | |---|---|---| | `text` | да* | текст, до 8000 символов | | `message_type` | нет | по умолчанию `text` | | `payload` | нет | дополнительные данные (шаблон, клавиатура) | | `attachment_ids` | нет | до 10 id файлов, загруженных через `POST /v1/attachments` | | `idempotency_key` | нет | альтернатива заголовку `Idempotency-Key` | | `author_user_id` | нет | внешний id сотрудника-автора | \* Для сообщений без текста используйте `payload` (например шаблон) и нужный `message_type`. Ответ `201`: ```json { "message_id": "e3b55006-691e-4abb-b503-267ebd9206ff", "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "status": "queued" } ``` **Асинхронность.** Ответ означает «принято в очередь», а не «доставлено». Дальнейшие статусы (`sent`, `delivered`, `read`, `failed`) приходят вебхуком `message.status.changed` или видны в `GET /v1/messages/{id}`. **Идемпотентность.** Повтор с тем же `Idempotency-Key` не создаёт второе сообщение — возвращается ранее созданное. `409` — канал отключён (`inactive`), отправка невозможна. #### `GET /v1/messages/{message_id}` — сообщение и его статусы Scope: `messages:read` ```json { "id": "e3b55006-691e-4abb-b503-267ebd9206ff", "status": "read", "sent_at": "2026-10-19T08:52:22Z", "delivered_at": "2026-10-19T08:52:23Z", "read_at": "2026-10-19T08:52:40Z", "error": null, "...": "..." } ``` #### `POST /v1/messages/inbound` — принять входящее из вашей системы Scope: `messages:write` Для «своих» каналов и импорта переписки: сообщение попадает в CONNECT как входящее и дальше живёт по общим правилам (диалог создаётся автоматически). ```json { "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "contact_external_id": "79655385561", "external_message_id": "your-crm-msg-12345", "contact_name": "Иван", "contact_phone": "79655385561", "message_type": "text", "text": "Здравствуйте!", "payload": {}, "attachments": [], "occurred_at": "2026-10-19T08:50:00Z" } ``` Дополнительно можно передавать `contact_username`, `contact_avatar_url`, `idempotency_key`. **Вложения** (`attachments[]`): ```json [ { "kind": "image", "file_name": "photo.jpg", "mime_type": "image/jpeg", "size_bytes": 14832, "external_url": "https://your-cdn/photo.jpg", "meta": {} } ] ``` `kind`: `image`, `video`, `audio`, `file`. Ссылка `external_url` должна быть доступна извне без авторизации хотя бы на время обработки. Ответ `201`: ```json { "accepted": true, "message_id": "f1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8", "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7" } ``` `accepted: false` — сообщение с таким `external_message_id` уже было принято раньше (повторная отправка безопасна). `403` — приём входящих отключён для вашей организации: обратитесь в поддержку. --- ### 6.5 Вложения Файл живёт в приватном хранилище CONNECT. Порядок работы: **загрузить** → получить `id` → **передать `attachment_ids` при отправке сообщения** (или **скачать** по ссылке). | Шаг | Метод | Scope | |---|---|---| | Загрузить файл | `POST /v1/attachments` | `attachments:write` | | Метаданные | `GET /v1/attachments/{id}` | `attachments:read` | | Временная ссылка | `GET /v1/attachments/{id}/url` | `attachments:read` | | Скачать байты | `GET /v1/attachments/{id}/content` | `attachments:read` | | Удалить незакреплённый | `DELETE /v1/attachments/{id}` | `attachments:write` | #### `POST /v1/attachments` — загрузить файл Scope: `attachments:write` ```http POST /v1/attachments Authorization: Bearer <ключ> Content-Type: multipart/form-data; boundary=... --boundary Content-Disposition: form-data; name="file"; filename="photo.jpg" Content-Type: image/jpeg <байты файла> --boundary-- ``` ```bash curl -s -X POST https://api.aritin-connect.ru/v1/attachments \ -H "Authorization: Bearer $TOKEN" \ -F "file=@photo.jpg" ``` Ответ `201`: ```json { "id": "0f6c8a51-8b0e-4e0c-9a1e-3f2b1a4c5d6e", "file_name": "photo.jpg", "mime_type": "image/jpeg", "size_bytes": 14832, "kind": "image", "created_at": "2026-10-19T08:52:22Z" } ``` | Код | Значение | |---|---| | `413` | файл больше допустимого размера (8 МБ) | | `422` | пустой файл | Ограничения: **до 8 МБ** на файл (ограничение nginx перед API), **до 10** файлов в одном сообщении. `kind` (`image`, `video`, `audio`, `file`) определяется по `Content-Type` автоматически. Незакреплённое вложение живёт 24 часа, затем удаляется автоматически — загружайте файл непосредственно перед отправкой. #### `GET /v1/attachments/{id}` — метаданные Scope: `attachments:read` ```json { "id": "0f6c8a51-8b0e-4e0c-9a1e-3f2b1a4c5d6e", "file_name": "photo.jpg", "mime_type": "image/jpeg", "size_bytes": 14832, "kind": "image", "created_at": "2026-10-19T08:52:22Z" } ``` #### `GET /v1/attachments/{id}/url` — временная ссылка Scope: `attachments:read` Query: `inline` (`true`/`false`, по умолчанию `false`). ```json { "id": "0f6c8a51-8b0e-4e0c-9a1e-3f2b1a4c5d6e", "url": "https://files.aritin-connect.ru/f/0f6c8a51-.../1792526407/CE8EO1ifSoymRcaZd92deFVDhdlNfMEJNZUpLBF4Iqc/photo.jpg?d=attachment", "expires_in": 86400, "file_name": "photo.jpg", "mime_type": "image/jpeg" } ``` Ссылка ведёт на домен CONNECT (`files.aritin-connect.ru`) и содержит подпись и срок — **не копируйте её в публичные места**. | Свойство | Значение | |---|---| | Срок жизни | `expires_in` секунд, по умолчанию **24 часа** | | Авторизация | не требуется — доступ даёт сама подпись в URL | | `Range` / `HEAD` | поддерживаются (видео и аудио перематываются) | | Имя в конце | последний сегмент (`photo.jpg`) — подсказка для типа файла | | Перевыпуск | просто запросите `/url` снова, ссылка будет новой | Для вложений, пришедших из мессенджера, `expires_in: 0` — отдаётся ссылка провайдера, её срок CONNECT не контролирует. > Ссылка — временный пропуск: любой, кто её получил, скачает файл до истечения > срока. Для длительного показа изображений на своём сайте перезапрашивайте > ссылку через API или проксируйте файл через свой сервер. #### `GET /v1/attachments/{id}/content` — байты файла Scope: `attachments:read` Query: `inline` (`true`/`false`). Ответ — содержимое файла с `Content-Type` и `Content-Disposition`. Адрес хранилища наружу не раскрывается. Опасные для показа типы (HTML, SVG, XML) всегда отдаются как `attachment`. #### `DELETE /v1/attachments/{id}` — удалить Scope: `attachments:write` Только **незакреплённое** вложение. Если файл уже отправлен в сообщении — `409`. --- ### 6.6 Статистика #### `GET /v1/stats` — сводка Scope: `stats:read` Query: `channel_id` (необязательно). ```json { "channels_total": 3, "channels_active": 3, "contacts_total": 26, "conversations_open": 10, "conversations_pending": 1, "conversations_closed": 15, "messages_inbound_today": 7, "messages_outbound_today": 17, "messages_failed_today": 0 } ``` --- ### 6.7 Вебхуки #### `POST /v1/webhook-endpoints` — создать подписку Scope: `webhooks:write` ```json { "url": "https://crm.example.com/connect/webhook", "events": [ "message.inbound.received", "message.status.changed", "conversation.created" ], "description": "Приём событий CONNECT", "headers": { "X-Team": "support" } } ``` | Поле | Обязательно | Описание | |---|---|---| | `url` | да | HTTPS-адрес, куда слать события | | `events` | нет | пусто/не передано = **все** события | | `description` | нет | комментарий | | `headers` | нет | дополнительные заголовки в каждом запросе | Ответ `201` — **единственный раз** возвращает `secret` (ключ подписи): ```json { "id": "5f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a", "url": "https://crm.example.com/connect/webhook", "events": ["message.inbound.received", "message.status.changed", "conversation.created"], "description": "Приём событий CONNECT", "headers": {}, "is_active": true, "last_success_at": null, "last_failure_at": null, "failure_count": 0, "created_at": "2026-10-19T09:00:00Z", "secret": "whsec_9f8e7d6c5b4a439281706f5e4d3c2b1a" } ``` Сохраните `secret` — позже его получить нельзя. Он нужен для проверки подписи. #### `GET /v1/webhook-endpoints` — список подписок Scope: `webhooks:read`. Query: `limit`, `offset`. Секреты не отдаются. #### `GET /v1/webhook-endpoints/{id}` — одна подписка Scope: `webhooks:read`. #### `PATCH /v1/webhook-endpoints/{id}` — изменить Scope: `webhooks:write`. Любые из полей: `url`, `events`, `description`, `headers`, `is_active`. #### `DELETE /v1/webhook-endpoints/{id}` — удалить Scope: `webhooks:write`. Ответ `204`. #### `POST /v1/webhook-endpoints/{id}/test` — тестовая доставка Scope: `webhooks:write`. Ставит в очередь событие `webhook.test` (уходит обычным механизмом — с подписью и ретраями, в течение нескольких секунд). ```json { "queued": true, "event_id": "9a1b2c3d4e5f60718293a4b5c6d7e8f9", "endpoint_id": "5f8e..." } ``` #### `GET /v1/webhook-endpoints/event-types` — справочник событий Scope: `webhooks:read`. ```json [ { "value": "conversation.created", "label": "Создан диалог" }, { "value": "conversation.assigned", "label": "Диалог назначен оператору" }, { "value": "message.inbound.received", "label": "Получено входящее сообщение" }, { "value": "message.outbound.queued", "label": "Исходящее сообщение поставлено в очередь" }, { "value": "message.status.changed", "label": "Изменён статус доставки сообщения" } ] ``` --- ## 7. Вебхуки ### 7.1 Доставка Событие отправляется `POST`-запросом на ваш `url` с телом JSON и заголовками: ```http POST /connect/webhook HTTP/1.1 Content-Type: application/json User-Agent: CONNECT-Webhooks/1.0 X-Connect-Event: message.inbound.received X-Connect-Event-Id: 6f1c9d7e8a2b4c3d9e0f1a2b3c4d5e6f X-Connect-Timestamp: 1760863140 X-Connect-Signature: t=1760863140,v1=5b2f...c41d X-Team: support ``` - Ваш ответ **2xx** = доставлено. Любой другой код считается ошибкой. - Таймаут ожидания ответа — **15 секунд**. - Тело ответа сохраняется в журнале доставок (первые 2000 символов) — полезно для диагностики. - Отвечайте быстро: тяжёлую обработку выносите в фон. ### 7.2 Повторные попытки | Параметр | Значение | |---|---| | Всего попыток | 5 | | Задержки | 10 с, 60 с, 300 с, 1800 с, 7200 с | | Повтор | `5xx`, `408`, `429`, `404`, сетевые ошибки | | Без повтора | прочие `4xx` (ошибка в запросе, неверный адрес и т.п.) | Повторная доставка возможна, поэтому **обработчик должен быть идемпотентным**: ключ дедупликации — `X-Connect-Event-Id` (он же `event_id` в теле). ### 7.3 Формат события ```json { "event_id": "6f1c9d7e8a2b4c3d9e0f1a2b3c4d5e6f", "type": "message.inbound.received", "version": 1, "occurred_at": "2026-10-19T08:52:22.600243+00:00", "tenant_id": "00000000-0000-0000-0000-000000000000", "aggregate_type": "message", "aggregate_id": "e3b55006-691e-4abb-b503-267ebd9206ff", "correlation_id": null, "causation_id": null, "payload": {} } ``` | Поле | Описание | |---|---| | `event_id` | уникальный id события (для дедупликации) | | `type` | тип события | | `version` | версия формата (сейчас `1`) | | `occurred_at` | когда событие произошло (UTC, ISO 8601) | | `tenant_id` | служебный идентификатор организации (можно игнорировать) | | `aggregate_type`, `aggregate_id` | к какому объекту относится | | `correlation_id` | id исходного запроса, если событие — следствие API-вызова | | `payload` | данные события (см. ниже) | ### 7.4 Проверка подписи Подпись — HMAC-SHA256 от строки `".<тело запроса>"`, ключ — `secret` подписки. Тело берётся **байт-в-байт** из запроса, до разбора JSON. Заголовок: `X-Connect-Signature: t=1760863140,v1=5b2f...c41d` Алгоритм: 1. Разобрать `X-Connect-Signature`, взять `t` и `v1`. 2. `expected = HMAC_SHA256(secret, t + "." + raw_body)` в hex (нижний регистр). 3. Сравнить `expected` и `v1` **защищённым от тайминговых атак** сравнением (`hash_equals`, `hmac.compare_digest`, `crypto.timingSafeEqual`). 4. Отклонить, если |текущее время − `t`| больше 5 минут (защита от повторного использования перехваченного запроса). 5. Не прошло — вернуть `401` или `403` и **не обрабатывать** событие. **PHP** ```php $secret = getenv('CONNECT_WEBHOOK_SECRET'); $raw = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_CONNECT_SIGNATURE'] ?? ''; if (!preg_match('/(?:^|,)\s*t=(\d+)/', $header, $m)) { http_response_code(400); exit; } $timestamp = $m[1]; if (!preg_match('/v1=([0-9a-fA-F]+)/', $header, $m)) { http_response_code(400); exit; } $provided = $m[1]; if (abs(time() - (int) $timestamp) > 300) { http_response_code(400); exit; } $expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret); if (!hash_equals($expected, strtolower($provided))) { http_response_code(401); exit; } // подпись верна — обрабатываем $raw http_response_code(200); ``` **Python** ```python import hmac, hashlib, time from fastapi import Request, HTTPException SECRET = b"whsec_..." async def verify(request: Request) -> None: raw = await request.body() header = request.headers.get("X-Connect-Signature", "") parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part) ts, provided = parts.get("t", ""), parts.get("v1", "") if not ts or not provided: raise HTTPException(400, "Нет подписи") if abs(time.time() - int(ts)) > 300: raise HTTPException(400, "Устаревшая подпись") expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, provided.lower()): raise HTTPException(401, "Неверная подпись") ``` **Node.js** ```js const crypto = require('node:crypto'); function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(',').map(p => p.trim().split('='))); const ts = parts.t, provided = parts.v1; if (!ts || !provided) return false; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = crypto.createHmac('sha256', secret) .update(Buffer.concat([Buffer.from(`${ts}.`), rawBody])) .digest('hex'); const a = Buffer.from(expected), b = Buffer.from(provided.toLowerCase()); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ### 7.5 Типы событий и payload #### `conversation.created` — создан диалог ```json { "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "contact_id": "b191c476-0eda-4cc6-ad47-881e354364c0", "status": "open" } ``` #### `conversation.assigned` — диалог назначен ```json { "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "assigned_user_id": "9c1f2e3d-4b5a-4678-8901-23456789abcd", "queue_id": "1a2b3c4d-5e6f-4708-9123-456789abcdef" } ``` #### `message.inbound.received` — получено входящее ```json { "message_id": "e3b55006-691e-4abb-b503-267ebd9206ff", "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "contact_id": "b191c476-0eda-4cc6-ad47-881e354364c0", "message_type": "text", "text": "Здравствуйте!" } ``` > В событии есть текст сообщения. Если политика запрещает хранить переписку в вашей > системе — по `message_id` заберите полное сообщение через API и сохраните только > нужные поля. #### `message.outbound.queued` — исходящее поставлено в очередь ```json { "message_id": "e3b55006-691e-4abb-b503-267ebd9206ff", "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "channel_id": "d74c11c8-d8eb-457e-8692-39da8e99dca7", "message_type": "text", "has_attachments": false, "payload": {}, "idempotency_key": "6f1c9d7e-8a2b-4c3d-9e0f-1a2b3c4d5e6f" } ``` Текста в событии нет — забирайте через `GET /v1/messages/{message_id}`. #### `message.status.changed` — изменился статус ```json { "message_id": "e3b55006-691e-4abb-b503-267ebd9206ff", "conversation_id": "a7f7517a-dc7d-4c59-91d9-e0b522c4dda7", "status": "read", "error": null } ``` Статусы идут последовательно: `queued → sent → delivered → read`, либо `failed` с заполненной `error`. **Частые причины `failed`:** | Причина | Значение | |---|---| | `bad_contact` | номер не зарегистрирован в мессенджере | | `target_user_is_blocked` | получатель заблокировал отправителя | | `over_24_hour_limit` | для WABA: истекло 24-часовое окно, нужен шаблон | | `channel_blocked`, `channel_not_found` | проблема с каналом | | `message_limit_exceeded` | ограничение мессенджера на объём отправки | #### `webhook.test` — тестовая доставка Приходит в обычном конверте, в `payload`: ```json { "message": "Тестовая доставка от CONNECT API" } ``` --- ## 8. Типовые сценарии ### 8.1 Начать переписку с новым клиентом ```bash # 1) проверяем, есть ли уже такой контакт (необязательно) curl -s "https://api.aritin-connect.ru/v1/conversations/lookup?channel_id=$CHANNEL&phone=79655385561" \ -H "Authorization: Bearer $TOKEN" # 2) открываем диалог (контакт создастся сам) curl -s -X POST https://api.aritin-connect.ru/v1/conversations \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"channel_id":"'$CHANNEL'","contact_phone":"79655385561","contact_name":"Иван"}' # 3) отправляем сообщение curl -s -X POST "https://api.aritin-connect.ru/v1/conversations/$CONV/messages" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c9d7e-8a2b-4c3d-9e0f-1a2b3c4d5e6f" \ -d '{"text":"Здравствуйте! Чем можем помочь?"}' ``` ### 8.2 Отвечать на входящие (вебхуки) 1. Создать подписку на `message.inbound.received` и `message.status.changed`. 2. В приёмнике проверить подпись, ответить `200` сразу. 3. По `conversation_id` из события загрузить контекст (`GET /v1/conversations/{id}`). 4. Отправить ответ через `POST /v1/conversations/{id}/messages`. 5. Статусы доставки обновлять по `message.status.changed`. ### 8.3 Синхронизировать переписку в CRM - Периодическая синхронизация: `GET /v1/conversations?limit=200` → для каждого `GET /v1/conversations/{id}/messages?limit=200` (+ `before` для истории). - Инкрементально — только вебхуки, а полную сверку — раз в сутки. - Удалённых диалогов/сообщений API не сообщает: считайте отсутствующие в выгрузке записи удалёнными только после полной сверки. --- ## 9. Ограничения платформы 1. **Отметка «прочитано» наружу не отправляется.** Мессенджеры присылают статус `read` нам, но обратного метода «пометить прочитанным» в API провайдеров нет. 2. **`sent` ≠ `delivered`.** Ответ `201` на отправку означает «принято в очередь». Фактическую доставку смотрите в статусах. 3. **Вложения загружаются отдельным шагом.** Сначала `POST /v1/attachments` (multipart), затем `attachment_ids` в `POST /v1/conversations/{id}/messages`. Максимальный размер файла — 8 МБ (ограничение nginx перед API); до 10 файлов на сообщение. Вложения, пришедшие из мессенджера, — только метаданные + ссылка (`GET /v1/attachments/{id}/url`). 4. **Проверка «есть ли номер в WhatsApp» невозможна до отправки.** Метода проверки регистрации в мессенджере нет; достоверный признак — `failed` / `bad_contact` после первой отправки. 5. **Ограничения мессенджеров действуют и на вас:** WABA — 24-часовое окно, шаблоны; Telegram — запрет писать первым пользователям с настройками приватности. 6. **Правки и удаления сообщений в мессенджере** (редактирование, удаление) в CONNECT не синхронизируются. 7. **Каналы подключаются только в личном кабинете CONNECT** — через API создать канал нельзя, можно лишь читать список. 8. **`total` в пагинации** — общее число объектов по фильтру; при больших объёмах используйте стабильные `limit`/`offset`, порядок диалогов не меняется между запросами. --- ## 10. Чек-лист подключения - [ ] Получен API-ключ с нужными scope (`/v1/me` отвечает `200`). - [ ] Сохранён `X-Correlation-Id` из ответов в журналы — упрощает поддержку. - [ ] Для всех `POST` передаётся `Idempotency-Key`. - [ ] Создана подписка на вебхуки, `secret` сохранён в защищённом хранилище. - [ ] Настроена проверка `X-Connect-Signature` + окно 5 минут. - [ ] Приёмник отвечает `200` быстрее 15 секунд и не падает при повторной доставке одного `event_id`. - [ ] Пройден `POST /v1/webhook-endpoints/{id}/test` — событие пришло и подпись верна. - [ ] Реализована обработка `429` (`Retry-After`) и `5xx` с экспоненциальной паузой. - [ ] Для истории сообщений используется курсор `before`, а не `offset`. - [ ] Учтено: `201` на отправку ≠ доставка; статусы приходят вебхуком. --- ## Приложение. Полный список публичных методов | Метод | Путь | Scope | |---|---|---| | GET | `/v1/me` | — | | GET | `/v1/channels` | `channels:read` | | GET | `/v1/contacts` | `contacts:read` | | GET | `/v1/conversations` | `conversations:read` | | GET | `/v1/conversations/lookup` | `contacts:read` | | POST | `/v1/conversations` | `conversations:write` | | GET | `/v1/conversations/{id}` | `conversations:read` | | GET | `/v1/conversations/{id}/messages` | `messages:read` | | POST | `/v1/conversations/{id}/messages` | `messages:write` | | GET | `/v1/messages/{id}` | `messages:read` | | POST | `/v1/messages/inbound` | `messages:write` | | POST | `/v1/attachments` | `attachments:write` | | GET | `/v1/attachments/{id}` | `attachments:read` | | GET | `/v1/attachments/{id}/url` | `attachments:read` | | GET | `/v1/attachments/{id}/content` | `attachments:read` | | DELETE | `/v1/attachments/{id}` | `attachments:write` | | GET | `/v1/stats` | `stats:read` | | GET | `/v1/webhook-endpoints` | `webhooks:read` | | GET | `/v1/webhook-endpoints/event-types` | `webhooks:read` | | POST | `/v1/webhook-endpoints` | `webhooks:write` | | GET | `/v1/webhook-endpoints/{id}` | `webhooks:read` | | PATCH | `/v1/webhook-endpoints/{id}` | `webhooks:write` | | DELETE | `/v1/webhook-endpoints/{id}` | `webhooks:write` | | POST | `/v1/webhook-endpoints/{id}/test` | `webhooks:write` |