1. Главная
  2. Документация
  3. Документация API

Документация API

Ключи компании, заявки, звонки, записи разговоров, ошибки и примеры.

Версия API: v1. Базовый адрес: https://crm.linkodium.com.

API нужен, чтобы заявки и звонки попадали в CRM автоматически:

  • заявки с форм сайта, из квизов, чат-ботов и рекламных кабинетов;
  • звонки из любой АТС (Novofon, Zadarma, Mango, Asterisk и других) вместе с записями разговоров.
Все запросы идут от вашего сервера к серверу CRM. Вызывать API из браузера (из кода страницы сайта) нельзя: ключ API в коде страницы видит любой посетитель.

Содержание

  1. Ключ API
  2. Аутентификация
  3. Формат запросов и ответов
  4. Ошибки
  5. Лимиты
  6. Заявки: POST /api/v1/leads
  7. Телефония: POST /api/v1/calls
  8. Запись звонка файлом: POST /api/v1/calls/recording
  9. Пример прослойки для Novofon и Zadarma
  10. Переход со старых адресов

1. Ключ API

Ключ выпускает администратор компании: Настройки компании -> API-ключи -> Выпустить ключ. При выпуске ключу дают название (например, «Форма на сайте»), по нему ключ потом легко найти в списке.

  • Ключ выглядит так: crm_ и 40 символов 0-9a-f, например crm_3f9a....
  • Полное значение показывается один раз, сразу после выпуска. Скопируйте его и сохраните в настройках своего сервера. CRM хранит только отпечаток ключа и показать его снова не может. В списке ключей видны только первые символы. Если ключ потерян, выпустите новый и отзовите старый.
  • Ключ привязан к компании: всё, что приходит с ним, попадает в эту компанию. Выбрать другую компанию полем запроса нельзя.
  • Заведите отдельный ключ на каждую интеграцию (форма сайта, телефония, чат-бот). Тогда при утечке одного ключа достаточно отозвать только его.
  • Отозвать ключ можно там же, в списке ключей. Отзыв действует сразу.
  • Кнопка «Выйти на всех устройствах» в профиле ключи не отзывает: ключ принадлежит компании, а не сотруднику. Исключение сотрудника из компании, смена его роли и его выход из компании тоже не отзывают выпущенные им ключи сами: для этого есть отметка «Отозвать API-ключи» в карточке сотрудника и у кнопки «Покинуть компанию».
  • У компании может быть до 20 действующих ключей.

Где ключ хранить нельзя: в коде страницы сайта, в JavaScript, в мобильном приложении, в публичном репозитории, в адресе запроса (?key=...). Адрес запроса попадает в журналы серверов и историю браузера, поэтому ключ из адреса CRM не читает вовсе.

2. Аутентификация

Ключ передаётся в заголовке запроса. Подходит любой из двух вариантов:

X-API-Key: crm_0123456789abcdef0123456789abcdef01234567
Authorization: Bearer crm_0123456789abcdef0123456789abcdef01234567

Нет ключа, ключ неверный или отозван, компания заблокирована: ответ 401 UNAUTHORIZED.

