Play
online · 30 мин
Туториалы 18 мин чтения 20.09.2026

API-интеграция: как связать два сервиса, обработать ошибки и не потерять данные

Связать два сервиса по API — это не просто написать пару запросов. Разбираем, как выстроить исходящий и входящий каналы так, чтобы данные не терялись при сбоях, не задваивались из-за повторных вебхуков и не затирали актуальные записи.

Что в итоге сделаем: карта интеграции за 30 секунд

Когда к нам приходят с задачей «связать Bitrix24 с внешним сервисом», за формулировкой обычно стоят два независимых процесса. Первый — исходящий: наш код обращается к чужому REST API, забирает или отправляет данные. Второй — входящий: внешняя система сама дёргает нас вебхуком, когда у неё что-то произошло. Оба канала живут одновременно, и оба должны переживать сбои сети, повторы запросов и «пятисотые» ответы.

Что значит «переживать»? Данные не должны потеряться, если провайдер на минуту лёг. Не должны задвоиться, если вебхук пришёл дважды — а он придёт, доставка вебхуков работает по модели at-least-once. И не должны затирать то, что уже есть в системе, если событие пришло с опозданием. На этих сценариях ломается большинство «работающих» интеграций. Под них мы и проектируем каркас с самого начала.

К концу статьи у вас будет рабочая карта из шести узлов:

  1. HTTP-клиент к внешнему API — изолированный, с таймаутами и ретраями.
  2. Приёмник вебхуков — отдельная точка входа, не завязанная на публичные страницы.
  3. Проверка подписи — до парсинга тела запроса, не после.
  4. Идемпотентность — ключ дедупликации на каждое событие.
  5. Rate limiting — token bucket, чтобы не выжечь лимиты провайдера.
  6. Обработка ошибок — разбор 4xx/5xx и error_description из ответа REST API.

Интеграция по API: где разместить клиент — компонент или модуль

Типичный сценарий: нужно связать Bitrix с внешним REST-сервисом — маркетплейсом, службой доставки, CRM подрядчика. Первая мысль — «сделаю компонент, так быстрее». И это действительно быстрее, пока задача одна. Как только тот же API понадобился в cron-агенте, в очереди на выгрузку или в админском скрипте, код начинает расползаться копиями. Поэтому вопрос «компонент или модуль» не про красоту архитектуры, а про то, сколько раз вы собираетесь переиспользовать клиент.

Компонент — это UI-единица: он рендерит результат на странице, живёт в рамках запроса и подключается точечно. Модуль — это библиотека: его классы доступны из любого места системы через автозагрузку, а логика не привязана к выводу. В проектах, где интеграция нужна только для одной страницы, мы спокойно оставляем компонент. Но если API-клиент вызывается из агентов, очередей или бизнес-процессов — модуль окупается с первого же рефакторинга.

PHP
<?php
// Вариант 1: подключение собственного модуля (рекомендуется для переиспользуемого клиента)
use Bitrix\Main\Loader;

if (!Loader::includeModule('vendor.apiclient')) {
    throw new \RuntimeException('Модуль vendor.apiclient не установлен');
}

$client = new \Vendor\ApiClient\Client('https://api.example.com', $apiKey);
$products = $client->getProducts(['limit' => 50]);

// Вариант 2: подключение класса компонента (для одноразовой задачи)
\CBitrixComponent::includeComponentClass('vendor:api.client');

$component = new \Vendor\ApiClientComponent();
$result = $component->fetchProducts(['limit' => 50]);

Ключевая разница в том, как класс попадает в область видимости. Модуль регистрирует namespace через автозагрузчик в include.php, и дальше \Vendor\ApiClient\Client доступен где угодно — в агенте, в обработчике события, в консольной команде. Компонент же живёт по правилам ядра: его класс подтягивается только после явного CBitrixComponent::includeComponentClass(), а сам он по конвенции наследует CBitrixComponent и завязан на arParams, шаблон и контекст страницы.

На ru.stackoverflow разбирали похожий вопрос: разработчик спрашивал, можно ли обойтись компонентом или нужно писать модуль. Ответ был прямой — если собираетесь распространять через marketplace, модуль однозначно; для разовой задачи подойдёт компонент. Там же отметили, что компоненты на D7 — это обычные классы, и их методы можно вызывать, подключив класс вручную.

