Когда в портале происходит выбранное событие (создали сделку, изменили контакт, добавили задачу), исходящий вебхук Битрикс24 отправляет POST-запрос на ваш адрес. Сам вебхук методы REST не вызывает. Обработчик получает код события, ID объекта и служебный блок auth, а остальные данные читает отдельным запросом.
Короткий ответ:
- Данные приходят формой
application/x-www-form-urlencoded, в PHP это обычный$_POST. Все значения в нём строки, ID сделки приходит как'662'. - Подлинность запроса проверяют по полю
auth[application_token]: оно должно совпасть с «Токеном приложения» из настроек вебхука. Не совпало — ответ 403. - Отвечать нужно сразу. Медленному обработчику Битрикс24 реже отправляет события, а событие, на которое сервер ответил ошибкой, повторно не придёт.
- Полные данные сделки читают методом
crm.item.getсentityTypeId = 2через входящий вебхук.
Ниже обработчик, который на стенде (nginx 1.26.3, PHP-FPM 8.4.26) прошёл 27 запросов, и три типовые ошибки в проверке токена: одна роняет скрипт, две другие пропускают запрос без токена.
Рядом по теме: REST API Bitrix24 на PHP: авторизация и вебхуки, QUERY_LIMIT_EXCEEDED и лимиты REST, cron: расписание и блокировки.
Как создать исходящий вебхук и какой адрес ему нужен
Вебхук создают в разделе «Приложения > Разработчикам», указывают URL обработчика и события. После сохранения в форме появляется токен, по которому обработчик узнаёт свой портал.
Порядок из документации Битрикс24:
- Откройте «Приложения > Разработчикам».
- Перейдите на вкладку «Готовые сценарии > Другое > Исходящий вебхук».
- Укажите URL обработчика.
- Выберите события, например
ONCRMDEALUPDATE(изменение сделки). - Сохраните вебхук. Поле «Токен приложения» заполнится автоматически, его значение понадобится обработчику.
Адрес обработчика должен открываться из интернета и принимать POST. Не подойдут localhost, адреса локальной сети и HTTPS с самоподписанным сертификатом. В коробочной версии для исходящего вебхука нужна активная лицензия и открытые сетевые доступы, в демо-режиме он недоступен.
Вебхук получает только онлайн-события из своего списка. Офлайн-события, которые можно забирать из очереди самому, доступны приложению с подпиской через event.bind. Если без них не обойтись, нужно приложение для Bitrix24 на PHP.
Что приходит в обработчик
Битрикс24 отправляет обычную форму. PHP сам раскладывает её во вложенный массив $_POST, и все значения в нём строки, даже ID и время.
Тело запроса изменения сделки по образцу из документации (токен — пример оттуда же, поля auth сокращены):
event=ONCRMDEALUPDATE&event_handler_id=975&data%5BFIELDS%5D%5BID%5D=662&ts=1724140800&auth%5Bdomain%5D=example.bitrix24.ru&...&auth%5Bapplication_token%5D=jvh9y1ulvt2m6k5or90v9mg8nn32ozas
Что из этого получил PHP на стенде (вывод var_export($_POST), сокращён):
array (
'event' => 'ONCRMDEALUPDATE',
'event_handler_id' => '975',
'data' => array ( 'FIELDS' => array ( 'ID' => '662' ) ),
'ts' => '1724140800',
'auth' => array (
'domain' => 'example.bitrix24.ru',
'client_endpoint' => 'https://example.bitrix24.ru/rest/',
'server_endpoint' => 'https://oauth.bitrix24.tech/rest/',
'member_id' => 'd897063e1ce7c5eb9f04b9751eef5915',
'application_token' => 'jvh9y1ulvt2m6k5or90v9mg8nn32ozas',
),
)
| Поле | Что в нём |
|---|---|
event | код события в верхнем регистре: ONCRMDEALADD, ONCRMDEALUPDATE |
event_handler_id | ID подписки, удобная метка для логов |
data[FIELDS][ID] | ID объекта, для событий CRM он обычно здесь |
ts | время в формате Unix, в секундах |
auth | domain, client_endpoint, server_endpoint, member_id, application_token |
OAuth-токенов в запросе к исходящему вебхуку нет: по документации блок auth у него состоит только из этих пяти ключей. Вызвать REST с такими данными нельзя, для чтения сделки нужен отдельный входящий вебхук.
Стенд сразу показал две ловушки:
$_POST['data']['FIELDS']['ID'] === 662даётfalse, потому что там строка. Приводите к числу:(int) $_POST['data']['FIELDS']['ID'].- Если проверять обработчик JSON-запросом из Postman,
$_POSTокажется пустым, а тело останется вphp://input. Битрикс24 шлёт форму, поэтому разбирать JSON в обработчике не нужно. Для проверки тоже отправляйте форму.
Команда для ручной проверки. Методы event.bind и event.test из инструкции Битрикс24 работают только с авторизацией приложения и через вебхук вернут WRONG_AUTH_TYPE, так что для вебхука остаётся curl и тестовое изменение сделки:
curl -i -X POST https://example.com/b24/handler.php \
--data-urlencode 'event=ONCRMDEALUPDATE' \
--data-urlencode 'data[FIELDS][ID]=662' \
--data-urlencode 'auth[application_token]=ваш_токен'
Как проверить application_token и не открыть дыру
Сравнивайте auth[application_token] с сохранённым токеном через hash_equals, но сначала убедитесь, что эталон задан и что пришло строковое значение. Без этих двух условий проверка либо падает с 500, либо пропускает запрос без токена.
Документация описывает проверку так: сравнить пришедший токен с сохранённым, при несовпадении вернуть 403 и завершить обработку. Токен хранят как секрет: не пишут в логи и не выводят в сообщениях об ошибках. Если один обработчик принимает события от нескольких порталов, токен ищут по auth[member_id].
Для сравнения секретов php.net советует hash_equals: она сравнивает строки так, что по времени ответа нельзя подобрать известное значение. Короткая проверка, которую легко написать с первого раза:
if (!hash_equals(getenv('B24_APP_TOKEN'), $_POST['auth']['application_token'] ?? '')) {
http_response_code(403);
exit;
}
На стенде у этой проверки и у двух похожих вариантов нашлись слабые места:
| Что сделали | Ответ | Почему |
|---|---|---|
прислали токен массивом: auth[application_token][]=x | 500, Uncaught TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, array given | hash_equals принимает только строки, а [] в имени поля PHP превращает в массив |
не задали переменную B24_APP_TOKEN | 500, Argument #1 ($known_string) must be of type string, false given | getenv вернул false |
обернули эталон в (string) getenv(...), переменной нет, запрос без токена | 200, запрос принят | сравниваются две пустые строки |
сравнили через !=, переменной нет, запрос без токена | 200, запрос принят | при нестрогом сравнении '' и false равны |
Последние две строки опаснее всего. После переезда на новый сервер или опечатки в конфиге пула обработчик молча принимает события от кого угодно. Пустой эталон проверяют отдельно и до сравнения. В официальном примере Битрикс24 так и сделано: условие $storedApplicationToken === '' стоит первым.
Обработчик: проверить, записать, ответить
До ответа обработчик делает минимум: проверяет токен, метод и ID, дописывает событие в файл очереди и отвечает 200. Запросы к REST делает отдельный скрипт по cron.
<?php
declare(strict_types=1);
// Эталон из поля «Токен приложения» исходящего вебхука
$expected = (string) getenv('B24_APP_TOKEN');
if ($expected === '') {
error_log('b24 handler: B24_APP_TOKEN is empty');
http_response_code(500);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
header('Allow: POST');
http_response_code(405);
exit;
}
$token = $_POST['auth']['application_token'] ?? null;
if (!is_string($token) || !hash_equals($expected, $token)) {
http_response_code(403);
exit;
}
$event = $_POST['event'] ?? '';
$id = (int) ($_POST['data']['FIELDS']['ID'] ?? 0);
if (!is_string($event) || $event === '' || $id <= 0) {
http_response_code(400);
exit;
}
// Записать событие и сразу ответить Битрикс24
$job = [
'event' => $event,
'id' => $id,
'ts' => (int) ($_POST['ts'] ?? 0),
'handler_id' => (string) ($_POST['event_handler_id'] ?? ''),
'received' => microtime(true),
];
$saved = file_put_contents(getenv('B24_QUEUE_FILE') ?: '/var/lib/b24/queue.jsonl',
json_encode($job) . "\n", FILE_APPEND | LOCK_EX);
if ($saved === false) {
http_response_code(500); // причина будет в error_log PHP
exit;
}
http_response_code(200);
echo 'OK';
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // nginx уже получил ответ
}
// Дальше можно работать без Битрикс24, но долгую работу лучше отдать worker.php
Токен и путь к очереди лежат в переменных окружения пула PHP-FPM. По умолчанию FPM очищает окружение рабочих процессов (clear_env = yes), поэтому переменные задают в конфиге пула. На Debian и Ubuntu это /etc/php/<версия>/fpm/pool.d/www.conf:
env[B24_APP_TOKEN] = значение_поля_Токен_приложения
env[B24_QUEUE_FILE] = /var/lib/b24/queue.jsonl
После правки перезапустите PHP-FPM. Файл очереди держите вне корня сайта, и у пользователя пула должно быть право на запись в его каталог. Сам токен в очередь не попадает.
Как обработчик ответил на стенде:
| Запрос | Ответ |
|---|---|
| форма с верным токеном | 200 OK меньше чем за 2 мс |
чужой токен, без токена, токен массивом, auth=abc строкой | 403 |
| то же событие JSON-ом с верным токеном | 403 |
верный токен, нет data[FIELDS][ID] | 400 |
| GET, например адрес открыли в браузере | 405 |
переменная B24_APP_TOKEN не задана | 500 и строка B24_APP_TOKEN is empty в логе PHP |
| файл очереди недоступен для записи | 500 и Failed to open stream: Permission denied в логе PHP |
Если обработчик отдаёт 500, первым делом смотрите лог ошибок PHP. Как его включить на сайте с Битриксом, описано в статье про логирование ошибок PHP.
Почему обработчик должен отвечать быстро
События идут через сервер очередей Битрикс24. Медленному обработчику он отправляет события реже, а событие, которое не приняли, повторно не шлёт. Поэтому обработчик отвечает сразу, а работу откладывает.
В документации:
- Битрикс24 не вызывает обработчик напрямую. Событие сначала попадает в очередь, поэтому запрос может прийти с небольшой задержкой.
- Сервер следит за скоростью ответа. Если обработчик отвечает медленно, интервалы между запросами растут.
- Повторных отправок нет. Если сервер не ответил или вернул ошибку, очередь зафиксирует сбой, но событие не отправит.
- Нагрузку регулировать нельзя. Если одновременно изменили тысячу сделок, обработчик получит тысячу вызовов.
На стенде тот же обработчик после ответа две секунды «работал» (sleep(2)):
| Как запущен PHP | Ответ получен через | Работа закончилась |
|---|---|---|
nginx + PHP-FPM, вызван fastcgi_finish_request() | меньше 1 мс | через 2 с после приёма |
встроенный сервер php -S | 2,0 с | через 2 с после приёма |
fastcgi_finish_request() отдаёт ответ клиенту и даёт скрипту доработать. В CLI на стенде function_exists('fastcgi_finish_request') вернул false, под php -S ответ пришёл только после паузы, поэтому вызов обёрнут в function_exists. Процесс PHP-FPM при этом остаётся занятым до конца скрипта. Если тысяча событий займёт все процессы пула на несколько секунд, nginx начнёт отдавать 502 и 504. Поэтому долгую работу лучше вынести в отдельный скрипт.
Как получить полные данные сделки через crm.item.get
worker.php по cron забирает события из очереди и для каждого вызывает crm.item.get через входящий вебхук. Ответ приходит в result.item, ошибка — в поле error.
Входящий вебхук создают там же, на вкладке «Готовые сценарии > Другое > Входящий вебхук», с правами на CRM. Как устроен его URL — в статье про REST API Bitrix24 на PHP.
<?php
declare(strict_types=1);
// Запускается по cron: забирает события из очереди и читает сделки через REST
$queueFile = getenv('B24_QUEUE_FILE') ?: '/var/lib/b24/queue.jsonl';
$restUrl = rtrim((string) getenv('B24_REST_URL'), '/') . '/'; // URL входящего вебхука
$fp = fopen($queueFile, 'c+');
if ($fp === false || !flock($fp, LOCK_EX)) {
exit(1);
}
$lines = stream_get_contents($fp);
ftruncate($fp, 0);
flock($fp, LOCK_UN);
fclose($fp);
foreach (array_filter(explode("\n", (string) $lines)) as $line) {
$job = json_decode($line, true);
if (!isset($job['event'], $job['id'])) {
continue;
}
$ch = curl_init($restUrl . 'crm.item.get.json');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['entityTypeId' => 2, 'id' => $job['id']]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
]);
$answer = json_decode((string) curl_exec($ch), true);
curl_close($ch);
if (isset($answer['error'])) {
error_log("b24 worker: deal {$job['id']}: {$answer['error']}");
continue;
}
$deal = $answer['result']['item'] ?? null;
if ($deal === null) {
continue;
}
// Здесь своя логика: сохранить сделку, отправить в учётную систему и т.д.
echo $job['event'], ' ', $deal['id'], ' ', $deal['title'], ' ', $deal['stageId'], "\n";
}
На стенде вместо портала отвечала заглушка в формате ответа crm.item.get из документации. Из двух событий в очереди одно прочиталось, на второе пришла ошибка:
ONCRMDEALUPDATE 662 Сделка #662 NEW
b24 worker: deal 999: NOT_FOUND
Запись в crontab, чтобы два запуска не работали одновременно (flock -n пропускает запуск, пока занята блокировка):
* * * * * B24_QUEUE_FILE=/var/lib/b24/queue.jsonl B24_REST_URL=https://example.bitrix24.ru/rest/1/xxxxxxxx/ flock -n /tmp/b24-worker.lock php /path/to/worker.php >> /var/log/b24-worker.log 2>&1
Что учесть:
entityTypeIdу системных типов: лид 1, сделка 2, контакт 3, компания 4, счёт 31.crm.item.getвозвращает 400 сNOT_FOUND, если сделки нет, иACCESS_DENIED, если у владельца входящего вебхука нет права её читать.- Когда событий много, worker упрётся в лимит частоты запросов: 503
QUERY_LIMIT_EXCEEDED. Пауза и повторы разобраны в статье про лимиты REST. - Этот worker очищает очередь до обработки, и событие с ошибкой остаётся только в логе. Если терять нельзя ни одно, пишите неудачные ID в отдельный файл и разбирайте его следующим запуском.
- URL входящего вебхука — такой же секрет, как токен. Не кладите его в git и в файлы внутри корня сайта.
Чем отличается исходящий вебхук в роботе
Робот или бизнес-процесс тоже умеет отправлять исходящий вебхук, но приходит другой набор полей, и application_token в нём нет. Проверка из обработчика событий такой запрос отклонит.
В блоге компании Битрикс24 на Хабре (5 октября 2026) опубликован лог запроса от робота сделки. В теле формы document_id[0]=crm, document_id[1]=CCrmDocumentDeal, document_id[2]=DEAL_22424 и четыре поля auth: domain, client_endpoint, server_endpoint, member_id. Поля event и application_token нет. В запросе от вебхука на событие в том же логе оба поля есть. Заголовок User-Agent в обоих случаях — Bitrix24 Webhook Engine.
В URL робота можно подставить поля сделки, например ?deal_id={{ID}}. Автор там же предупреждает, что чувствительные данные в URL передавать не стоит.
На стенде запрос в формате робота на обработчик событий получил 403, как и должен. Для робота нужен отдельный адрес. Вариант на стенде: длинный случайный ключ в URL, ID сделки берём из document_id[2].
<?php
declare(strict_types=1);
// Робот «Исходящий вебхук»: ключ передаём в URL обработчика, ?key=...
$expected = (string) getenv('B24_ROBOT_KEY');
$key = $_GET['key'] ?? null;
if ($expected === '' || !is_string($key) || !hash_equals($expected, $key)) {
http_response_code(403);
exit;
}
// document_id[2] вида DEAL_22424
$doc = $_POST['document_id'][2] ?? '';
if (!is_string($doc) || !preg_match('/^DEAL_(\d+)$/', $doc, $m)) {
http_response_code(400);
exit;
}
$dealId = (int) $m[1];
echo 'OK deal ', $dealId;
Ключ можно получить командой php -r 'echo bin2hex(random_bytes(16)), PHP_EOL;', это 32 символа. С ключом стенд ответил OK deal 22424, без ключа 403.
У ключа в URL есть ограничения. Его видят все, кто может редактировать роботов, и он оседает в access-логе веб-сервера: на стенде nginx записал URL целиком. От случайных запросов из интернета ключ защищает, от сотрудников с доступом к настройкам нет. Поэтому данным из запроса робота не доверяйте: возьмите ID и перечитайте сделку через crm.item.get.
Почему исходящий вебхук не срабатывает
Идите по цепочке: дошёл ли запрос до сервера, дошёл ли до PHP, прошла ли проверка, не упал ли скрипт. Первые два шага видны в access-логе веб-сервера.
| Что видно | Что проверить | Что делать |
|---|---|---|
| в access-логе нет запросов | адрес открывается из интернета, это не localhost и не локальная сеть, сертификат не самоподписанный; у коробки активна лицензия и открыты сетевые доступы | поправить адрес или сеть, повторить изменение сделки |
| в access-логе 301 или 302 | редирект с http на https, с домена без www, с каталога без слэша | указать в вебхуке конечный адрес: в списке проверок документации — принимать POST без редиректа |
| 403 от вашего обработчика | токен в конфиге совпадает с полем «Токен приложения» этого вебхука; запрос пришёл не от робота | обновить B24_APP_TOKEN, для робота сделать отдельный адрес |
| 500 | лог ошибок PHP: TypeError, пустой B24_APP_TOKEN, нет прав на файл очереди | исправить по тексту ошибки |
| 405 | кто-то открыл адрес GET-запросом | для Битрикс24 это не ошибка, он шлёт POST |
| события приходят с задержкой или реже | время ответа обработчика в логе | отвечать сразу, работу отдать worker |
| часть событий пропала | были ли 5xx или таймауты в момент изменения | повторов нет, пропущенное дочитать через REST |
| нужного события нет в списке вебхука | вебхуку доступны только онлайн-события из списка | приложение с event.bind |
На стенде POST на адрес каталога без слэша получил 301 от nginx, и скрипт вообще не запустился. Если в логе есть 301 на ваш обработчик, событие до PHP не дошло.
Найти запросы от Битрикс24 в логе nginx можно по заголовку из лога в блоге Битрикс24. Путь к логу зависит от сервера, ниже вариант по умолчанию:
grep 'Bitrix24 Webhook Engine' /var/log/nginx/access.log | tail -n 20
Что стенд не проверял
На стенде не было живого портала. Запросы собраны по образцу из документации и по логу из блога Битрикс24, REST отвечала заглушка в формате документации. Задержки очереди, снижение частоты вызовов и отсутствие повторов описаны по документации, мы их не измеряли. Сколько очередь ждёт ответа, прежде чем считать вызов неудачным, на страницах документации из списка источников не сказано.
Если интеграция с Битрикс24 теряет события или нужен обработчик под вашу схему, разбор и доработка на karoche.ru.
Источники
- Входящие и исходящие вебхуки — REST API Битрикс24
- События: обзор методов и событий — REST API Битрикс24
- Безопасность в обработчиках — REST API Битрикс24
- Как проверить свой обработчик для обработки событий Битрикс24 — REST API Битрикс24
- Получить элемент по Id crm.item.get — REST API Битрикс24
- hash_equals — руководство PHP
- fastcgi_finish_request — руководство PHP
- Настройка PHP-FPM — руководство PHP
- Исходящие вебхуки Битрикс24: как принять POST на своём сервере и что делать дальше — блог компании Битрикс24 на Хабре, 5 октября 2026