3. Формат запросов и ответов

  • Только HTTPS и только метод POST.
  • Тело запроса: JSON-объект (Content-Type: application/json, кодировка UTF-8) или обычная форма (application/x-www-form-urlencoded, multipart/form-data). Запись звонка файлом: только multipart/form-data.
  • Тело, которое начинается с { или [, разбирается как JSON при любом Content-Type. Если это не корректный JSON-объект (ошибка в JSON или массив вместо объекта), ответ 400 VALIDATION_ERROR.
  • Ответ всегда JSON в UTF-8, в том числе отказы, которые сервер выдаёт до разбора запроса: слишком частые запросы (429), слишком большое тело (413), технические работы (503).

Успешный ответ:

{
  "success": true,
  "data": { "lead_number": 128, "is_duplicate": false, "message": "Заявка создана" }
}

Ответ с ошибкой:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Ошибка валидации данных",
    "details": ["Некорректный формат email"]
  }
}
  • lead_number: номер заявки внутри вашей компании, тот же, что менеджеры видят в CRM (#128).
  • Неизвестные поля запроса молча игнорируются.
  • Ответы не кэшируются (Cache-Control: no-store).
  • В каждом ответе есть заголовок X-Request-ID. Если пишете в поддержку о конкретном запросе, приложите его значение.

4. Ошибки

HTTPerror.codeЧто случилосьЧто делать
400VALIDATION_ERRORДанные не прошли проверку. Список причин в error.detailsИсправить запрос. Повторять без изменений бесполезно
401UNAUTHORIZEDНет ключа, ключ неверный или отозван, компания заблокированаПроверить заголовок и ключ
403QUOTA_EXCEEDEDХранилище компании заполнено, запись звонка файлом не принята (раздел 8)Освободить место или обратиться в поддержку
404CALL_NOT_FOUNDЗвонка с таким call_id в CRM нет: пришло событие без номера клиента, а начала звонка ещё не было (раздел 7), или загружается файл к незаведённому звонку (раздел 8)Если начало звонка может прийти позже, повторить через минуту. Для записи исходящего звонка, который не сохранялся (раздел 7), это штатный ответ
405METHOD_NOT_ALLOWEDМетод не POSTИспользовать POST
413PAYLOAD_TOO_LARGEТело запроса или файл записи больше допустимого (раздел 5)Уменьшить файл (например, mp3 вместо wav)
429RATE_LIMITEDПревышен лимит запросов или одновременных загрузокПовторить позже, см. заголовок Retry-After
500INTERNAL_ERRORСбой на стороне CRMПовторить с паузой
503MAINTENANCEИдут технические работыПовторить через несколько минут, см. Retry-After
503SERVICE_UNAVAILABLEСервис временно недоступенПовторить через минуту, см. Retry-After

Рекомендация по повторам: при 429, 500, 503 и обрыве связи повторяйте запрос с растущей паузой (например, 10 секунд, 1 минута, 5 минут), а если в ответе есть заголовок Retry-After, не раньше указанного в нём числа секунд. Повтор безопасен: /api/v1/calls и загрузка записи идемпотентны, а повторная заявка с тем же телефоном или email распознаётся как повторное обращение (см. раздел 6). 404 CALL_NOT_FOUND повторяйте, только если начало звонка ещё может прийти. Ошибки 400, 401, 405, 413 повтором не исправляются.

Отказ принять запись разговора по ссылке ошибкой всего запроса не считается: звонок и заявка сохраняются, а причина отказа приходит в ответе полем recording_error (раздел 7).

5. Лимиты

ЧтоЛимит
Запросов на один ключ3000 за 5 минут
Запросов с одного IP-адресамягкое ограничение от флуда, живая интеграция в него не упирается
Одновременных загрузок записи файлом с одного IP-адреса8
Размер тела запроса (JSON, форма)3 МБ
Файл записи звонка100 МБ
Запись звонка по ссылкедо 200 МБ
Записей по ссылке, ждущих скачивания20 на одну заявку, 200 на компанию, 400 на все компании одного владельца
Действующих ключей у компании20

Превышение лимита запросов или одновременных загрузок: 429 RATE_LIMITED с заголовком Retry-After.

Очередь записей по ссылке. Пока у компании меньше 20 записей ждут скачивания, её новые записи принимаются всегда, как бы ни была загружена очередь остальных компаний (предел в 20 записей на одну заявку действует и тогда). Если очередь заполнена (своя, общая для компаний владельца или общая для сервиса), звонок всё равно сохраняется, а запись получает отказ QUEUE_FULL в поле recording_error (раздел 7). Живая телефония держит в очереди единицы записей и в этот потолок не упирается.

6. Заявки: POST /api/v1/leads

Создаёт заявку в компании ключа. Ответственным становится менеджер по умолчанию компании (Настройки компании -> Сотрудники). Сотрудники получают уведомление в Telegram по своим настройкам.

Поля

Обязательно хотя бы одно из полей name, phone, email.

ПолеОписание
nameИмя клиента
phoneТелефон в любом формате, не меньше 7 цифр. Номер короче: ошибка 400
emailEmail. Адрес в неверном формате: ошибка 400
companyКомпания клиента
websiteСайт клиента. Некорректный адрес не сохраняется, но заявку не отменяет
messageТекст обращения
client_needsЧто нужно клиенту (например, название формы или квиза)
sourceИсточник: название сайта, рекламной кампании, «Звонок» и т. п.
serviceУслуга, любой текст до 255 символов
statusЭтап заявки: номер (1, 2, 5, 6, 7) или название («Новая заявка», «В работе», «Успешно», «Провал», «Спам»; регистр букв не важен). По умолчанию «Новая заявка». Другое значение: ошибка 400
landing_pageСтраница первого захода посетителя
form_pageСтраница, с которой отправлена форма
referrerОткуда пришёл посетитель
ip_addressIP-адрес посетителя
user_agentБраузер посетителя
utm_source, utm_medium, utm_campaign, utm_term, utm_contentМетки UTM
ym_client_idClientID Яндекс Метрики (латиница, цифры, ., _, -, до 64 символов; иное значение не сохраняется)

Заявка не теряется из-за длины: слишком длинные значения обрезаются по ширине поля (phone 50 символов, landing_page, form_page, referrer 500, ip_address 45, ym_client_id 64, message, client_needs и user_agent около 65 000 байт, остальные строки 255 символов). Байты не в UTF-8 заменяются знаком замены (U+FFFD).

ip_address, user_agent, referrer передавайте с данными посетителя сайта. CRM не подставляет вместо них адрес вашего сервера.

Заявка со статусом «Спам» сохраняется, но уведомление о ней не отправляется. Так удобно передавать заявки, которые отметил антиспам формы: если он ошибся, заявку видно в CRM под фильтром «Спам».

Повторное обращение

Если в компании уже есть активная заявка (этап «Новая заявка» или «В работе», не в архиве) с тем же телефоном (сравниваются последние 10 цифр) или тем же email, новая заявка не создаётся. В существующую добавляется событие «Повторное обращение» с полями source, name, company, phone, email, service, message, client_needs, form_page, ip_address, метками UTM (utm_source, utm_medium, utm_campaign, utm_term, utm_content), ym_client_id, landing_page и referrer, ответ 200 с is_duplicate: true и номером существующей заявки. Уведомление при этом не отправляется.

Остальные поля повторного обращения (website, user_agent, status) не сохраняются. Поля самой существующей заявки не меняются: метки и ym_client_id повторного обращения лежат в его событии в ленте заявки, а не в карточке.

Телефон, в котором меньше 10 цифр (например, городской номер без кода города), для поиска повторного обращения не используется: по короткому хвосту он совпал бы с номером другого клиента. Такая заявка сверяется только по email.

Ответы

Новая заявка, 201 Created:

{ "success": true, "data": { "lead_number": 128, "is_duplicate": false, "message": "Заявка создана" } }

Повторное обращение, 200 OK:

{ "success": true, "data": { "lead_number": 97, "is_duplicate": true, "message": "Повторное обращение добавлено к существующей заявке" } }

Пример: curl

curl -X POST https://crm.linkodium.com/api/v1/leads \
  -H "X-API-Key: $CRM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Анна",
        "phone": "+7 999 123-45-67",
        "email": "anna@example.com",
        "message": "Нужна консультация",
        "source": "Сайт example.com",
        "form_page": "https://example.com/contacts/",
        "utm_source": "yandex",
        "utm_campaign": "brand"
      }'

