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": "<текст>" } }
HTTPcodeКогда
400bad_requestневерные данные запроса (формат, типы, неизвестное поле)
401invalid_keyневерный, отозванный или истёкший ключ
403forbiddenу ключа нет нужного права
404not_foundобъект не найден (или принадлежит другой компании)
409conflictконфликт состояния (например, дубль заявки)
422unprocessableзапрос корректен, но бизнес-правило не даёт выполнить (например, защищённый этап — см. §5 «Сделки»)
429rate_limitedпревышен лимит частоты
500internalошибка на НАШЕЙ стороне

code: internal означает ровно одно — сбой у нас; повторите позже. Всё остальное (bad_request, unprocessable, conflict, …) — про ваш запрос: сам по себе повтор без изменений не поможет. Объект другой компании никогда не отдаётся — вернётся 404, а не чужие данные.

**Про 422.** Это НЕ сбой сервера: code в конверте — unprocessable. Запрос корректен по форме, но бизнес-правило не даёт его выполнить. Ориентируйтесь на текст message (он объясняет, что именно поправить); повтор без изменений не поможет.

---

4. Ограничение частоты

120 запросов в минуту на ключ. В каждом ответе есть заголовки:

ЗаголовокЗначение
X-RateLimit-Limit120
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 — поля сделки

typetext | 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-Signaturesha256=<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 — поможем с подключением и разберём ошибки.