На практике мы советуем такой критерий: если API-клиент вызывается из одного места и результат сразу уходит в шаблон — берите компонент, не усложняйте. Если вызовов два и больше, или хотя бы один из них вне страницы (cron, агент, REST-эндпоинт) — оформляйте модуль. Это же снимает проблему с namespace: в модуле вы задаёте Vendor\ApiClient один раз, а не тащите пути через includeComponentClass в каждом файле.

Классическая грабля: логику интеграции пишут прямо в component.php — «пока хватит». Через месяц тот же обмен нужен в cron-агенте для ночной синхронизации. Компонент в агент не подключишь без костылей, и разработчик копирует код в отдельный скрипт. Дальше правки в API-контракте приходится вносить в двух местах, и они неизбежно расходятся. Если есть хоть малейший шанс, что клиент понадобится вне страницы — сразу выносите его в модуль.

REST API интеграция: выносим HTTP-клиент из репозитория

Распространённая ошибка: GuzzleHttp\Client создаётся прямо в конструкторе репозитория, а адрес провайдера и API-ключ лежат в приватных свойствах того же класса. Сначала это удобно — один класс, вся логика рядом. Но проходит пара месяцев, и оказывается, что тот же провайдер нужен для синхронизации остатков по крону, для выгрузки в другой маркетплейс и для фоновой задачи в очереди. Репозиторий, который «умеет всё», начинают дёргать отовсюду, а через него — и HTTP-запросы, к которым он отношения иметь не должен.

Что это ломает по факту: ключ и адрес провайдера расползаются по проекту — их копируют в соседние классы, потому что «в репозиторий лезть неудобно». Тесты превращаются в мок-сервер, потому что подменить HTTP-клиент нельзя — он создаётся внутри. Смена провайдера означает переписывание репозитория, а не подмену одной зависимости. Правильное разделение простое: клиент знает про адреса, ключи и форматы, репозиторий — только про данные. Ниже — как это выглядит в коде.

PHP
namespace Vendor\Integration\Client;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;

class ApiProviderClient
{
    private Client $httpClient;
    private string $address;
    private string $key;

    public function __construct(Client $httpClient, string $address, string $key)
    {
        $this->httpClient = $httpClient;
        $this->address = rtrim($address, '/');
        $this->key = $key;
    }

    public function getProducts(): array
    {
        try {
            $response = $this->httpClient->request('GET', $this->address . '/products', [
                    'headers' => [
                        'Authorization' => 'Bearer ' . $this->key,
                        'Accept'        => 'application/json',
                    ],
                    'timeout' => 30,
            ]);
        } catch (GuzzleException $e) {
            throw new \RuntimeException('Provider API unavailable: ' . $e->getMessage(), 0, $e);
        }

        $data = json_decode($response->getBody()->getContents(), true);

        return $data['products'] ?? [];
    }
}
PHP
namespace Vendor\Integration\Repository;

use Vendor\Integration\Client\ApiProviderClient;
use Bitrix\Main\Type\Collection;

class ProviderRepository
{
    private ApiProviderClient $apiClient;

    public function __construct(ApiProviderClient $apiClient)
    {
        $this->apiClient = $apiClient;
    }

    public function find(array $params = []): Collection
    {
        $products = $this->apiClient->getProducts();

        // фильтрация по params, маппинг в объекты — здесь, не в клиенте
        $collection = new Collection();
        foreach ($products as $item) {
            $collection->add($this->mapToEntity($item));
        }

        return $collection;
    }

    private function mapToEntity(array $item): array
    {
        return [
            'id'    => (int) $item['id'],
            'name'  => (string) $item['title'],
            'price' => (float) $item['price'],
        ];
    }
}

Зачем выделять клиент в отдельный класс

Когда HTTP-запрос живёт в репозитории, у класса появляется две причины для изменения: поменялся формат ответа провайдера — правим репозиторий; поменялась бизнес-логика фильтрации — тоже правим репозиторий. Это классический признак нарушения единой ответственности, и на нём ломается всё, что стоит выше.

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