Пример: PHP

<?php
// Ключ храните в настройках сервера, не в коде страницы.
$apiKey = getenv('CRM_API_KEY');

$lead = [
    'name'       => $_POST['name'] ?? '',
    'phone'      => $_POST['phone'] ?? '',
    'email'      => $_POST['email'] ?? '',
    'message'    => $_POST['message'] ?? '',
    'source'     => 'Сайт example.com',
    'form_page'  => $_SERVER['HTTP_REFERER'] ?? '',
    'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
    'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
];

$ch = curl_init('https://crm.linkodium.com/api/v1/leads');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode($lead, JSON_UNESCAPED_UNICODE),
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'X-API-Key: ' . $apiKey],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 15,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$resp = json_decode((string)$body, true);

// 201 - новая заявка, 200 - повторное обращение: оба случая означают «заявка у менеджера».
if (in_array($code, [200, 201], true) && !empty($resp['success'])) {
    $number = $resp['data']['lead_number'];
} else {
    // Заявку не потеряйте: сохраните её у себя или отправьте письмом и повторите позже.
    error_log('CRM: HTTP ' . $code . ' ' . $body);
}

7. Телефония: POST /api/v1/calls

Универсальный приём звонков от любой АТС. Прослойку между АТС и CRM пишете вы (или берёте готовую), она переводит события АТС в вызовы этого метода. Пример для Novofon и Zadarma в разделе 9.

Модель

