API интеграции с CRM LinksAi
Этот документ — для разработчика коннектора. По нему вы принимаете заявки и переписку клиентов в CRM и получаете от неё события, когда менеджер вмешивается в диалог.
- Базовый адрес: https://<домен-CRM>/api/v1 (домен даёт владелец компании). - Все запросы — по HTTPS, тело и ответы — JSON (кроме случаев ниже).
---
Что изменилось (для тех, кто читал прошлую версию)
- **Файлы от менеджера в событии operator.message.** Теперь менеджер может отправить клиенту вложения: в событии появляется массив attachments (url/filename/mimeType) — до 10 файлов, до 50 МБ каждый, ссылки живут ~1 час. Формат тот же, что у входящих вложений. text (если есть) — подпись к первому файлу. Подробности и поведение при неудаче — в «События к вам → Тела событий → Вложения в operator.message». - Два новых события по сделке (см. «События к вам»): deal.stage.changed (сделку перевели на другой этап) и deal.deleted (сделку удалили). - **Поле by в deal.stage.changed** — «кто двинул этап»: объект с сотрудником { memberId, name } (ручной перевод), { "system": true } (автоматика воронки) или null (перевод внутри CRM без опознанного сотрудника). - Правило эха для этапа: если этап двинули ВЫ через наш API, событие deal.stage.changed мы вам НЕ шлём (вы и так это сделали) — как и с сообщениями. - Предсказуемый порядок поиска контакта по телефону: первым — контакт с активной сделкой, дальше — самый свежий по изменению. Берите первый. - Клиент без телефона — принимаем контакт и сделку по мессенджер-личности (channel + messengerId); у сделки noPhone: true, на этап замера её не перевести, пока не появится номер. - Ограничения (укрепление API): ключ может иметь срок действия — истёкший перестаёт работать (401); адрес приёма событий должен быть публичным https (внутренние/локальные адреса не принимаются); ключ формы сайта (leads) ограничен по числу заявок и отсекает дубли.
---
С чего начать
Пройдите эти шаги по порядку — за час получите первую заявку и первое сообщение в ленте.
1. Ключ. Попросите владельца компании выдать ключ: в CRM Настройки → Интеграции и AI → «Публичное API — ключи» (страница «Ключи публичного API»). При выдаче владелец отмечает нужные вам области прав (см. §1). Для полного цикла общения с клиентом попросите отметить пять областей: Чтение справочников, Чтение контактов и сделок, Изменение контактов и сделок, Чтение переписки, Запись переписки (всё, кроме «Приём заявок с формы»). Ключ показывается ОДИН раз — сохраните его. Все запросы шлите с заголовком Authorization: Bearer <ключ>. 2. Проверьте связь. GET /ext/me — вернёт название компании. Если пришёл ответ 200 с data.companyName — ключ рабочий. 3. Заберите справочники. GET /ext/crm/pipelines и GET /ext/crm/pipelines/{id}/stages — узнаете воронки и этапы; GET /ext/crm/managers — ответственных. Эти id пригодятся при создании сделки. 4. Примите первую заявку. POST /ext/crm/intake — за один вызов создаст контакт и сделку. Передайте свой deal.externalId — повторный вызов с ним не создаст второй. 5. Положите сообщение в ленту. POST /ext/crm/deals/{id}/messages — добавит сообщение клиента в сделку. Время сообщения (eventAt) задаёте вы. 6. Принимайте наши события. Поднимите у себя адрес приёма, дайте его владельцу (он впишет в настройках). Когда менеджер возьмёт диалог или ответит — вы получите подписанный POST (см. «События к вам»).
---
1. Авторизация
Каждый запрос несёт ключ компании:
Authorization: Bearer <ключ>(Также принимается заголовок X-Api-Key: <ключ> — эквивалентно.)
Свойства ключа: - ключ привязан к ОДНОЙ компании; достать данные другой компании им нельзя; - ключ показывается владельцу один раз при создании; в ответах API не возвращается; - владелец может отозвать ключ — отозванный сразу перестаёт работать (401); - у ключа может быть срок действия; истёкший перестаёт работать (401, в тексте — что ключ истёк). Ключ без срока живёт как раньше.
Области прав ключа
У каждого ключа — набор ОБЛАСТЕЙ. Ручка без нужной области отвечает 403 с текстом, называющим недостающую область. Владелец выбирает области при создании ключа.
| Область | Что разрешает |
|---|---|
directories:read | видеть справочники: воронки, этапы, сотрудников, поля (и /ext/me) |
crm:read | видеть контакты и сделки |
crm:write | создавать/менять контакты и сделки, задачи, двигать этапы |
messages:read | читать ленту переписки сделки |
messages:write | добавлять сообщения в переписку |
leads | ТОЛЬКО приём заявок с формы сайта (ключ формы виден на сайте — больше ничего не может) |
Совместимость (важно для действующих интеграций). Ключ, выданный ДО областей — со старым набором read+write или вовсе без областей — считается ПОЛНЫМ и проходит любую ручку, как раньше. Ничего менять в таких интеграциях не нужно.
---
2. Формат ответов и дат
- Успешный ответ — объект с полем data (внутри объект или массив). - Имена полей — латиницей. - Все даты и время — ISO 8601 с часовым поясом, например 2026-08-21T14:00:00+03:00. Никаких секунд с начала эпохи в теле.
{ "data": { "companyId": "clx123", "companyName": "ООО Ромашка" } }---
3. Ошибки
Единый конверт с человекочитаемым текстом на русском:
{ "error": { "code": "<код>", "message": "<текст>" } }| HTTP | code | Когда |
|---|---|---|
| 400 | bad_request | неверные данные запроса (формат, типы, неизвестное поле) |
| 401 | invalid_key | неверный, отозванный или истёкший ключ |
| 403 | forbidden | у ключа нет нужного права |
| 404 | not_found | объект не найден (или принадлежит другой компании) |
| 409 | conflict | конфликт состояния (например, дубль заявки) |
| 422 | unprocessable | запрос корректен, но бизнес-правило не даёт выполнить (например, защищённый этап — см. §5 «Сделки») |
| 429 | rate_limited | превышен лимит частоты |
| 500 | internal | ошибка на НАШЕЙ стороне |
code: internal означает ровно одно — сбой у нас; повторите позже. Всё остальное (bad_request, unprocessable, conflict, …) — про ваш запрос: сам по себе повтор без изменений не поможет. Объект другой компании никогда не отдаётся — вернётся 404, а не чужие данные.
**Про 422.** Это НЕ сбой сервера: code в конверте — unprocessable. Запрос корректен по форме, но бизнес-правило не даёт его выполнить. Ориентируйтесь на текст message (он объясняет, что именно поправить); повтор без изменений не поможет.
---
4. Ограничение частоты
120 запросов в минуту на ключ. В каждом ответе есть заголовки:
| Заголовок | Значение |
|---|---|
X-RateLimit-Limit | 120 |
X-RateLimit-Remaining | остаток в текущем окне |
X-RateLimit-Reset | момент сброса окна (секунды эпохи, Unix) |
При превышении — 429, заголовок Retry-After (секунды) и тело:
{ "error": { "code": "rate_limited", "message": "Слишком много запросов по ключу (лимит 120/мин). Повторите через 1 мин." } }---
5. Защита от повторов (идемпотентность)
В запросах на создание передавайте свой постоянный идентификатор externalId. Повторный запрос с тем же externalId вернёт ТОТ ЖЕ объект, а не создаст второй; в ответе будет reused: true. Идентификатор уникален в рамках вашей компании и провайдера (provider, по умолчанию noya).
Дополнительно контакт дедуплицируется по телефону: если контакт с таким номером уже есть, он обновляется, а не дублируется. Контакты не удаляются и не сливаются автоматически.
---
Справочники
Проверка связи и чтение справочников.
GET /ext/me — проверка связи
{ "data": { "companyId": "clx123", "companyName": "ООО Ромашка", "keyName": "Ноя — мессенджеры", "scopes": ["read", "write"] } }GET /ext/crm/pipelines — воронки
{ "data": [ { "id": "pl_1", "name": "Продажи" }, { "id": "pl_2", "name": "Гарантия" } ] }GET /ext/crm/pipelines/{id}/stages — этапы воронки
order — порядок этапа (по возрастанию). Чужая/несуществующая воронка → 404.
{ "data": [ { "id": "st_1", "name": "Новая", "order": 0 }, { "id": "st_2", "name": "Замер", "order": 1 } ] }GET /ext/crm/managers — сотрудники (ответственные)
id передавайте как ответственного при создании/изменении сделки.
{ "data": [ { "id": "mem_1", "name": "Петров Пётр" }, { "id": "mem_2", "name": "ivanov@romashka.ru" } ] }GET /ext/crm/deal-fields — поля сделки
type ∈ text | number | date | select. Для select — массив options.
{ "data": [
{ "id": "f_1", "name": "Комментарий", "type": "text" },
{ "id": "f_2", "name": "Площадь", "type": "number" },
{ "id": "f_3", "name": "Цвет плёнки", "type": "select", "options": ["белый", "чёрный"] }
] }GET /ext/crm/contact-fields — поля контакта
Отдаёт ТОЛЬКО записываемые поля контакта. Произвольные (кастомные) поля контакта не поддерживаются — всё дополнительное пишите в поля СДЕЛКИ (/ext/crm/deal-fields).
{ "data": [
{ "id": "name", "name": "Имя", "type": "text" },
{ "id": "phone", "name": "Телефон", "type": "text" },
{ "id": "extraPhones", "name": "Дополнительные телефоны", "type": "text" },
{ "id": "email", "name": "Почта", "type": "text" }
] }---
Контакты
GET /ext/crm/contacts?phone=... — поиск по телефону
Номер в любом написании (79991234567, +7 (999) 123-45-67, 8 999 123 45 67). Может вернуться несколько контактов (дубли, заведённые до интеграции). Порядок выдачи предсказуем — берите первый: сначала идёт контакт с активной (открытой) сделкой; при прочих равных — самый свежий по последнему изменению. Автоматически мы контакты не объединяем.
{ "data": [ {
"id": "c_1", "name": "Петров Пётр", "firstName": "Пётр", "lastName": "Петров",
"phone": "+79991234567", "email": null, "comment": null,
"hasPhone": true, "duplicateSuspectContactId": null,
"createdAt": "2026-08-19T10:00:00.000Z"
} ] }POST /ext/crm/contacts — создать/обновить контакт
Тело (поля опциональны; fields — только записываемые поля контакта из §contact-fields):
{ "externalId": "noya-777", "name": "Иван Петров", "phone": "+7 999 000 11 22", "email": "i@x.ru", "comment": "из WhatsApp" }Ответ:
{ "data": { "id": "c_1", "name": "Петров Иван", "phone": "+79990001122", "hasPhone": true, "createdAt": "2026-08-19T10:00:00.000Z" },
"contactId": "c_1", "reused": false }Клиент без телефона (мессенджер по нику)
Если номера нет (например, пишут в Telegram по нику), всё равно создавайте контакт и сделку. Для связывания последующих обращений передавайте мессенджер-личность: channel + messengerId (постоянный id собеседника у вас). Поиск контакта идёт СНАЧАЛА по мессенджер-личности, затем по телефону. - у сделки без телефона в ответе noPhone: true; - перевести такую сделку на этап назначения замера нельзя, пока нет номера — вернётся 422 (code: unprocessable) с текстом «Нельзя назначить замер: у контакта нет телефона…» (замерщика нельзя отправить, не имея телефона); - как только придёт обновление контакта с phone — пометка снимается; - если номер совпал с ДРУГИМ вашим контактом — мы не сливаем автоматически, а ставим пометку возможного дубля (duplicateSuspectContactId); решает менеджер.
GET /ext/crm/contacts/{id}/deals — сделки контакта
Нужно, чтобы писать в открытую сделку, а не плодить новые. active — сделка в работе.
{ "data": [ {
"id": "d_1", "number": 42, "title": "Кухня", "stageId": "st_1", "stageName": "Новая",
"status": "open", "state": "active", "active": true, "budget": 15000,
"createdAt": "2026-08-19T10:00:00.000Z", "url": "https://<домен-CRM>/deals/d_1"
} ] }---
Сделки
GET /ext/crm/deals/{id} — сделка целиком
Поля сделки — плоским объектом fields (пары «название → значение»).
{ "data": {
"id": "d_1", "number": 42, "title": "Кухня", "status": "open", "state": "active", "noPhone": false,
"pipelineId": "pl_1", "pipelineName": "Продажи", "stageId": "st_1", "stageName": "Новая",
"budget": 15000, "contactId": "c_1",
"responsible": { "id": "mem_1", "name": "Петров Пётр" },
"fields": { "Цвет плёнки": "белый" },
"createdAt": "2026-08-19T10:00:00.000Z", "updatedAt": "2026-08-19T11:00:00.000Z",
"url": "https://<домен-CRM>/deals/d_1"
} }PATCH /ext/crm/deals/{id} — обновить поля сделки
Тело — плоский объект полей (ключ — название ИЛИ id поля из /ext/crm/deal-fields), можно и в обёртке { "fields": { ... } }. Неизвестный ключ поля → 400.
{ "Цвет плёнки": "чёрный", "Площадь": 24.5 }Ответ — сделка целиком (как GET).
POST /ext/crm/deals/{id}/stage — перевести на этап
{ "stageId": "st_2" }Также принимается { "stageName": "Замер" }. Для проигрышного этапа добавьте "lossReason": "дорого".
Защищённые этапы. Если по настройкам CRM положить сделку на этап нельзя, вернётся честная ошибка 422 (code: unprocessable) с объяснением — молча в другой этап мы не кладём:
{ "error": { "code": "unprocessable", "message": "Нельзя перевести на этот этап: заполните обязательные поля — Сумма (бюджет), Адрес объекта." } }или для проигрышного этапа без причины:
{ "error": { "code": "unprocessable", "message": "Нельзя перевести в проигрышный этап без указания причины отказа (передайте lossReason)." } }POST /ext/crm/deals/{id}/responsible — сменить ответственного
{ "memberId": "mem_2" }POST /ext/crm/deals/{id}/tasks — поставить задачу
{ "title": "Перезвонить", "dueAt": "2026-08-21T14:00:00+03:00", "responsibleMemberId": "mem_2" }{ "data": { "id": "task_1", "title": "Перезвонить", "status": "open", "priority": "normal",
"dueAt": "2026-08-21T11:00:00.000Z", "responsibleMemberId": "mem_2",
"createdAt": "2026-08-19T10:00:00.000Z" } }POST /ext/crm/intake — контакт + сделка ОДНИМ вызовом
Ищет контакт по телефону/мессенджер-личности (или создаёт), создаёт сделку, привязывает. Идемпотентно по deal.externalId. Текст обращения кладите в deal.comment (примечание сделки). Берите stageId из §Справочники: если указать защищённый или проигрышный этап, придёт отказ 422 (заполните обязательные поля или передайте lossReason) — для первой заявки берите первый этап воронки.
{ "provider": "noya",
"contact": { "externalId": "noya-c-1", "name": "Иван Петров", "phone": "+7 999 000 11 22", "channel": "whatsapp", "messengerId": "wa-777" },
"deal": { "externalId": "noya-d-1", "title": "Кухня", "pipelineId": "pl_1", "stageId": "st_1",
"source": "whatsapp", "comment": "Нужен потолок в кухню", "fields": { "Цвет плёнки": "белый" } } }{ "data": { "contactId": "c_1", "dealId": "d_1", "reused": false,
"contact": { "id": "c_1", "name": "Петров Иван" },
"deal": { "id": "d_1", "number": 42 },
"url": "https://<домен-CRM>/deals/d_1" } }---
Переписка
Лента сообщений складывается в сделку.
Автор сообщения
Поле author принимает РОВНО четыре значения (других не будет): - client — написал клиент; - ai — ответил ИИ (включая автоответы вне рабочего времени); - operator — ответил человек с вашей стороны (из вашего кабинета или прямо в мессенджере); - system — сервисное (рассылка, служебное уведомление).
Рядом — необязательное authorName.
Время сообщения — ВАШЕ
eventAt (ISO 8601 с зоной) задаёте ВЫ — это время события. Мы не подставляем своё время получения, иначе выгруженная история склеится в одну секунду. Поле обязательно.
Идемпотентность
Сообщение идентифицируется вашим messageId. Повтор с известным messageId не добавится вторым — ответим успехом.
Сообщения менеджера не присылайте обратно
Когда наш менеджер отвечает клиенту, мы кладём его сообщение в ленту сами и шлём вам событие operator.message (см. «События к вам»). Это же сообщение НЕ присылайте нам обратно ручкой добавления сообщения — эхо создаст дубль.
Вложения
В сообщении можно передать вложения ссылкой url (ваша ссылка). Мы скачиваем файл СРАЗУ при получении сообщения (фоном, с повторами) и храним у себя — поэтому ваша ссылка может жить недолго (нам достаточно ~1 часа). - Предел размера — 50 МБ; типы: pdf, doc, docx, xls, xlsx, png, jpg, jpeg, gif, webp, txt, csv, ogg, mp3, wav, m4a. Прочее отклоняется (status: "rejected"). - Пока качается — status: "pending"/"downloading"; успех — "stored"; если не удалось после всех попыток — "failed". - Для stored-вложения в ленте приходит ВРЕМЕННАЯ ссылка url на наш файл (живёт ~15 минут; при необходимости перечитайте ленту, чтобы получить свежую).
POST /ext/crm/deals/{id}/messages — добавить сообщение
{ "channel": "whatsapp", "messageId": "wamid.XXX", "author": "client", "authorName": "Иван",
"text": "Здравствуйте!", "eventAt": "2026-08-21T14:00:00+03:00", "dialogId": "wa-dialog-1",
"attachments": [ { "url": "https://noya/file/abc", "filename": "смета.pdf", "mimeType": "application/pdf" } ] }{ "data": {
"id": "m_1", "channel": "whatsapp", "dialogId": "wa-dialog-1", "messageId": "wamid.XXX",
"author": "client", "authorName": "Иван", "outgoing": false,
"text": "Здравствуйте!", "eventAt": "2026-08-21T11:00:00.000Z", "createdAt": "2026-08-21T11:00:02.000Z",
"attachments": [ { "id": "a_1", "filename": "смета.pdf", "mimeType": "application/pdf", "size": null,
"status": "pending", "failed": false, "canRetry": false, "error": null, "url": null } ]
} }POST /ext/crm/deals/{id}/messages/batch — пачка (максимум 100)
Тело — массив сообщений или { "messages": [ ... ] }. Больше 100 — 400 с указанием предела. Каждое сообщение идемпотентно по messageId.
{ "data": { "created": 42, "reused": 0, "total": 42, "ids": ["m_1", "m_2"] } }GET /ext/crm/deals/{id}/messages?limit=&offset= — чтение ленты
limit до 100 (по умолчанию 50), offset — сдвиг. Порядок — хронологический по eventAt.
{ "data": [ {
"id": "m_1", "channel": "whatsapp", "author": "client", "outgoing": false, "text": "Здравствуйте!",
"eventAt": "2026-08-21T11:00:00.000Z",
"attachments": [ { "id": "a_1", "status": "stored", "url": "https://<домен-CRM>/files/...signed" } ]
} ],
"page": { "limit": 50, "offset": 0, "nextOffset": 50 } }---
События к вам (исходящие)
Когда наш менеджер вмешивается в диалог, мы отправляем POST на адрес, который владелец указал в настройках интеграции. **Адрес приёма должен быть публичным https** — внутренние и локальные адреса (loopback, частные сети, *.local) не принимаются: их отклонят и при сохранении, и при каждой отправке. Пять событий: три диалоговых (пауза ИИ) и два о жизненном цикле сделки:
| event | когда | что делаете вы |
|---|---|---|
dialog.taken | менеджер забрал диалог | ставите паузу ИИ (ИИ молчит) |
dialog.released | менеджер вернул диалог ИИ | снимаете паузу (ИИ снова отвечает) |
operator.message | менеджер написал текст | отправляете текст клиенту и ставите паузу, если её ещё не было |
deal.stage.changed | сделка перешла на другой этап | реагируете на этап (напр. «уехала на замер» → напомнить клиенту) |
deal.deleted | сделка удалена | перестаёте писать в эту сделку |
Пауза у вас бессрочная и сама не снимается — поэтому важны все три диалоговых события: между «забрал» и первым сообщением ИИ уже должен молчать, а dialog.released снимает паузу.
Когда шлём события жизненного цикла. deal.stage.changed и deal.deleted уходят ТОЛЬКО если интеграция включена И по сделке уже есть переписка (хотя бы одно сообщение) — иначе вы получали бы события по сделкам, о которых ничего не знаете. Диалоговые события по определению приходят только по сделкам с перепиской.
**Тестовое событие connection.test.** Когда владелец в настройках нажимает «Проверить связь», мы шлём на ваш адрес одно событие типа connection.test — подписанное настоящим секретом по обычной схеме (те же заголовки и подпись, что у боевых событий). Это ПРОВЕРКА, а не действие: увидев event: "connection.test", просто проверьте подпись и ответьте 2xx — НЕ пишите клиенту и не заводите ничего по нему. Тело: { "event": "connection.test", "eventId": "…", "at": "…" } (без dealId). У боевых событий event — один из пяти выше; connection.test среди них не встречается.
Тела событий
operator.message:
{ "event": "operator.message", "eventId": "550e8400-e29b-41d4-a716-446655440000",
"dealId": "d_1", "channel": "whatsapp", "dialogId": "wa-1",
"text": "Здравствуйте, уточню и вернусь", "operator": { "memberId": "mem_1", "name": "Иванов Иван" },
"at": "2026-08-21T14:05:00.000Z" }С вложениями менеджера (поле attachments; текст может быть пустым — тогда его нет):
{ "event": "operator.message", "eventId": "…", "dealId": "d_1",
"channel": "whatsapp", "dialogId": "wa-1",
"text": "Смотрите смету", "operator": { "memberId": "mem_1", "name": "Иванов Иван" },
"attachments": [
{ "url": "https://<домен-CRM>/files/...signed", "filename": "смета.pdf", "mimeType": "application/pdf" },
{ "url": "https://<домен-CRM>/files/...signed", "filename": "фото.jpg", "mimeType": "image/jpeg" }
],
"at": "2026-08-21T14:05:00.000Z" }**Вложения в operator.message** (формат тот же, что вы шлёте нам во входящих — url/filename/mimeType): - Поле attachments — массив; появляется, ТОЛЬКО когда менеджер приложил файлы. Нет файлов — поля нет. - Максимум 10 вложений в одном событии; если пришлём больше — лишние можно игнорировать (у нас тот же предел, больше 10 менеджер отправить не может). - Предел 50 МБ на файл; типы — те же, что у входящих: pdf, doc, docx, xls, xlsx, png, jpg, jpeg, gif, webp, txt, csv, ogg, mp3, wav, m4a. - **text — подпись к ПЕРВОМУ файлу. Если текста нет, отправляйте файлы без подписи. - Срок жизни url — ~1 час (3600 сек) с момента постановки события. Этого заведомо хватает на весь наш цикл повторов доставки (при задержке доставки ссылка всё ещё жива). Скачивайте файл при получении события; повторно тянуть ту же ссылку спустя час не нужно — событие вы уже получили. - Неудача по одному файлу не должна ронять остальные.** Если какой-то файл вы не смогли принять — верните нам это ОБЫЧНЫМ сообщением (POST /ext/crm/deals/{id}/messages) с author: "system" и понятным текстом причины; менеджер увидит его в ленте. По другим файлам продолжайте как обычно. dialog.taken — то же без text:
{ "event": "dialog.taken", "eventId": "…", "dealId": "d_1", "channel": "whatsapp", "dialogId": "wa-1",
"operator": { "memberId": "mem_1", "name": "Иванов Иван" }, "at": "2026-08-21T14:03:00.000Z" }dialog.released — без text и operator:
{ "event": "dialog.released", "eventId": "…", "dealId": "d_1", "channel": "whatsapp", "dialogId": "wa-1",
"at": "2026-08-21T14:10:00.000Z" }deal.stage.changed — что было/стало (id и названия этапов) и кто перевёл (by). channel/dialogId тут нет — событие не про переписку.
{ "event": "deal.stage.changed", "eventId": "…", "dealId": "d_1",
"from": { "stageId": "st_1", "stageName": "Новая" },
"to": { "stageId": "st_2", "stageName": "Замер" },
"by": { "memberId": "mem_1", "name": "Иванов Иван" },
"at": "2026-08-21T15:00:00.000Z" }Поле **by** — источник перевода; по нему решайте, писать ли клиенту «менеджер перевёл вашу заявку»: - { "memberId": "...", "name": "..." } — перевёл КОНКРЕТНЫЙ сотрудник вручную; - { "system": true } — перевела АВТОМАТИКА воронки (замер/монтаж по расписанию); человека нет — не приписывайте перевод менеджеру; - null — перевод сделан ВНУТРИ CRM, но конкретный сотрудник не опознан (например системный пользователь). Это тоже действие на нашей стороне, просто без имени.
Эхо не шлём. Если этап сделки двинули ВЫ — вызовом нашего API (POST /ext/crm/deals/{id}/stage) — событие deal.stage.changed мы вам НЕ отправляем: вы и так знаете об этом переводе, вы его сделали. Событие приходит только когда сделку двигают на НАШЕЙ стороне (менеджер в CRM или автоматика). Так исключается петля «вы двигаете → мы шлём вам обратно → ваша автоматика реагирует как на чужое». То же правило, что для сообщений: то, что вы прислали нам сами, обратно событием не летит.
Тот же переход, выполненный автоматикой:
{ "event": "deal.stage.changed", "eventId": "…", "dealId": "d_1",
"from": { "stageId": "st_1", "stageName": "Новая" },
"to": { "stageId": "st_2", "stageName": "Замер" },
"by": { "system": true }, "at": "2026-08-21T15:00:00.000Z" }deal.deleted — сделку удалили; перестаньте в неё писать. Поле reason сейчас всегда deleted, а mergedInto — всегда null (объединение сделок пока не поддерживается; mergedInto зарезервировано под будущее — если появится, там будет { "dealId": "..." } с целевой сделкой).
{ "event": "deal.deleted", "eventId": "…", "dealId": "d_1",
"reason": "deleted", "mergedInto": null,
"at": "2026-08-21T16:00:00.000Z" }Подпись, заголовки и повторы у событий жизненного цикла — ТЕ ЖЕ, что у диалоговых (см. ниже); очередь и механизм доставки общие.
Заголовки
Метод POST, Content-Type: application/json.
| Заголовок | Значение |
|---|---|
X-Event-Id | идентификатор события; ПОСТОЯНЕН между попытками — отсекайте повторы по нему |
X-Timestamp | время отправки (секунды эпохи); при каждой попытке НОВОЕ |
X-Signature | sha256=<hex> — подпись (см. ниже) |
Схема подписи
1. Секрет генерируете ВЫ и передаёте владельцу — он вписывает его в настройках CRM. Секрет хранится у нас зашифрованным и в ответах не появляется. 2. Секрет — это СТРОКА, и ключом HMAC берётся ИМЕННО ЭТА СТРОКА, символ в символ (её байты как есть, UTF-8). Мы отдаём секрет строкой (например, 64 символа в шестнадцатеричной записи). НЕ раскодируйте эти 64 символа в 32 байта и НЕ берите ключом декодированные байты — это ДРУГОЙ ключ, и подпись не сойдётся никогда. Что получили строкой — тем и подписывайте. 3. Подпись считается по СЫРОМУ телу запроса, байт в байт — по той самой строке, которую вы получили, БЕЗ повторной сборки из разобранного объекта (иначе порядок ключей и пробелы разъедутся и подпись не сойдётся). 4. Подписываемая строка: X-Timestamp + "." + сырое тело. 5. Алгоритм: HMAC-SHA256, где ключ = строка секрета как есть, **данные = "<X-Timestamp>.<сырое тело>"**; результат — hex в нижнем регистре, с префиксом sha256=. 6. Проверьте, что X-Timestamp не старше 5 минут (защита от переигровки).
Псевдокод проверки (secret — строка как есть, БЕЗ декодирования):
expected = "sha256=" + hex(hmac_sha256(key = secret_string, data = X_Timestamp + "." + raw_body))
valid = (expected == X_Signature) and (now - X_Timestamp <= 300)Контрольный пример (проверьте свою реализацию)
Возьмите значения дословно и посчитайте подпись у себя — должна получиться ровно такая же строка. Секрет здесь выдуманный (не рабочий). Тело — одной строкой, байт в байт, без переносов.
secret = 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
X-Timestamp = 1700000000
тело (raw) = {"event":"connection.test","eventId":"00000000-0000-4000-8000-000000000000","at":"2026-01-01T00:00:00.000Z"}
подписываемая строка = 1700000000.{"event":"connection.test","eventId":"00000000-0000-4000-8000-000000000000","at":"2026-01-01T00:00:00.000Z"}
X-Signature = sha256=f3fdcf24599a1b75e1c39a036139abf70309b7f41b92424d4bec1be5e8937406Получилась другая подпись — почти наверняка вы раскодировали секрет из hex в байты (см. п.2): возьмите секрет строкой как есть.
Про направление. Подпись — ТОЛЬКО на событиях, которые шлём МЫ вам (исходящие). Ваши запросы К нам (контакты, сделки, сообщения) подписью НЕ снабжаются и нами по подписи НЕ проверяются — там авторизация по ключу Authorization: Bearer <ключ>. Секрет подписи для запросов к нам не нужен.
Повторы
Если вы не ответили за 5 секунд или ответили не-2xx, мы повторяем доставку с нарастающим интервалом. При КАЖДОЙ попытке подпись пересчитывается с НОВЫМ X-Timestamp (поэтому и требуется свежесть ≤ 5 минут), а X-Event-Id остаётся ПРЕЖНИМ — по нему отсекайте повторную обработку. Отвечайте 2xx, как только приняли событие.
---
Пределы
- Лимит частоты — 120 запросов/мин на ключ. - Поиск контактов по телефону — до 50 записей за запрос. Список сделок контакта возвращает все его сделки (обычно их немного). - Пачка сообщений — до 100 за запрос; страница ленты — до 100 (по умолчанию 50). - Вложение — до 50 МБ; типы см. в разделе «Вложения». - Телефон для поиска — не менее 7 цифр.
---
Вопросы
Пишите на help@linksai.ru — поможем с подключением и разберём ошибки.