Именно на этом шаге отваливается большинство «необъяснимых» багов при смене провайдера: репозиторий ожидал один формат поля, новый клиент отдавал другой — и вся цепочка выше разваливалась.

Ключевой принцип здесь — единая ответственность. Клиент знает адреса, ключи и форматы ответов провайдера. Репозиторий оперирует данными: фильтрует, сортирует, отдаёт коллекцию. А между ними стоит Data Mapper — он приводит ответ провайдера к одной внутренней структуре, которую ждёт репозиторий. Если завтра провайдер вернёт title вместо name или цену строкой вместо числа, вы поправите один маппер, а не будете искать все места, где эти поля используются.

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

Вебхуки интеграция: проверка подписи и приём событий

Входящий вебхук — это когда внешняя система дёргает наш Битрикс24 или наш сайт, а не наоборот. Исходящий — когда мы сами отправляем событие через «Исходящий Вебхук» в REST Битрикс24 во внешнюю систему. В e-commerce и корпоративных интеграциях оба направления живут одновременно: платёжка присылает статус оплаты, маркетплейс — новый заказ, CRM подрядчика — изменение сделки. И тут начинается то, о чём почти никто не думает в первой итерации: как понять, что запрос действительно от платёжки, а не от бота с curl.

По документации Битрикс24 при работе с входящим вебхуком обязательно запускается проверка checkserver — это базовая валидация источника. Но её недостаточно: она подтверждает, что запрос пришёл «откуда надо», а не что тело не подменили по дороге. Поэтому мы всегда проверяем HMAC-подпись до парсинга JSON. Причина простая: если распарсить тело раньше проверки, любой мусор от злоумышленника уже прошёл через json_decode, попал в лог, в очередь, в бизнес-логику — а мы только потом узнали, что подпись не сходится. Порядок «сначала подпись, потом тело» закрывает эту дыру.

PHP
<?php
// /local/api/webhook/handler.php — точка входа для входящего вебхука
require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Bitrix\Main\Application;

const WEBHOOK_SECRET = 'whsec_xxxxxxxxxxxxxxxx'; // замените на ваш секрет

$request = Application::getInstance()->getContext()->getRequest();

// 1. Проверка checkserver — обязательна для входящих вебхуков Битрикс24
if ((string)$request->get('checkserver') !== 'ok') {
    http_response_code(403);
    exit('checkserver failed');
}

// 2. Читаем сырое тело ДО любого json_decode
$rawBody = file_get_contents('php://input');

// 3. Сверяем HMAC-подпись (заголовок вида X-Signature: sha256=...)
$signature = (string)$request->getHeader('X-Signature');
$expected  = 'sha256=' . hash_hmac('sha256', $rawBody, WEBHOOK_SECRET);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('invalid signature');
}

// 4. Только теперь разбираем тело
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);

// 5. Отдаём 200 быстро, тяжёлую работу — в очередь/агент
http_response_code(200);
echo 'ok';

Идемпотентность: webhook-id как ключ дедупликации

Подпись проверили, тело распарсили — но это ещё не значит, что событие новое. Внешние сервисы шлют вебхуки с доставкой at-least-once: retries и дубликаты неизбежны, порядок тоже не гарантирован. Практика GitHub/GitLab и большинства платёжек — класть в заголовки webhook-id, уникальный для события и не меняющийся между попытками. Это и есть готовый idempotency key.

Мы держим таблицу processed_webhooks с уникальным индексом по WEBHOOK_ID. Перед бизнес-логикой пытаемся вставить id — если вставка упала на дубликате, событие уже обработано, отвечаем 200 и выходим. Никаких «а вдруг это новый заказ с тем же номером» — id генерирует отправитель, и он уникален именно для события.

PHP
<?php
use Bitrix\Main\Application;
use Bitrix\Main\DB\SqlQueryException;

$connection = Application::getConnection();
$webhookId  = (string)$request->getHeader('webhook-id');

if ($webhookId === '') {
    http_response_code(400);
    exit('missing webhook-id');
}