Звонок в CRM определяется парой «компания + call_id», где call_id это идентификатор звонка на стороне АТС. Каждое событие звонка (начало, ответ, конец, готовая запись) присылайте отдельным запросом с одним и тем же call_id:

  • Первый запрос с новым call_id заводит звонок в ленте заявки.
  • Следующие запросы с тем же call_id дописывают поля звонка: итог, длительность, время, запись. Непереданные и пустые поля сохраняют прежние значения.
  • Повтор одного и того же запроса ничего не портит. Если не получили ответ, смело отправляйте ещё раз.

Хранить у себя соответствие «звонок АТС -> заявка CRM» не нужно: заявку по номеру клиента находит сама CRM.

Как CRM находит или создаёт заявку

При первом событии звонка (в компании ещё нет звонка с этим call_id):

  1. Нужен client_phone, номер клиента. Без него ответ 404 CALL_NOT_FOUND: звонок не заведён и ничего не сохранено. Так штатно выглядит, например, событие готовой записи исходящего звонка, который не сохранялся (см. ниже), или событие, которое пришло раньше начала звонка. Некорректный номер (меньше 7 цифр) в первом событии: 400.
  2. CRM ищет активную заявку компании (этап «Новая заявка» или «В работе», не в архиве) с этим номером (сравниваются последние 10 цифр; номер короче 10 цифр активную заявку не находит). Нашла: звонок попадает в неё.
  3. Не нашла:
    • входящий звонок создаёт новую заявку: телефон клиента, источник source (по умолчанию «Звонок»), услуга service, ответственный менеджер по умолчанию. Сотрудники получают уведомление «Новая заявка» по своим настройкам;
    • исходящий звонок создаёт заявку, только если в компании включена настройка Настройки компании -> «Исходящий звонок на номер без заявки создаёт новую заявку». Если выключена (так по умолчанию), звонок не сохраняется, ответ 200 с skipped: true.

Отдельных уведомлений о звонках нет: звонок виден в ленте заявки.

Поля

ПолеОбязательноОписание
call_idдаИдентификатор звонка в АТС: от 1 до 100 символов из латинских букв, цифр и _ . -, хотя бы одна буква или цифра
client_phoneв первом событииНомер клиента (не меньше 7 цифр). В последующих событиях можно не передавать; некорректное значение (например, anonymous) в них игнорируется
directionнетin (входящий, по умолчанию) или out (исходящий). Принимаются также incoming, outgoing, inbound, outbound. Другое значение: ошибка 400
lineнетВаш номер или линия: куда звонил клиент (для входящего) или с какого номера звонили (для исходящего). До 100 символов
statusнетИтог звонка, см. словарь ниже. До 100 символов
durationнетДлительность разговора в секундах, целое число от 0 до 604800
started_atнетВремя начала звонка в ISO-8601 со смещением часового пояса, например 2026-09-28T14:05:00+03:00 или 2026-09-28T11:05:00Z. Время без смещения отклоняется с ошибкой 400
sourceнетИсточник для новой заявки (по умолчанию «Звонок»)
serviceнетУслуга для новой заявки, любой текст
record_urlнетСсылка на запись разговора, см. ниже

Словарь status (регистр и разделители не важны: no answer, no_answer, NO-ANSWER одинаковы):

ЗначениеВ карточке
answeredПринят
no answerНет ответа
missedПропущен
busyЗанято
cancel, canceled, cancelledОтменён
failedОшибка
congestionПерегрузка
unallocated numberНомер не существует
no moneyНедостаточно средств
unallowed destinationНаправление запрещено

Другое значение показывается в карточке как есть.

Время и часовой пояс

CRM хранит время в UTC и показывает каждому сотруднику в его часовом поясе. Поэтому started_at обязан содержать смещение: 2026-09-28 14:05:00 без смещения можно прочитать по-разному, и в карточке оказалось бы неверное время. Если АТС присылает время без пояса, прослойка должна дописать пояс, в котором работает АТС (обычно он задан в настройках кабинета АТС). Пример в разделе 9.

Запись разговора ссылкой

Если АТС отдаёт запись по ссылке, передайте её в record_url (в том же событии или отдельным запросом с тем же call_id). CRM поставит запись в очередь, скачает её в течение нескольких минут и прикрепит к звонку.

