Исходящий вебхук Битрикс24: обработчик на PHP и проверка токена

Когда в портале происходит выбранное событие (создали сделку, изменили контакт, добавили задачу), исходящий вебхук Битрикс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:

  1. Откройте «Приложения > Разработчикам».
  2. Перейдите на вкладку «Готовые сценарии > Другое > Исходящий вебхук».
  3. Укажите URL обработчика.
  4. Выберите события, например ONCRMDEALUPDATE (изменение сделки).
  5. Сохраните вебхук. Поле «Токен приложения» заполнится автоматически, его значение понадобится обработчику.

Адрес обработчика должен открываться из интернета и принимать 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_idID подписки, удобная метка для логов
data[FIELDS][ID]ID объекта, для событий CRM он обычно здесь
tsвремя в формате Unix, в секундах
authdomain, 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][]=x500, Uncaught TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, array givenhash_equals принимает только строки, а [] в имени поля PHP превращает в массив
не задали переменную B24_APP_TOKEN500, Argument #1 ($known_string) must be of type string, false givengetenv вернул 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 -S2,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.

Источники