try {
    $connection->addQuery(
        'INSERT INTO processed_webhooks (WEBHOOK_ID, DATE_INSERT) VALUES (?, NOW())',
        [$webhookId]
    );
} catch (SqlQueryException $e) {
    // Дубликат — уникальный индекс не дал вставить. Событие уже принято.
    http_response_code(200);
    exit('duplicate');
}

// Только здесь — бизнес-логика: создание заказа, списание остатка и т.д.

Ключевое, что читатель захочет поменять под себя, — где именно стоит дедупликация. Мы ставим её до бизнес-логики, но после проверки подписи: нет смысла писать в processed_webhooks мусор от неподписанных запросов. Если у вас несколько обработчиков на разные типы событий, таблицу можно расширить полем EVENT_TYPE — тогда один и тот же id из разных потоков не будет конфликтовать. Альтернатива таблице — Bitrix\Main\Application::getCache() с TTL, но кэш не переживает сброс и не даёт гарантий на длинном горизонте, поэтому для финансовых операций мы так не делаем.

Отдельно про доставку. Отправитель вебхука — «оптимистичный акт»: HTTP-запрос уходит на неподконтрольный сервер по непредсказуемой сети. Отсюда три следствия, которые мы учитываем в каждом проекте:

  • Retries неизбежны — сервис повторит доставку, если не получил 200 в таймаут. Значит, обработчик обязан быть идемпотентным.
  • Порядок не гарантирован — событие «оплата» может прийти раньше события «создание заказа». Не стройте логику на последовательности.
  • Редиректы 3xx — риск — следование редиректу при доставке вебхука может увести подпись на чужой хост. Наш обработчик всегда отвечает 200 напрямую, без редиректов.

Грабля: без дедупликации повторный вебхук создаёт второй заказ или повторно списывает остаток. Платёжка отправила «оплачено», не дождалась 200 за таймаут, отправила снова — и у вас два одинаковых заказа с одним webhook-id. Лечится ровно одним: уникальным индексом на WEBHOOK_ID и вставкой до бизнес-логики. Не «проверим существование SELECT-ом» — между SELECT и INSERT есть окно гонки, а INSERT с уникальным индексом атомарен.

Обмен данными API: структура запроса, версии и лимиты

Структура запроса, версии и лимиты всплывают не тогда, когда интеграция пишется, а когда она уже работает в проде. Формат даты в теле запроса ломает парсер на стороне провайдера, если отправить локальное время вместо ISO. Версионирование даёт о себе знать, когда провайдер выкатывает v2 и старые вызовы возвращают неожиданные структуры. А лимиты скорости — когда ночной импорт на 20 тысяч позиций упирается в 429 и валится на середине, оставив половину каталога несинхронизированной. У нас в практике это три самых частых причины «интеграция работала, а потом сломалась». Все три решаются на уровне контракта с API, до того как что-то пойдёт не так. Ниже — как договориться с провайдером о версии, как форматировать даты по стандарту и как не положить ни свой сервис, ни чужой при всплеске запросов.

Способ Пример Плюсы Минусы
Custom-заголовок API-Version: 2 Просто читать и логировать, не ломает URL Не видно в браузере, легко забыть прокинуть через прокси
Accept с медиатипом Accept: application/vnd.mycompany.v2+json Версия в одном заголовке с форматом, REST-идиоматично Сложнее дебажить, не все клиенты умеют гибко менять Accept
Отказ от версии поле status в ответе Один эндпоинт навсегда, обратная совместимость Раздувает ответы, старые поля нельзя удалить

Даты в ISO 8601 и ГОСТ Р 7.0.64-2018

Дата — первое, что ломает обмен между системами с разными локалями. Согласно ГОСТ Р 7.0.64-2018 (ИСО 8601:2004) «Представление дат и времени», введённому в действие с 01.01.2019, стандарт является модифицированным по отношению к ISO 8601:2004. На практике это значит одно: отправляйте даты в формате ISO 8601 с явным указанием таймзоны, а не как локальную строку. Провайдеры вроде маркетплейсов и служб доставки почти всегда ждут именно ISO. И молча отбрасывают или неверно интерпретируют всё остальное.