Требования к ссылке:

  • схема http или https, порт 80 или 443, длина до 2000 символов;
  • доступна из интернета без авторизации (подписанная ссылка АТС подходит). Логин и пароль в самой ссылке (https://user:pass@...) не принимаются;
  • ведёт в интернет: IP-адрес в ссылке допустим только публичный (IPv6 только из глобального диапазона 2000::/3), имена внутренней сети (localhost, имя без точки, домены .local, .internal и подобные) не принимаются. Ссылки во внутреннюю сеть CRM не скачивает;
  • живёт не меньше нескольких часов: при временной ошибке CRM повторяет скачивание с растущими паузами, на все попытки уходит от получаса и дольше. Если АТС позволяет задать срок жизни ссылки, задайте сутки;
  • сервер записи присылает начало ответа (заголовки) не позже чем через 60 секунд, в том числе на каждом редиректе, и отдаёт файл целиком не дольше чем за 10 минут. Если в ответе есть заголовок Content-Length, CRM через 30 секунд после начала передачи прикидывает, успеет ли файл докачаться за эти 10 минут, и прерывает попытку, если не успеет; без Content-Length средняя скорость отдачи должна быть не ниже 8 КБ/с. Запись, которая не укладывается в эти пределы ни в одной из 5 попыток, к звонку не прикрепляется;
  • формат mp3, wav, ogg, webm, m4a или aac, размер до 200 МБ.

Повтор той же ссылки для того же звонка не ставит вторую загрузку (ответ recording: "duplicate").

Если запись по ссылке принять нельзя, звонок и заявка всё равно сохраняются: ответ обычный (201 или 200), а в нём recording: "rejected" и поле recording_error с причиной:

recording_error.codeПричинаЧто делать
VALIDATION_ERRORСсылка не подходит под требования выше, подробности в recording_error.detailsИсправить ссылку и прислать её тем же call_id
QUOTA_EXCEEDEDХранилище компании заполненоОсвободить место, затем прислать ссылку снова
QUEUE_FULLОчередь записей переполнена (раздел 5)Прислать ссылку тем же call_id через несколько минут

Проверяйте поле recording в ответе: если запись важна, а пришло rejected, сохраните ссылку у себя и повторите позже или загрузите файл методом из раздела 8.

Если ссылки нет, а файл есть, загрузите его методом из раздела 8.

Ответы

Звонок заведён этим запросом, 201 Created:

{ "success": true, "data": { "lead_number": 128, "call_id": "1727517900.1234", "created_lead": true } }

Звонок уже был, поля дописаны, 200 OK:

{ "success": true, "data": { "lead_number": 128, "call_id": "1727517900.1234", "created_lead": false, "recording": "queued" } }
  • created_lead: true, если этот запрос создал новую заявку;
  • recording (только если передан record_url): queued поставлена в очередь, duplicate эта ссылка уже была, rejected запись не принята (звонок при этом сохранён);
  • recording_error (только при recording: "rejected"): code, message и, для негодной ссылки, details.

Запись не принята, звонок сохранён, 200 OK:

{
  "success": true,
  "data": {
    "lead_number": 128,
    "call_id": "1727517900.1234",
    "created_lead": false,
    "recording": "rejected",
    "recording_error": {
      "code": "VALIDATION_ERROR",
      "message": "Ссылка на запись не принята",
      "details": ["record_url: допустим только порт 80 или 443"]
    }
  }
}

Исходящий звонок на номер без заявки при выключенной настройке, 200 OK:

{ "success": true, "data": { "lead_number": null, "call_id": "out-777", "skipped": true, "reason": "no_lead_for_outgoing" } }

Пример: входящий звонок

Начало звонка:

curl -X POST https://crm.linkodium.com/api/v1/calls \
  -H "X-API-Key: $CRM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "call_id": "1727517900.1234",
        "direction": "in",
        "client_phone": "+79991234567",
        "line": "+73422000000",
        "started_at": "2026-09-28T14:05:00+05:00",
        "source": "Реклама Яндекс"
      }'

Конец звонка (тот же call_id):

curl -X POST https://crm.linkodium.com/api/v1/calls \
  -H "X-API-Key: $CRM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "call_id": "1727517900.1234", "status": "answered", "duration": 185 }'

Готовая запись:

curl -X POST https://crm.linkodium.com/api/v1/calls \
  -H "X-API-Key: $CRM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "call_id": "1727517900.1234", "record_url": "https://records.example-pbx.com/abc123.mp3" }'

Пример: PHP

