Главная/Статьи/REST API в Битрикс — создание своих методов

REST API в Битрикс — создание своих методов

Создаём собственные REST-методы в Битрикс24 и коробке: регистрация эндпоинтов, авторизация, документация. Интеграция с внешними системами.

ДМ
Дмитрий Мещеряков
📅 12 августа 2026 г.📖 10 мин чтения

REST в Битриксе — это два разных мира под одним названием. В Битрикс24 есть готовый развесистый API с OAuth и маркетплейсом приложений. В коробке — модуль rest, который даёт вам каркас, а всё остальное вы пишете сами: авторизацию, валидацию, версионирование, документацию.

Ниже — оба сценария и, что важнее, те решения по безопасности, которые в примерах из документации обычно опущены, а на боевом API стоят дорого.

REST API в Битрикс

Битрикс имеет встроенный REST API для интеграций. Можно использовать готовые методы или создавать свои.

💡 Совет

Два режима:

  • Битрикс24 — OAuth 2.0, приложения в маркетплейсе
  • Коробка — входящие/исходящие вебхуки, модуль rest

Использование встроенного REST (Битрикс24)

Исходящий вебхук

Создайте вебхук в Настройки → Интеграции → REST API → Добавить вебхук.

php
<?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
<?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
<?php
// /local/php_interface/init.php
use Bitrix\Main\EventManager;
$eventManager = EventManager::getInstance();
// Регистрация REST-методов при загрузке модуля
$eventManager->addEventHandler(
    'rest',
    'OnRestServiceBuildDescription',
    ['\\Local\\Rest\\ServiceProvider', 'onRestServiceBuildDescription']
);

Провайдер методов

php
<?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
<?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
<?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
<?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
<?php
// /urlrewrite.php
$arUrlRewrite = [
    // ... существующие правила
    [
        'CONDITION' => '#^/api/v1/(.+)#',
        'RULE' => 'action=$1',
        'ID' => '',
        'PATH' => '/local/tools/api.php',
    ],
];

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

php
<?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',
    ],
    // ...
];

Идемпотентность: то, о чём вспоминают после инцидента

Пункт, которого нет почти ни в одной статье про вебхуки, и который стоит дороже всех остальных.

Внешняя система, не получившая от вас ответ вовремя, повторит запрос. Так устроены все нормальные интеграции — от платёжных шлюзов до маркетплейсов. Ваш обработчик при этом мог успешно всё выполнить и упасть уже на отдаче ответа. В результате заказ создаётся дважды, товар списывается дважды, письмо уходит дважды.

Лечится это одним полем. Внешняя система присылает идентификатор операции (или вы вычисляете его из содержимого), вы храните обработанные идентификаторы и на повторе возвращаете сохранённый результат, не выполняя действие заново:

php
$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.