В PHP удобнее всего форматировать через DateTimeImmutable с константой DATE_ATOM — она как раз даёт ISO 8601 с офсетом:

PHP
<?php

use Bitrix\Main\Type\DateTime;

// Источник — Bitrix-дата из инфоблока или заказа
$bitrixDate = new DateTime('2024-11-05 14:30:00');

// Приводим к ISO 8601 с таймзоной сервера
$isoDate = (new \DateTimeImmutable($bitrixDate->toString(), new \DateTimeZone(date_default_timezone_get())))
->format(\DateTimeInterface::DATE_ATOM);

// $isoDate === '2024-11-05T14:30:00+03:00' — можно класть в тело запроса
$payload = [
    'order_id'  => 1024,
    'created_at' => $isoDate,
    'updated_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
    ->format(\DateTimeInterface::DATE_ATOM),
];

$response = $httpClient->post('/orders', ['json' => $payload]);

Ключевое здесь — не полагаться на date('c') без таймзоны: он подставит текущий офсет процесса, а не системы, из которой пришла дата. Если у вас в проекте Bitrix настроена московская таймзона, а сервер живёт в UTC, разница вылезет именно на границе суток. Второй момент, который читатель захочет поменять под себя, — источник даты: если она приходит из 1С, лучше не парсить строку, а сразу запрашивать DateTimeImmutable с явным DateTimeZone('Europe/Moscow'). И третье — при отправке в API, который сам нормализует время, иногда удобнее отдавать UTC: тогда new DateTimeZone('UTC') и никаких сюрпризов на стороне получателя.

Token bucket и leaky bucket решают одну задачу — не дать всплеску запросов положить сервис, — но по-разному. Token bucket хранит запас токенов и пополняет их с постоянной скоростью; каждый запрос забирает токен, а если токенов нет — запрос ждёт или отклоняется. Его главное свойство: он допускает всплески до burst_capacity — то есть за один момент можно отправить пачку запросов, если до этого был простой. Leaky bucket, наоборот, сглаживает трафик до фиксированной выходной скорости: запросы попадают в очередь и «протекают» наружу равномерно, а при переполнении новые отбрасываются. На шлюзах это обычно настраивается парой параметров — strategy со значением "token-bucket" или "leaky-bucket" и limit с числом запросов в единицу времени. По нашему опыту: если интеграция шлёт данные пакетами раз в час, берите token bucket — всплеск до burst_capacity как раз покроет пачку. Если же вы дёргаете API постоянно и важно не превысить среднюю скорость — leaky bucket предсказуемее.

Обработка ошибок REST API Битрикс24 и внешних сервисов

Многие думают, что ошибка REST API — это всегда «что-то упало». в Битрикс24 и у большинства внешних провайдеров ошибка приходит в теле ответа, а HTTP-статус может оставаться 200 OK . Внешние сервисы (маркетплейсы, службы доставки, CRM подрядчиков) ведут себя иначе: часть отдаёт честный 4xx/5xx, часть кладёт ошибку в тело. Разбирать нужно оба канала.

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

Код / error Что значит Что делать
ERROR_CORE Внутренняя ошибка метода Битрикс24 Проверить параметры, логировать error_description
insufficient_scope Прав у токена не хватает Расширить scope приложения/вебхука
ERROR_METHOD_NOT_FOUND Метода нет (часто в batch) Сверить имя метода с документацией
401 Не аутентифицирован Обновить токен, не ретраить вслепую
403 Запрос корректен, но API отказывает Проверить права, квоты, доступ к сущности
409 Конфликт с состоянием сервера Перечитать состояние, повторить с новыми данными
431 Заголовки превысили лимит сервера Сократить cookie/токены, чистить заголовки
PHP
use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Diag\Debug;

final class Bitrix24Client
{
    private const RETRYABLE_HTTP = [429, 500, 502, 503, 504];

    public function __construct(
        private readonly HttpClient $http,
        private readonly string $webhookUrl,
    ) {}