<?php
function crmCall(array $fields): array
{
    $ch = curl_init('https://crm.linkodium.com/api/v1/calls');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($fields, JSON_UNESCAPED_UNICODE),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'X-API-Key: ' . getenv('CRM_API_KEY')],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
    ]);
    $body = curl_exec($ch);
    return [curl_getinfo($ch, CURLINFO_HTTP_CODE), json_decode((string)$body, true)];
}

[$code, $resp] = crmCall([
    'call_id'      => '1727517900.1234',
    'direction'    => 'in',
    'client_phone' => '+79991234567',
    'started_at'   => (new DateTimeImmutable('now'))->format(DATE_ATOM), // со смещением
]);

8. Запись звонка файлом: POST /api/v1/calls/recording

Для АТС, которые не дают публичную ссылку на запись: прослойка скачивает файл сама и отправляет его в CRM.

  • Тело: только multipart/form-data с полями call_id и file. JSON или форма без файла: 400 VALIDATION_ERROR (ссылку на запись передавайте в /api/v1/calls, поле record_url).
  • Звонок с этим call_id уже должен быть передан в /api/v1/calls, иначе 404 CALL_NOT_FOUND. По одному файлу CRM заявку не создаёт: в файле нет номера клиента.
  • Форматы: mp3, wav, ogg, webm, m4a, aac. CRM проверяет содержимое файла: файл, в котором распознан другой тип (HTML, картинка, архив, документ), отклоняется с ошибкой 400. Если тип по содержимому определить нельзя (так бывает у mp3 без заголовка), решает расширение имени файла из списка выше. Пустой файл: 400.
  • Размер: до 100 МБ, больше: 413 PAYLOAD_TOO_LARGE.
  • Место в хранилище компании: если его не хватает, 403 QUOTA_EXCEEDED.
  • Одновременно с одного IP-адреса: до 8 загрузок, сверх этого 429 RATE_LIMITED с Retry-After.
  • Повтор того же файла (то же содержимое) для того же звонка второй записи не создаёт: ответ 200 с duplicate: true и данными ранее принятой записи. Имя файла при этом значения не имеет.
  • У одного звонка может быть несколько записей (например, разговор в двух частях или каналы клиента и оператора отдельными файлами). Разные файлы принимаются все, даже с одинаковыми именем и размером. Имена всё же лучше давать разные (call-1727517900.1234-1.mp3, ...-2.mp3): под этим именем запись видят и скачивают менеджеры.
  • Если расширение в имени файла не совпадает с форматом, который CRM определила по содержимому, в имени оно заменяется на верное (поле file_name ответа).

Ответ 201 Created:

{
  "success": true,
  "data": {
    "lead_number": 128,
    "call_id": "1727517900.1234",
    "recording": { "id": "5d0c7a4e-8f3b-4f5e-9a51-2b7f0c1d9e20", "file_name": "call.mp3", "size": 482113, "type": "audio/mpeg" }
  }
}

Повтор уже принятого файла, 200 OK:

{
  "success": true,
  "data": {
    "lead_number": 128,
    "call_id": "1727517900.1234",
    "duplicate": true,
    "recording": { "id": "5d0c7a4e-8f3b-4f5e-9a51-2b7f0c1d9e20", "file_name": "call.mp3", "size": 482113, "type": "audio/mpeg" }
  }
}

Пример: curl

curl -X POST https://crm.linkodium.com/api/v1/calls/recording \
  -H "X-API-Key: $CRM_API_KEY" \
  -F "call_id=1727517900.1234" \
  -F "file=@/tmp/call-1727517900.1234.mp3"

Пример: PHP

<?php
$callId = '1727517900.1234';
$part   = 1; // номер части, если АТС режет разговор на несколько файлов

$ch = curl_init('https://crm.linkodium.com/api/v1/calls/recording');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => [
        'call_id' => $callId,
        'file'    => new CURLFile('/tmp/call.mp3', 'audio/mpeg', 'call-' . $callId . '-' . $part . '.mp3'),
    ],
    CURLOPT_HTTPHEADER     => ['X-API-Key: ' . getenv('CRM_API_KEY')],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 120,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

// 201 - принята, 200 - этот файл уже был. 429, 5xx и обрыв связи: повторить позже.
if (!in_array($code, [200, 201], true)) {
    error_log('CRM recording: HTTP ' . $code . ' ' . $body);
}

9. Пример прослойки для Novofon и Zadarma

