REST в Битриксе — это два разных мира под одним названием. В Битрикс24 есть готовый развесистый API с OAuth и маркетплейсом приложений. В коробке — модуль rest, который даёт вам каркас, а всё остальное вы пишете сами: авторизацию, валидацию, версионирование, документацию.
Ниже — оба сценария и, что важнее, те решения по безопасности, которые в примерах из документации обычно опущены, а на боевом API стоят дорого.
REST API в Битрикс
Битрикс имеет встроенный REST API для интеграций. Можно использовать готовые методы или создавать свои.
Два режима:
- Битрикс24 — OAuth 2.0, приложения в маркетплейсе
- Коробка — входящие/исходящие вебхуки, модуль rest
Использование встроенного REST (Битрикс24)
Исходящий вебхук
Создайте вебхук в Настройки → Интеграции → REST API → Добавить вебхук.
<?php
// Вызов метода через вебхук.
// ВНИМАНИЕ: токен входит в состав URL — весь адрес является секретом
$webhookUrl = getenv('B24_WEBHOOK_URL');
// Получить сделки
$response = file_get_contents($webhookUrl . 'crm.deal.list?' . http_build_query([
'filter' => ['STAGE_ID' => 'NEW'],
'select' => ['ID', 'TITLE', 'OPPORTUNITY'],
]));
$deals = json_decode($response, true)['result'];URL вебхука Битрикс24 — это пароль. Токен зашит прямо в адрес, а значит адрес нельзя ни коммитить в репозиторий, ни писать в логи, ни передавать в мессенджере. Любой, у кого есть эта строка, получает доступ к CRM с правами создавшего вебхук.
Отсюда практические следствия: держите URL в переменной окружения, а в обработчиках ошибок логируйте имя метода, а не полный адрес запроса — иначе первая же неудачная интеграция запишет ваш ключ в error.log, который читают все. И заведите привычку отзывать вебхук, когда он больше не нужен: они не истекают сами.
file_get_contents для вызова API годится только для примера в статье. У него нет таймаута (запрос к недоступному серверу подвесит ваш скрипт на default_socket_timeout, обычно 60 секунд), нет доступа к коду ответа без разбора $http_response_header и он вообще не работает при выключенном allow_url_fopen. В рабочем коде — cURL, как в классе ниже, или Bitrix\Main\Web\HttpClient.
Класс для работы с REST
<?php
namespace Local\Bitrix24;
class RestClient
{
private string $webhookUrl;
public function __construct(string $webhookUrl)
{
$this->webhookUrl = rtrim($webhookUrl, '/') . '/';
}
public function call(string $method, array $params = []): array
{
$url = $this->webhookUrl . $method;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new \RuntimeException("HTTP Error: {$httpCode}");
}
$data = json_decode($response, true);
if (isset($data['error'])) {
throw new \RuntimeException($data['error_description'] ?? $data['error']);
}
return $data['result'] ?? $data;
}
public function batch(array $calls): array
{
$cmd = [];
foreach ($calls as $name => $call) {
$cmd[$name] = $call['method'] . '?' . http_build_query($call['params'] ?? []);
}
return $this->call('batch', ['cmd' => $cmd]);
}
}
// Использование
$client = new RestClient('https://your.bitrix24.ru/rest/1/xxx/');
// Одиночный вызов
$deals = $client->call('crm.deal.list', [
'filter' => ['>=DATE_CREATE' => '2026-01-01'],
'select' => ['ID', 'TITLE'],
]);
// Batch-запрос (до 50 методов за раз)
$result = $client->batch([
'deals' => ['method' => 'crm.deal.list', 'params' => ['filter' => ['STAGE_ID' => 'NEW']]],
'contacts' => ['method' => 'crm.contact.list', 'params' => ['select' => ['ID', 'NAME']]],
]);Создание своих REST-методов (коробка)
Регистрация метода
<?php
// /local/php_interface/init.php
use Bitrix\Main\EventManager;
$eventManager = EventManager::getInstance();
// Регистрация REST-методов при загрузке модуля
$eventManager->addEventHandler(
'rest',
'OnRestServiceBuildDescription',
['\\Local\\Rest\\ServiceProvider', 'onRestServiceBuildDescription']
);Провайдер методов
<?php
// /local/lib/Rest/ServiceProvider.php
namespace Local\Rest;
use Bitrix\Main\Loader;
class ServiceProvider
{
public static function onRestServiceBuildDescription(): array
{
return [
'local' => [
// Описание scope
'local' => [
'NAME' => 'Локальные методы',
'DESCRIPTION' => 'API для работы с каталогом',
],
],
// Методы
'local.product.list' => [
'callback' => [self::class, 'productList'],
'options' => [],
],
'local.product.get' => [
'callback' => [self::class, 'productGet'],
'options' => [],
],
'local.product.add' => [
'callback' => [self::class, 'productAdd'],
'options' => [],
],
'local.order.create' => [
'callback' => [self::class, 'orderCreate'],
'options' => [],
],
];
}
/**
* Список товаров
*
* @param array $query Параметры запроса
* @param int $start Смещение для пагинации
* @param \CRestServer $server REST-сервер
* @return array
*/
public static function productList(array $query, int $start, \CRestServer $server): array
{
Loader::includeModule('iblock');
$filter = ['IBLOCK_ID' => CATALOG_IBLOCK_ID, 'ACTIVE' => 'Y'];
// Применяем фильтры из запроса
if (!empty($query['filter']['SECTION_ID'])) {
$filter['SECTION_ID'] = (int) $query['filter']['SECTION_ID'];
}
if (!empty($query['filter']['PRICE_FROM'])) {
$filter['>=PROPERTY_PRICE'] = (float) $query['filter']['PRICE_FROM'];
}
$select = ['ID', 'NAME', 'CODE', 'PREVIEW_TEXT', 'PROPERTY_PRICE', 'PROPERTY_ARTICLE'];
$result = [];
$res = \CIBlockElement::GetList(
['SORT' => 'ASC', 'NAME' => 'ASC'],
$filter,
false,
['nPageSize' => 50, 'iNumPage' => floor($start / 50) + 1],
$select
);
while ($item = $res->GetNext()) {
$result[] = [
'id' => (int) $item['ID'],
'name' => $item['NAME'],
'code' => $item['CODE'],
'description' => $item['PREVIEW_TEXT'],
'price' => (float) $item['PROPERTY_PRICE_VALUE'],
'article' => $item['PROPERTY_ARTICLE_VALUE'],
];
}
return [
'result' => $result,
'total' => \CIBlockElement::GetList([], $filter, [], false, ['ID']),
'next' => count($result) === 50 ? $start + 50 : null,
];
}
/**
* Один товар по ID
*/
public static function productGet(array $query, int $start, \CRestServer $server): array
{
if (empty($query['id'])) {
throw new \Bitrix\Rest\RestException(
'Parameter "id" is required',
'INVALID_PARAMS',
\CRestServer::STATUS_WRONG_REQUEST
);
}
Loader::includeModule('iblock');
$res = \CIBlockElement::GetList(
[],
['IBLOCK_ID' => CATALOG_IBLOCK_ID, 'ID' => (int) $query['id']],
false,
false,
['ID', 'NAME', 'CODE', 'DETAIL_TEXT', 'PROPERTY_PRICE', 'PROPERTY_ARTICLE']
);
if ($item = $res->GetNext()) {
return [
'id' => (int) $item['ID'],
'name' => $item['NAME'],
'code' => $item['CODE'],
'description' => $item['DETAIL_TEXT'],
'price' => (float) $item['PROPERTY_PRICE_VALUE'],
'article' => $item['PROPERTY_ARTICLE_VALUE'],
];
}
throw new \Bitrix\Rest\RestException(
'Product not found',
'NOT_FOUND',
\CRestServer::STATUS_NOT_FOUND
);
}
/**
* Создание заказа
*/
public static function orderCreate(array $query, int $start, \CRestServer $server): array
{
// Валидация
$required = ['customer_email', 'items'];
foreach ($required as $field) {
if (empty($query[$field])) {
throw new \Bitrix\Rest\RestException(
"Parameter \"{$field}\" is required",
'INVALID_PARAMS',
\CRestServer::STATUS_WRONG_REQUEST
);
}
}
Loader::includeModule('sale');
Loader::includeModule('catalog');
// Создаём заказ...
// (логика создания заказа)
return [
'order_id' => $orderId,
'status' => 'created',
];
}
}Авторизация REST-запросов
Проверка токена
<?php
class ServiceProvider
{
public static function orderCreate(array $query, int $start, \CRestServer $server): array
{
// Токен приходит только заголовком: в строке запроса он осел бы
// в access-логе nginx, в Referer и в истории браузера
$token = (string) ($_SERVER['HTTP_X_API_TOKEN'] ?? '');
if (!self::isValidToken($token)) {
throw new \Bitrix\Rest\RestException(
'Invalid API token',
'AUTH_ERROR',
\CRestServer::STATUS_FORBIDDEN
);
}
// Продолжаем...
}
private static function isValidToken(string $token): bool
{
// Токены — из конфигурации вне репозитория, а не из константы класса
$known = (array) \Bitrix\Main\Config\Configuration::getValue('api_tokens');
foreach ($known as $candidate) {
// hash_equals сравнивает за постоянное время: обычное сравнение
// строк выходит на первом различии и по времени ответа
// позволяет подбирать токен посимвольно
if (hash_equals((string) $candidate, $token)) {
return true;
}
}
return false;
}
}Три вещи, которые в исходном примере были сделаны неправильно, и все три встречаются в проде.
Токены в константе класса. Это значит — в репозитории, в истории git, у всех, кто когда-либо делал клон. Секреты живут в конфигурации вне репозитория или в переменных окружения.
Токен в параметрах запроса ($query['token']). Строка запроса попадает в access-лог веб-сервера, в заголовок Referer при переходе по внешней ссылке и в историю браузера. Только заголовок.
Сравнение через in_array/===. Обычное сравнение строк прекращается на первом несовпавшем байте, и по времени ответа токен подбирается посимвольно. Для секретов — только hash_equals().
OAuth-авторизация
<?php
// Для методов, требующих авторизации пользователя
public static function userProfile(array $query, int $start, \CRestServer $server): array
{
// Получаем авторизованного пользователя
$authData = $server->getAuth();
if (empty($authData['user_id'])) {
throw new \Bitrix\Rest\RestException(
'Authorization required',
'AUTH_REQUIRED',
\CRestServer::STATUS_UNAUTHORIZED
);
}
$userId = (int) $authData['user_id'];
// Получаем данные пользователя...
return $userData;
}Входящий вебхук (для внешних систем)
Простой обработчик
<?php
// /local/tools/webhook.php
require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
use Bitrix\Main\Application;
use Bitrix\Main\Web\Json;
// CORS
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Api-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}
// Проверка ключа
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? $_REQUEST['api_key'] ?? '';
if ($apiKey !== getenv('WEBHOOK_API_KEY')) {
http_response_code(401);
echo Json::encode(['error' => 'Unauthorized']);
exit;
}
try {
// Разбор тела — ВНУТРИ try: на некорректном JSON Json::decode бросает
// исключение, и снаружи оно уйдёт в 500 со стектрейсом наружу
$input = file_get_contents('php://input');
$data = Json::decode($input);
$action = $data['action'] ?? '';
switch ($action) {
case 'sync_products':
$result = ProductSync::run($data['products']);
break;
case 'update_stock':
$result = StockUpdater::update($data['items']);
break;
default:
throw new \InvalidArgumentException('Unknown action');
}
echo Json::encode([
'success' => true,
'result' => $result,
]);
} catch (\Throwable $e) {
http_response_code(400);
// Наружу — нейтральный текст, детали в лог.
// $e->getMessage() выдаёт пути к файлам, имена таблиц и фрагменты SQL
AddMessage2Log($e->getMessage(), 'webhook');
echo Json::encode([
'success' => false,
'error' => 'Request processing failed',
]);
}Access-Control-Allow-Origin: * здесь лишний. CORS-заголовки нужны, когда ваш эндпоинт вызывает браузер со стороннего домена. Вебхук для серверной интеграции вызывается сервером, а серверу CORS безразличен. Звёздочка в этом месте — привычка, скопированная из фронтендовых примеров: пользы никакой, а поверхность атаки шире.
URL Rewrite для красивых URL
<?php
// /urlrewrite.php
$arUrlRewrite = [
// ... существующие правила
[
'CONDITION' => '#^/api/v1/(.+)#',
'RULE' => 'action=$1',
'ID' => '',
'PATH' => '/local/tools/api.php',
],
];Документация API
<?php
// /local/tools/api-docs.php — простая документация
$methods = [
'local.product.list' => [
'description' => 'Получить список товаров',
'params' => [
'filter[SECTION_ID]' => 'ID раздела каталога',
'filter[PRICE_FROM]' => 'Минимальная цена',
],
'response' => [
'result' => 'Массив товаров',
'total' => 'Общее количество',
'next' => 'Смещение для следующей страницы',
],
'example' => '/rest/local.product.list?filter[SECTION_ID]=5',
],
// ...
];Идемпотентность: то, о чём вспоминают после инцидента
Пункт, которого нет почти ни в одной статье про вебхуки, и который стоит дороже всех остальных.
Внешняя система, не получившая от вас ответ вовремя, повторит запрос. Так устроены все нормальные интеграции — от платёжных шлюзов до маркетплейсов. Ваш обработчик при этом мог успешно всё выполнить и упасть уже на отдаче ответа. В результате заказ создаётся дважды, товар списывается дважды, письмо уходит дважды.
Лечится это одним полем. Внешняя система присылает идентификатор операции (или вы вычисляете его из содержимого), вы храните обработанные идентификаторы и на повторе возвращаете сохранённый результат, не выполняя действие заново:
$operationId = (string) ($data['operation_id'] ?? '');
if ($operationId === '') {
throw new \InvalidArgumentException('operation_id required');
}
if ($existing = ProcessedOperationTable::getById($operationId)->fetch()) {
echo Json::encode(['success' => true, 'result' => Json::decode($existing['RESULT'])]);
exit;
}Проектировать это нужно сразу: добавить идемпотентность в работающую интеграцию после первых дублей заметно дороже, чем заложить её на старте.
Итоги
| Сценарий | Решение |
|---|---|
| Интеграция с Битрикс24 | Вебхуки + REST-клиент |
| Свой API в коробке | OnRestServiceBuildDescription |
| Входящие данные | Вебхук-обработчик |
| Авторизация | API-токены или OAuth |
Что я проверяю перед тем, как отдать API наружу.
Секреты не в коде и не в URL. Токены — в конфигурации вне репозитория, передаются заголовком, сравниваются через hash_equals(). Адрес вебхука Битрикс24 сам по себе является секретом.
Версия в адресе с первого дня. /api/v1/ пишется один раз и ничего не стоит. Добавить версионирование в API, которым уже пользуются три внешние системы, — отдельный проект с согласованиями.
Идемпотентность на всех изменяющих операциях. Повторы будут, вопрос только когда.
Ошибки наружу — нейтральные и с кодами. Клиенту нужен машиночитаемый код, чтобы отличить «неверные данные» от «попробуйте позже». Тексты исключений наружу не отдаются никогда.
Ограничение частоты. Без него один цикл с ошибкой в чужом коде положит вам базу, и виноватыми окажетесь вы.
Логирование вызовов — с вырезанными секретами. Лог интеграции нужен, чтобы отвечать на вопрос «а вы точно присылали?», и без него разбор споров с подрядчиком превращается в гадание. Но токены и персональные данные в него попадать не должны.
Документация вместе с кодом. Не «потом опишем» — описание методов и примеры запросов пишутся в момент разработки, иначе через полгода вы сами не вспомните формат filter.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.