    public function call(string $method, array $params = []): array
    {
        $url = rtrim($this->webhookUrl, '/') . '/' . $method . '.json';
        $attempts = 0;

        while (true) {
            $attempts++;
            $this->http->post($url, $params);
            $status = $this->http->getStatus();
            $body = json_decode($this->http->getResult(), true) ?? [];

            // Битрикс24 кладёт ошибку в тело даже при 200 OK
            if (!empty($body['error'])) {
                Debug::writeToFile(
                    ['method' => $method, 'error' => $body, 'status' => $status],
                    'b24_api_error',
                    '/local/logs/b24.log'
                );

                // 5xx и 429 — единственные поводы повторить
                if (in_array($status, self::RETRYABLE_HTTP, true) && $attempts < 3) {
                    sleep(2 ** $attempts);
                    continue;
                }

                throw new \RuntimeException(sprintf(
                        'B24 %s failed: %s — %s',
                        $method,
                        $body['error'],
                        $body['error_description'] ?? ''
                ));
            }

            return $body['result'] ?? $body;
        }
    }
}

Когда 4xx повторять бессмысленно, а 5xx — нужно

В наших проектах retry-политика строится по одному правилу: повторяем только то, что может измениться само. 4xx — это детерминированный отказ: неверный токен, отсутствие scope, несуществующий метод, некорректные параметры. Сколько ни повторяй, ответ будет тот же, а вы только сожжёте лимиты и задержите очередь.

5xx — другое дело: сервер временно недоступен, шлюз перегружен, идёт деплой. Здесь retry с экспоненциальной задержкой (2 ** $attempts секунд) и лимитом в 2-3 попытки — норма. Отдельно стоит 429 Too Many Requests: формально это 4xx, но по смыслу «приходи позже». Если сервер вернул Retry-After, уважайте его вместо своей задержки.

Исключение из правила — 409 Conflict. Повтор без изменений бессмыслен, но если перед повтором перечитать состояние сущности и подставить актуальные данные — запрос пройдёт.

Разберём три кода, которые чаще всего вводят в заблуждение. 403 — запрос клиента сформирован корректно, но API отказывается его выполнять; это не случай недостаточных учётных данных, а отказ по правам, квотам или доступу к конкретной сущности. 409 по RFC 7231 подходит для случаев, когда запрос не может быть выполнен из-за конфликта с текущим состоянием сервера: например, попытка создать сущность, которая уже существует, или обновить запись, изменённую параллельно. 431 определён в RFC 6585 и возникает, когда один заголовок или весь их набор превысил лимит, установленный сервером. Типичная причина — разросшиеся cookie или длинный Authorization.

Отдельно про batch: этот метод Битрикс24 может вернуть ERROR_METHOD_NOT_FOUND не потому, что метода нет вообще, а потому что он недоступен в контексте конкретного приложения или передан с опечаткой в имени. Внутри batch-ответа ошибки приходят по каждой команде отдельно. Проверяйте не только общий result, но и вложенные result_error, иначе половина пачки молча потеряется.

Самые частые ошибки, которые ломают интеграцию

За годы работы с интеграциями у нас сложилась коллекция грабель, на которые наступают почти все. Общий знаменатель один: техническая часть вроде работает, но обмен данными внезапно рвётся — то дубли товаров после повторной доставки вебхука, то падение синхронизации из-за одной непойманной ошибки, то «необъяснимые» пропуски событий в логах. Особенно больно это бьёт по проектам с большим каталогом и активной выгрузкой из 1С: там каждое лишнее обращение к API упирается в лимиты, а каждый сбой — в ручной разбор сотен позиций.

Хорошая новость: почти все эти ошибки архитектурные, а не «случайные». Их видно на этапе ревью, и они лечатся заранее, а не после инцидента.

Ниже пять грабель, которые мы встречаем чаще всего, и что с каждой делать.

Грабля Причина Решение
Логика в компоненте Нет переиспользования, тестов, версионирования Вынести в модуль, компонент — только представление
HTTP в репозитории Смешаны доступ к данным и транспорт Отдельный API-клиент, репозиторий зависит от него
Нет idempotency key Дубли при повторной доставке вебхука Ключ из webhook-id, дедупликация на входе
Парсинг JSON до подписи Обработка поддельных запросов Сначала проверить подпись, потом json_decode
Локальный сайт без URL Внешний сервис не достучится до вебхука Туннель или опрос через getUpdates