Novofon и Zadarma сообщают о звонках вебхуками (уведомлениями о событиях АТС) на адрес, который вы указываете в кабинете АТС. Прослойка это небольшой скрипт на вашем сервере: принимает вебхук, переводит его в вызов /api/v1/calls и сразу отвечает АТС.

Ниже учебный пример. Проверьте названия полей и событий по документации своей АТС: они могут отличаться в зависимости от тарифа и версии API.

Соответствие событий

Событие АТСПоля АТСВызов CRM
NOTIFY_START (входящий, начало)pbx_call_id, caller_id, called_did, call_startcall_id = pbx_call_id, direction = in, client_phone = caller_id, line = called_did, started_at = call_start с поясом
NOTIFY_END (входящий, конец)pbx_call_id, caller_id, called_did, duration, disposition, call_startcall_id = pbx_call_id, direction = in, client_phone = caller_id, status = disposition, duration = duration
NOTIFY_OUT_START (исходящий, начало)pbx_call_id, destination, internal, call_startcall_id = pbx_call_id, direction = out, client_phone = destination, started_at = call_start с поясом
NOTIFY_OUT_END (исходящий, конец)pbx_call_id, destination, caller_id, duration, dispositioncall_id = pbx_call_id, direction = out, client_phone = destination, line = caller_id, status = disposition, duration = duration
NOTIFY_RECORD (запись готова)pbx_call_id, call_id_with_recссылку получить методом API АТС pbx/record/request, затем call_id = pbx_call_id, record_url = ссылка

Важно:

  • В call_id передавайте pbx_call_id: он одинаков у всех событий одного звонка. call_id_with_rec нужен только для запроса ссылки на запись.
  • client_phone передавайте в каждом событии, где номер есть: если первое событие потеряется, звонок заведёт следующее.
  • call_start у этих АТС приходит без часового пояса, во времени, заданном в кабинете АТС. Прослойка должна дописать этот пояс (в примере константа PBX_TIMEZONE).
  • Ссылку на запись АТС готовит не сразу; Novofon советует запрашивать её примерно через 40 секунд после уведомления.
  • Ответ 404 CALL_NOT_FOUND на NOTIFY_RECORD штатен для исходящего звонка, если в компании выключена настройка «Исходящий звонок на номер без заявки создаёт новую заявку»: такой звонок не сохранялся, и запись к нему прикладывать некуда.
  • Если в ответе recording: "rejected", запись не принята, хотя звонок сохранён. Причину смотрите в recording_error (раздел 7).

Пример прослойки (PHP)

<?php
// Прослойка АТС -> Linkodium CRM. Адрес этого скрипта укажите в кабинете АТС
// как адрес уведомлений о звонках.

const CRM_URL      = 'https://crm.linkodium.com/api/v1/calls';
const PBX_TIMEZONE = 'Europe/Moscow'; // пояс из настроек кабинета АТС

// Проверка адреса при подключении уведомлений (Novofon, Zadarma).
if (isset($_GET['zd_echo'])) {
    exit(preg_replace('/[^A-Za-z0-9]/', '', (string)$_GET['zd_echo']));
}
// Здесь же стоит проверить подпись уведомления (заголовок Signature)
// по документации АТС: иначе прислать «звонок» сможет кто угодно.

// $okCodes - какие ответы кроме 200/201 для этого события норма (не ошибка).
function crmCall(array $fields, array $okCodes = []): void
{
    $ch = curl_init(CRM_URL);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(array_filter($fields, fn($v) => $v !== '' && $v !== null),
                                              JSON_UNESCAPED_UNICODE),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'X-API-Key: ' . getenv('CRM_API_KEY')],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $resp = json_decode((string)$body, true);
    if (!in_array($code, array_merge([200, 201], $okCodes), true)) {
        // Ответ и тело сохраните в журнал: по ним видно, что не так с запросом.
        // 429, 5xx и обрыв связи стоит повторить позже (раздел 4).
        error_log('CRM calls: HTTP ' . $code . ' ' . $body);
    } elseif (($resp['data']['recording'] ?? '') === 'rejected') {
        // Звонок сохранён, запись нет: причина в recording_error (раздел 7).
        error_log('CRM calls: запись не принята ' . json_encode($resp['data']['recording_error'] ?? []));
    }
}