Перед выкаткой интеграции прогоняем короткий чеклист. Он ловит большинство проблем ещё до продакшена.

  • Проверка подписи выполняется до парсинга тела запроса — HMAC или секретный токен сверяются первыми.
  • Есть idempotency key: повторная доставка того же события не создаёт дубль.
  • Retry настроен только для 5xx и 429 - 4xx повторять бессмысленно, запрос сломан на нашей стороне.
  • Rate limiting учитывает лимиты провайдера — token bucket сглаживает всплески выгрузки.
  • Логируется raw body запроса: без него разбор инцидента превращается в гадание.

Разобранные случаи на ru.stackoverflow: Хочу реализовать интеграцию Битрикс с api, Архитектура php приложения на laravel repository Интеграция с api, Реакция на команды telegram бота на локальном сайте.

Читайте также: Обмен с сайтом 1С Битрикс: пошаговая настройка узла и импорта, Невосстановимая ошибка СУБД 1С: причины и решение.

Подробнее об услуге: внедрение и доработка Битрикс24 CRM.

Частые вопросы

Можно ли использовать один HTTP-клиент для Битрикс24 и внешнего сервиса, если у них разные базовые URL?

Да, но лучше держать два экземпляра клиента с разными base_uri и таймаутами: у Битрикс24 типичный таймаут 30 секунд, у внешнего сервиса часто 5–10. Общий клиент с одним base_uri приведёт к склейке URL через конкатенацию и потере заголовков авторизации.

Что делать, если вебхук пришёл дважды с одинаковым event_id?

Сохраняйте event_id в таблице с уникальным индексом и при повторной вставке возвращайте 200 без повторной обработки. Это дешевле, чем проверять состояние сущности в Битрикс24 через crm.deal.get.

Чем отличается идемпотентность на стороне клиента от идемпотентности на стороне вебхука?

На клиенте вы генерируете Idempotency-Key и повторяете запрос при 5xx/таймауте, а на вебхуке вы дедуплицируете уже пришедшие события по event_id. Оба механизма нужны одновременно: первый защищает от потери при отправке, второй — от дублей при приёме.

А если у меня Битрикс24 отвечает 429 Too Many Requests — сколько ждать перед повтором?

Читайте заголовок Retry-After, если он есть; иначе используйте экспоненциальную задержку от 1 секунды с джиттером и максимум 5 попыток. Битрикс24 считает лимит по методу и по приложению, поэтому параллельные воркеры стоит ограничить семафором на 2–3 запроса.

Можно ли проверять подпись вебхука, если секрет хранится в переменной окружения, а не в коде?

Да, это предпочтительный вариант: сравнивайте hash_hmac('sha256', $rawBody, getenv('WEBHOOK_SECRET')) с заголовком X-Signature через hash_equals. Важно читать именно сырое тело запроса до json_decode, иначе подпись не сойдётся.

Что делать, если внешний сервис вернул 200, но в теле ошибка вида {"error": "invalid_token"}?

Проверяйте не только HTTP-код, но и поле error/status в теле — многие API отдают бизнес-ошибки с кодом 200. Заведите единый метод parseResponse, который бросает исключение при наличии error, и логируйте request_id из ответа для разбора.

#Bitrix #OAuth #JWT
автор · Backend / SRE Engineer
Артем Колячек

Backend-разработчик и SRE в студии Paradigma. Занимается тем, что у других проектов обычно обнаруживается за неделю до запуска: переездом Bitrix-проектов между серверами, починкой кешей после миграций, выстраиванием pipeline для контейнеров, мониторингом под нагрузкой.

До Paradigma — 7 лет в backend (PHP + Postgres) и в operations (Linux, Docker, Coolify, restic-бэкапы). Любит когда логи разговаривают полным синтаксисом ошибки, а не «что-то пошло не так».

На блоге пишет ровно про те ситуации с которыми сам разбирался руками: какой Bitrix-апдейт сломал кеш и как откатить, почему Docker-сеть не находит контейнер после рекрейта, что делать когда asyncpg ругается на event loop.