// Время АТС «2026-09-28 14:05:00» -> «2026-09-28T14:05:00+03:00».
function pbxTime(string $raw): string
{
    $dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new DateTimeZone(PBX_TIMEZONE));
    return $dt ? $dt->format(DATE_ATOM) : '';
}

$p = $_POST;

switch ($p['event'] ?? '') {
    case 'NOTIFY_START':
        crmCall([
            'call_id'      => $p['pbx_call_id'] ?? '',
            'direction'    => 'in',
            'client_phone' => $p['caller_id'] ?? '',
            'line'         => $p['called_did'] ?? '',
            'started_at'   => pbxTime($p['call_start'] ?? ''),
            // Источник по номеру линии: каждой рекламной линии свой.
            'source'       => ['73422000000' => 'Сайт', '79670000000' => 'Реклама'][$p['called_did'] ?? ''] ?? 'Звонок',
        ]);
        break;

    case 'NOTIFY_END':
        crmCall([
            'call_id'      => $p['pbx_call_id'] ?? '',
            'direction'    => 'in',
            'client_phone' => $p['caller_id'] ?? '',
            'line'         => $p['called_did'] ?? '',
            'status'       => $p['disposition'] ?? '',
            'duration'     => (int)($p['duration'] ?? 0),
            'started_at'   => pbxTime($p['call_start'] ?? ''),
        ]);
        break;

    case 'NOTIFY_OUT_START':
        crmCall([
            'call_id'      => $p['pbx_call_id'] ?? '',
            'direction'    => 'out',
            'client_phone' => $p['destination'] ?? '',
            'started_at'   => pbxTime($p['call_start'] ?? ''),
        ]);
        break;

    case 'NOTIFY_OUT_END':
        crmCall([
            'call_id'      => $p['pbx_call_id'] ?? '',
            'direction'    => 'out',
            'client_phone' => $p['destination'] ?? '',
            'line'         => $p['caller_id'] ?? '',
            'status'       => $p['disposition'] ?? '',
            'duration'     => (int)($p['duration'] ?? 0),
            'started_at'   => pbxTime($p['call_start'] ?? ''),
        ]);
        break;

    case 'NOTIFY_RECORD':
        // Ссылку на запись выдаёт API АТС (метод pbx/record/request, параметр
        // call_id = call_id_with_rec, срок жизни ссылки lifetime). Используйте
        // официальную библиотеку АТС; pbxRecordLink() здесь условная функция.
        $url = pbxRecordLink($p['call_id_with_rec'] ?? '', $p['pbx_call_id'] ?? '');
        if ($url) {
            // 404: звонок не сохранялся (исходящий при выключенной настройке).
            crmCall(['call_id' => $p['pbx_call_id'] ?? '', 'record_url' => $url], [404]);
        }
        break;
}

header('Content-Type: application/json');
echo json_encode(['success' => true]);

Если ссылку на запись получить нельзя, но файл скачать можно, отправьте его методом из раздела 8 с тем же call_id.

10. Переход со старых адресов

Интеграции Linkodium, подключённые до выхода API v1, пользуются старыми адресами без ключа: /api/lead.add (и /api/leads), /api/lead.call, /api/call.record. Они работают, пока идёт перевод интеграций на v1, и будут отключены. Новые интеграции подключайте только к v1.

БылоСтало
POST /api/lead.add без ключа, в ответе lead_idPOST /api/v1/leads с ключом, в ответе lead_number
POST /api/lead.call с lead_id, phone, called_did, disposition, call_start без поясаPOST /api/v1/calls с call_id, client_phone, line, status, started_at с поясом. lead_id не нужен: заявку находит CRM
POST /api/call.record с lead_id, record_urlrecord_url в POST /api/v1/calls с тем же call_id
Прослойка хранит у себя соответствие «звонок -> заявка»Не нужно

До отключения старые адреса принимают и ключ: с заголовком X-API-Key вызов попадает в компанию ключа. Ключ, который не прошёл проверку, даёт 401 UNAUTHORIZED: в режим без ключа такой запрос не переходит.

Отличия старого /api/call.record от записи ссылкой в v1: этот адрес принимает только запись, поэтому отказ отвечает на весь запрос. Негодная ссылка (требования те же, что в разделе 7): 400 VALIDATION_ERROR с причиной в error.details; хранилище заполнено: 403 QUOTA_EXCEEDED; очередь переполнена: 429 QUEUE_FULL с заголовком Retry-After.