Рассказываю про одну простую, но эффективную реализацию вебхуков, не требующую тотальной переделки вашей замечательной системы и/или сетевой инфраструктуры.
~300 строк кода.

Постановка вопроса
Угорев в последнее время по «высокодуховным» темам вроде кросс-компиляции или эмуляции пополам с некромантией редких систем, совсем забыл о приземленных, но куда более актуальных темах в разработке ПО, которыми собственно и зарабатываю на скромную жизнь.
Ну что ж, пришло время шатать исправляться и выдать наконец материал, достойный не только занесения в закладки, но и практического применения в реальных проектах.
Допустим, у вас есть задача:
получать сообщения из Авито, из групп ВКонтакте или с виртуальной АТС, например о пропущенных звонках.
Обычно такого рода «таски» считаются грязной работой, поэтому отдаются на откуп интернам, аутсорсерам, кандидатам на собеседовании или еще какой нечисти, случайно забредшей в отдел разработки.
Наверное будет неправильным с моей стороны возводить хулу на «кормящую руку», поэтому лишь скромно замечу, что в 99% случаев реализация подобных интеграций являет собой настолько чудовищный говнокод и «overhead» пополам с оверинжинирингом, что все приличные разработчики обходят подобные проекты и задачи стороной:
самая отбитая кодогенерация, самые лютые костыли и самые грязные хаки, а также ярчайшее применение ИИ — вас ждут именно тут.
Тем интереснее будет мой рассказ — узнаете, как подобный проект должен быть реализован в идеале, когда у исполнителя есть и практический опыт и необходимые знания, и трезвый ум с твердой памятью.
Но вернемся к постановке.
Сообщения, полученные из Авито, VK, Яндекса или еще какой крупной онлайн-платформы необходимо складировать и обрабатывать на вашей стороне, обычно с привязкой к существующим данным:
профилю клиента, карточке товара, оформленному заказу и так далее.
Для организации такого обмена, необходимо либо переодически вызывать API, предоставляемое платформами, либо реализовать специальный сервис для приема входящих запросов со стороны платформы — вебхук (webhook):
In web development, a webhook is a method of augmenting or altering the behavior of a web page or web application with custom callbacks. These callbacks may be maintained, modified, and managed by third-party users who need not be affiliated with the originating website or application.
Поскольку постоянный вызов API платформ порождает ненужную и непредсказуемую нагрузку на них, все чаще работу с событийными сообщениями требуют реализовывать именно через вебхуки, тем самым заставляя потребителя API создавать и поддерживать специальный сервис для приема входящих запросов, доступный из интернета.
На этом месте начинается цирк с конями, поскольку системы, в которых происходит обработка заявок из Авито или сообщений из ВКонтакте чаще всего внутренние:
обычно это ERP/CRM-системы, набитые под завязку конфиденциальной информацией, вроде списков клиентов, заказов, состояния товаров на складе и т.д. и т.п.
Поэтому попытка выставить их голым задом API в интернет точно будет нести определенные риски для компании.
В некоторых случаях, такой софт вообще представляет собой десктопное однпользовательское приложение на Delphi, используемое прямо на рабочем месте сотрудника.
А вам необходимо закидывать внутрь такой системы данные о входящих заявках с Авито, согласно вот такой красивой блок-схеме из документации платформы:

Готовые решения
Разумеется на свете существует масса готовых решений для подобных задач, разной степени упоротости адекватности:
шины данных (ESB), брокеры сообщений (MQ), событийные движки (Kafka), pазнообразные Low-code/No-code платформы, обещающие достижение тотального счастья путем таскания кубиков по экрану.
Все они несут с собой дополнительные издержки, в первую очередь по сопровождению, требуют обучения и сложной настройки, временами — дополнительной разработки.
Но самое главное, что все они по тем или иным причинам не отражают современных реалий при работе с вебхуками.
Так появилось на свет наше решение:
мы его не придумали, не изобрели и не сгенерировали
нейросетью, мы пришли к нему путем долгой практики, тестов и изысканий.

Наше все
Представьте на мгновение совершенно невероятный факт:
программисты, отвечающие за разработку крупных онлайн-систем вроде Авито, Яндекса или Амазона — не полные идиоты.
Понимаю в это сложно поверить, ведь всем (в интернете) очевидно, что в такие места попадают строго по блату и вместо тяжелой работы от заката до рассвета, высокооплачиваемые сотрудники там непрерывно пьют смузи, делают селфи и музицируют.
Тем не менее, лично мой опыт подсказывает, что все несколько не так, как кажется обывателям и совсем уж откровенных технических ляпов за разработчиками крупных проектов замечено не было.
Проще говоря:
программисты крупных интернет-платформ умнее и опытнее
вассреднего пользователя их API.
Считайте это непреложным фактом и столпом мироздания.
Применительно к техническому решению для вебхуков, сие означает что отправляющая сторона (крупная онлайн-платформа) точно догадается проверить ваш вебхук на доступность перед отправкой, понимает что такое код ошибки 500 и обязательно сделает повторную отправку недоставленных сообщений через какое-то время.
Повторюсь:
за все время работ по интеграции с крупными онлайн-платформами, детских ошибок вроде бесконечной отправки или неудержимых попыток пробиться к неработающему вебхуку замечено не было.
А первые робкие интеграции автор делал еще году так в 2009м.
Все это означает практическую возможность довериться и положиться на адекватность отправляемой стороны, дабы сократить собственные затраты на сложную разработку и инфраструктуру.
Представляю как перекосило лица у интернет-параноиков на этом абзаце, однако лучшее лечение от ИТ-шизы и цифровой параноии — счет на оплату услуг заказной разработки.
Стоит хоть раз оплатить из своего кармана скажем, 40-50к баксов за реализацию больных фантазий очередного ИТ-шизоида с должностью архитектора: с очередями сообщений, хранилищами данных, балансировщиками нагрузки и кластерами метаданных — паранойя по отношению к интернет-платформам сразу отпустит.
Как рукой снимет.
И вместо дурацкой и непродуктивной параноии, вы наконец займетесь нормальным делом:
зарабатыванием денег, выращиванием детей
и просмотрами сериалов от Netflix.

Схема работы
Наш замечательный сервис вебхуков представляет собой отдельное веб-приложение, с двумя разными API:
public — для приема входящих вебхук-запросов с внешних интернет-платформ;
internal — для выдачи полученных запросов внутренней системе с помощью поллинга.
Путем черной магии и колдовства особой настройки, публичное и приватное API отвечают на двух разных портах (см. ниже).
Подключение из внутренней системы к сервису вебхуков (к внутреннему API) происходит как клиент к серверу, чем снимается требование на наличие входящих подключений непосредственно к внутренней системе.
Вызывать внешний вебсервис можно без особых проблем хоть из 1С, в отличие от приема входящих запросов.
Входящее сообщение (например от Авито) приходит с помощью POST-запроса к зарегистрированному вебхуку, сохраняется в небольшом in-memory хранилище вида «ключ-значение», затем отдается внутренней системе.
Есть важный нюанс:
если какое-то время не было запросов к внутреннему API, считается что весь сервис вебхуков не работает и в публичное API выдается 500я ошибка
Отправляющая сторона увидев такую ошибку, на какое-то время перестает дергать ваш сервис и начинает коупить копить сообщения на своей стороне.
Как только появится запрос во внутренее API, сервис вебхуков продолжит работать в штатном режиме, перестав отдавать 500ю ошибку наружу.
Отправитель, который будет делать переодические пустые запросы к вебхуку это заметит и продолжит отправку сообщений на ваш вебхук в штатном режиме, включая недоставленные.
Таким образом все сложности транзакционной обработки, хранения и передачи сообщений перекладываются на отправляющую сторону, снимая это с ваших собственных хилых плеч.
Да, именно это и называется «system engineering», ради таких задач архитекторы и получают свои сказочные зарплаты и бонусы. Хотя-бы в теории.
Реализация
Представленная ниже реализация сервиса вебхуков — на Java и Spring Boot (поскольку это наш основой стек), но логика работы легко повторяется на любом другом языке и технологии.
Собственно тут нет привязки даже к формату данных, поэтому совершенно не обязательно принимать вебхуком именно JSON.
Весь исходный код выложен в отдельном репозитории на Github.

Обработка сообщений
За обработку самих сообщений (содержимого) отвечает внутренняя система — ERP/CRM, поэтому принимаются и отдаются они сервисом вебхуков «как есть», без попыток разбора и/или валидации.
Такая схема хоть и несет определенные риски, зато работает очень эффективно, поскольку отправляющая сторона имеет строго документированные схемы отправляемых сообщений.
Поэтому шансы получить мусор или битые данные со стороны какого-нибудь Авито или Яндекса — на практике минимальны.
Просто задумайтесь о количестве потребителей API у таких платформ — десятки (если не сотни) тысяч и любая «вольная трактовка» выдаваемых данных может привести к сбою разом у всех.
С соответствующими последствиями для служб поддержки.
Публичное API
Переходим наконец к исходному коду нашего сервиса вебхуков и для начала я покажу, как выглядит публичное API, на которое приходят сообщения из внешней интернет-платформы (например из Авито):
..
@WebServlet(urlPatterns = "/api/hooks/public/*")
public static class WebhooksServlet extends HttpServlet {
@Autowired
private MessagesStore ms; // in-memory storage
@Autowired
private ConnectionCheck cc; // сервис контроля состояния
// (см. описание ниже)
@Override
protected void doHead(HttpServletRequest req,
HttpServletResponse resp) {
// если подключение к внутренней ERP живое - отдаем статус 200,
// если нет то 500,
// для того чтобы запустить повторную отправку
// со стороны внешних систем
resp.setStatus(!cc.isAlive() ? 500 : 200);
}
@Override
protected void doGet(HttpServletRequest req,
HttpServletResponse resp) {
// не используется со стороны Авито, только для тестов
resp.setStatus(!cc.isAlive() ? 500 : 200);
}
@Override
protected void doPost(HttpServletRequest req,
HttpServletResponse resp)
throws IOException {
// если внутреннее подключение разорвано -
// отдаем 500ю ошибку
if (!cc.isAlive()) {
resp.setStatus(500);
return;
}
// считываем тело запроса без разбора
// чаще всего это JSON-объект
final String requestBody = req.getReader()
.lines().collect(Collectors.joining());
// в некоторых случаях допустимы пустые запросы,
// без данных - используется для проверки доступности
if (requestBody.isBlank()) {
resp.setStatus(200);
return;
}
if (LOG.isDebugEnabled())
LOG.debug("got webhook message: '{}'", requestBody);
// повторная проверка на подключение к ERP
if (!cc.isAlive()) {
resp.setStatus(500);
return;
}
// если хранилище не переполнено - сохраняем,
// если нет - возвращаем статус 500 и
// тем самым запускаем повторный прием
resp.setStatus(!ms.addMessage(requestBody)? 500: 200);
}
}
..
Как можно заметить, тут используется очень низкоуровневая логика — API реализовано в виде сервлета, обрабатывающего несколько разных типов HTTP-запросов и принимающего входящие данные в «сыром» виде.
Повторюсь, это сделано специально:
чтобы не связывать сервис вебхуков с логикой валидации схем данных конкретных интернет-платформ, которые эти данные присылают.
На стороне конечной системы, в которую необходимо доставлять сообщения из вебхука в любом случае придется делать разбор данных, изучать альбомы форматов для каждой конкретной интернет-платформы — тут от этой задачи никуда не деться.
Но в сервисе вебхуков этого можно избежать.
Логика работы выглядит следующим образом (на примере Авито):
После регистрации вебхука в личном кабинете Авито, приходит специальный робот, который тестирует доступность путем отправки HEAD-запроса.
Если запрос отрабатывает, вебхук считается работающим и Авито начинает присылать события (настроенные в ЛК, либо через вызовы к API) с помощью POST-запросов.
Если в при попытке отправки выдается 500я ошибка, отправка приостанавливается и через какое-то время снова происходит проверка через HEAD-запрос.
Ну и дальше по кругу, в таком же стиле.
Некоторые платформы для валидации вебхука вместо HEAD-запроса присылают пустой GET-запрос, ответ на который также должен быть пустым.

Внутреннее API
Вторая важная часть сервиса вебхуков — внутреннее API, отвечающее за взаимодействие с (внезапно) внутренней системой, в которую должны в итоге попадать полученные сообщения.
Так выглядит исходный код этой части:
..
/**
* Сервлет для внутреннего API, вызываемого со стороны ERP
*/
@WebServlet(urlPatterns = "/internal/api/hooks/*")
public static class InternalWebhooksServlet extends HttpServlet {
@Value("${app.webhooka.apiToken}")
private String apiToken; // токен авторизации
@Autowired
private MessagesStore ms;
@Autowired
private ConnectionCheck cc;
@Override
protected void doGet(HttpServletRequest req,
HttpServletResponse resp)
throws IOException {
// получение токена авторизации,
// передаваемого отдельным заголовком
final String authToken = req.getHeader("X-Webhooka-Auth");
if (authToken == null || authToken.isBlank()) {
LOG.warn("No authentication token");
resp.setStatus(403);
return;
}
// да, тут простейшее сравнение "в лоб"
// в более серьезных случаях тут должен быть JWT
if (!apiToken.equals(authToken)) {
LOG.warn("Invalid authentication token: {}", authToken);
resp.setStatus(403);
return;
}
// обязательно помечаем активность поключения со стороны ERP
cc.markAlive();
resp.setStatus(200);
resp.setContentType(MediaType.APPLICATION_JSON_VALUE);
resp.setCharacterEncoding(StandardCharsets.UTF_8);
// получение накопленных сообщений из хранилища.
// в момент вызова, эти сообщения из хранилища удалятся (!)
final List<MessagesStore.InputMessage> pending
= ms.getPendingMessages();
// для уменьшения объема трафика - отдаем пустое тело
// если в хранилище нет сохранненных записей
if (pending.isEmpty()) {
resp.flushBuffer();
return;
}
final StringBuilder sb = new StringBuilder();
for (MessagesStore.InputMessage m : pending) {
// append new string for elements after first
if (!sb.isEmpty())
sb.append('\n');
// append message body as plain string
// assume it does not contain new lines
sb.append(m.messageBody());
}
sb.append('\n');
if (LOG.isDebugEnabled())
LOG.debug("transferred message, sz: {}", sb.length());
// отправка ответа
resp.getWriter().write(sb.toString());
resp.flushBuffer();
}
}
..
Реализация также в виде сервлета, по аналогичной причине — для пропуска обработки и выдачи данных «как есть», без сериализации/десериализации в JSON, как в случае с REST API.
Поддерживается только один HTTP-метод GET, вызовом к которому и происходит забор сообщений.
Еще тут реализована простейшая авторизация — с помощью API-токена, передаваемого отдельным HTTP-заголовком:
..
final String authToken = req.getHeader("X-Webhooka-Auth");
if (authToken == null || authToken.isBlank()) {
LOG.warn("No authentication token");
resp.setStatus(403);
return;
}
..
Этого вполне достаточно для простейших случаев внутреннего API, доступ к которому осуществляется только изнутри инфраструктуры компании.
По сути это работает как защита от случайного вызова, чтобы API не дернули без предупреждения из другого внутреннего сервиса.
Следующий важный нюанс — формат выдаваемых данных, который точно вызовет вопросы у опытных читателей:
..
final StringBuilder sb = new StringBuilder();
for (MessagesStore.InputMessage m : pending) {
// append new string for elements after first
if (!sb.isEmpty())
sb.append('\n');
// append message body as plain string
// assume it does not contain new lines
sb.append(m.messageBody());
}
sb.append('\n');
if (LOG.isDebugEnabled())
LOG.debug("transferred message, sz: {}", sb.length());
// write to servlet's output stream
resp.getWriter().write(sb.toString());
..
Тут происходит формирование одного длинного строкового значения из накопленных сообщений, разделенных символом новой строки.
В нашем случае это работает, поскольку я на 100% уверен, что принимаемый с помощью вехбука JSON всегда содержит только экранированные символы новой строки
\n.
Если это не ваш случай — придется использовать в качестве разделителя данных какой-то другой символ или их последовательность.
Также добавлю, что все принимаемые вебхуком сообщения — всегда очень небольшого размера, поэтому нет необходимости городить потоковую обработку или использовать массивы байт.
В качестве иллюстрации, как-то так выглядит типичное сообщение, присылаемое в вебхук:
{"messages":[{"id":"99d5a29f72995aef6978725729e74982",
"author_id":1,
"created":1707559708,
"content":{"text":"[Системное сообщение] Здравствуйте
?\n\nЗдесь вам всегда ответит служба поддержки.
Если кто-то представляется поддержкой Авито в других чатах,
это мошенники — будьте начеку."},
"type":"system","direction":"in",
"isRead":true,"read":1707560003}],"meta":{"has_more":false}}
Разбивка на отдельные строки добавлена вручную — для удобства чтения, в оригинале это все одна длинная строка.
Клиент
Сторона клиента для чтения такого ответа выглядит примерно так:
..
// using new java.net.http.HttpClient
final HttpClient client = HttpClient.newHttpClient();
// create request with webhook internal API url
// and auth token
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8086/internal/api/hooks"))
.header("Content-Type", "application/json")
.header("X-Webhooka-Auth", "****")
.build();
// call to API
final HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
// get response body
String body = response.body();
// don't process if empty
if (StringUtils.isBlank(body))
return;
if (log.isDebugEnabled())
log.debug("input request: {}", body);
// split by new line
final String[] lines = body.split("\\r?\\n");
// process each line as separate JSON object
for (String l: lines) {
// parse JSON object
final JsonNode tree = mapper.readTree(l);
// process parsed JSON object
if (!processRequest(tree)) {
log.warn("Error processing request");
}
}
..
Тут нет автоматического связывания с DTO, как вы привыкли делать с помощью аннотаций Jackson, вместо этого вся обработка JSON происходит полностью вручную.
Так выглядит часть метода processRequest, отвечающего за получение конкретных значений полей из объекта JSON:
..
private boolean processRequest(JsonNode tree, JsonNode params) {
if (tree == null || tree.isEmpty()) {
log.warn("empty json");
return false;
}
if (!tree.has("id")) {
log.warn("no field 'id'");
return false;
}
JsonNode jrequestId = tree.get("id");
if (jrequestId.isNull()) {
log.warn("empty field 'id'");
return false;
}
..
Возможно код выше выглядит переусложнением или архаикой, но такой подход дает железобетонную гарантию надежности обработки JSON, поскольку сразу будет видно и какого именно поля нехватает и что требуемое поле — пустое.
Без длинных и бесполезных трассировок.
Напоминаю, что все принимаемые вебхуком сообщения — крайне небольшого объема, поэтому вы врядли устанете обрабатывать подобным образом требуемые поля с данными.
Проверка состояния
В исходном коде обработчика внутреннего API, приведенного выше есть одно интересное место:
// обязательно помечаем активность поключения со стороны ERP
cc.markAlive();
Этим вызовом к методу класса ConnectionCheck устанавливается признак активного подключения из внутренней системы и отсчет неактивности начинается заново.
Целиком код класса ConnectionCheck выглядит следующим образом:
..
@Service
public static class ConnectionCheck {
private transient boolean alive; // current connection state
private long lastCheck; // last time checked (millis)
@Value("${app.webhooka.connectExpireMin}")
private int connectExpireMin;
@Scheduled(initialDelay = 0, fixedDelay = 5000)
public void dropCheck() {
if (alive && System.currentTimeMillis() -
lastCheck > 1000L * 60 * connectExpireMin) {
alive = false;
LOG.debug("internal connection lost");
}
}
public void markAlive() {
this.lastCheck = System.currentTimeMillis();
if (!alive) {
this.alive = true;
LOG.debug("internal connection established");
}
}
public boolean isAlive() {
return alive;
}
}
..
Как можно заметить, метод dropCheck помечен аннотацией @Scheduled, что означает переодический вызов данного метода в фоне, согласно параметрам настройки — каждые 5 секунд.
Внутри метода происходит самое банальное сравнение между текущим временем и меткой последнего обращения к внутреннему API.
Если обращение было слишком давно — флаг alive сбрасывается.
Все просто.

Хранилище
На короткий отрезок времени между получением входящего запроса к вебхуку и выдачей накопленного внутренней системе, данные необходимо где-то хранить.
Без красивой истории с информированием внешней системы, пришлось бы поднимать СУБД, очередь сообщений
или еще какое неведомое чудище, либо самостоятельно сохранять входяшие запросы на диск — из-за риска утраты недоставленных сообщений.
Но поскольку мы выдаем 500ю ошибку в вебхук при отлетевшем подключении из внутренней системы, сервис фактически перестает получать входящие запросы до момента восстановления внутреннего подключения.
Поэтому хранить в памяти придется лишь очень небольшую часть сообщений, которым не повезло быть полученными непосредственно в момент, когда отвалилось подключение из внутренней системы.
Так выглядит исходный код хранилища:
package com.Ox08.serious.experiments.webhooka;
import org.slf4j.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory хранилище входящих сообщений.
* Сообщения хранятся до момента вызова внутреннего API со стороны ERP.
*
* @since 1.0
* @author 0x08
*/
@Service
public class MessagesStore {
private static final Logger LOG = LoggerFactory.getLogger("WEBHOOKA");
@Value("${app.webhooka.maxMessagesNum}")
private int maxMessagesLimit; // лимит на количество хранимых сообщений
// хранилище
private final Map<UUID, InputMessage> messages
= new ConcurrentHashMap<>();
/**
* Добавление сообщения
* @param msg
* данные - чаще всего в виде JSON
* @return
* true сообщение добавлено
* false - не добавлено
*/
public boolean addMessage(String msg) {
// check for max messages limit
if (messages.size() > maxMessagesLimit) {
LOG.warn("message limit exceeded, ignoring message: {}", msg);
return false;
}
// store new message
messages.put(UUID.randomUUID(), new InputMessage(msg));
if (LOG.isDebugEnabled())
LOG.debug("message added: {}", msg);
return true;
}
/**
@return
список полученных сообщений,
все они будут удалены из хранилища при вызове этого метода.
*/
public List<InputMessage> getPendingMessages() {
// check for empty storage
if (messages.isEmpty())
return Collections.emptyList();
final List<InputMessage> out = new ArrayList<>();
// retrieve key set
// note: other keys could be added to storage *during* this call
final Set<UUID> keys= Collections.unmodifiableSet(messages.keySet());
for (UUID k : keys)
// note: call to remove() returns removed object
out.add(messages.remove(k));
if (LOG.isDebugEnabled())
LOG.debug("got {} pending messages", out.size());
return out;
}
/**
* DTO для хранения временных сообщений
В оригинале полей было больше.
* @param messageBody
* данные
*/
public record InputMessage(String messageBody) {}
}
Из интересного, например реализация автоматического удаления записей при вызове метода получения getPendingMessages:
..
final List<InputMessage> out = new ArrayList<>();
final Set<UUID> keys= Collections.unmodifiableSet(messages.keySet());
for (UUID k : keys)
// note that call to remove() will return removed object
out.add(messages.remove(k));
if (LOG.isDebugEnabled())
LOG.debug("got {} pending messages", out.size());
return out;
..
А также проверка на лимит сохраняемых сообщений:
..
if (messages.size() > maxMessagesLimit) {
LOG.warn("message limit exceeded, ignoring message: {}", msg);
return false;
}
..
Что вообще-то не более чем простой предохранитель от перегрузки.
Хотя технически нет никакой проблемы держать в памяти хоть миллионы сообщений — современное оборудование это вполне позволяет.

Два сервера
Наконец последний интересный функционал — разделение внутреннего и внешнего API на два разных порта, в рамках одного и того же веб-приложения.
Этот функционал не то чтобы критически важен, но позволяет существенно снизить риски неправильного использования API или возможной утечки данных, поскольку доступ к разным портам куда проще разграничить, нежели к префиксам URL.
В случае Spring Boot и встроенного Apache Tomcat, используемого там по-умолчанию, настройка дополнительного порта выглядит следующим образом:
..
@Bean
public WebServerFactoryCustomizer<@NonNull TomcatServletWebServerFactory>
tomcatCustomizer() {
return (TomcatServletWebServerFactory factory) -> {
// add new connector with additional port
final Connector connector = new Connector();
connector.setPort(internalPort);
factory.addAdditionalConnectors(connector);
};
}
..
К сожалению этого кода недостаточно, поскольку мы лишь указали приложению использовать дополнительный порт, помимо основного.
Но обработчики как публичного так и приватного API будут продолжать работать на обеих портах.
Чтобы разграничить доступ внутри приложения на основе используемых портов, используется отдельная логика, в виде фильтра:
..
@Component
class WebhookaApiAccessFilter implements Filter {
@Override
public void doFilter(ServletRequest request,
ServletResponse response, FilterChain fc)
throws IOException, ServletException {
if (request instanceof HttpServletRequest req
&& response instanceof HttpServletResponse res) {
// проверка URL к которому идет обращение,
// для внутреннего API
if (req.getRequestURI()
.startsWith(req.getContextPath() + "/internal")
&& request.getServerPort() != internalPort) {
LOG.warn("access to internal API from public denied.");
res.sendError(404);
return;
}
// .. для внешнего API
if (req.getRequestURI()
.startsWith(req.getContextPath() + "/api/hooks/public")
&& request.getServerPort() == internalPort) {
LOG.warn("access to public API from internal denied.");
res.sendError(404);
return;
}
}
// обязательный вызов следующего фильтра в цепочке.
fc.doFilter(request, response);
}
}
..
Современный Spring умеет регистрировать фильтры обработки автоматически, достаточно указать implements jakarta.servlet.Filter при декларировании бина.
Но самое веселое будет происходить снаружи:
вызов к приватному API с публичного порта будет выдавать ошибку 404
Как и вызов публичного API с приватного порта — голубая мечта безопасника, да и только.
Тестовые вызовы
В качестве иллюстрации, показываю как выглядят тестовые вызовы к нашему сервису вебхуков.
Для симуляции внутренней системы, был реализован такой простой как топор шелл-скрипт, вызывающий в цикле приватное API:
#!/bin/bash
while true;
do
status=$(curl -s -L -X GET -H 'X-Webhooka-Auth: MDIwEAYHKoZIzj0CAQYFK4EEAAYDHgAEOSnsUrEOhSWErFap2IRdrbOkSrPGww6WBk+aMw==' http://localhost:8086/webhooka/internal/api/hooks);
printf "$(date +%H:%M:%S): $status \n";
sleep 10
done
Для симуляции получения входящего сообщения можно использовать вот такой запрос:
curl -L -X POST --data '{"test":"aaaa"}' http://localhost:8081/webhooka/api/hooks/public
Вся схема целиком в действии:

За кадром
Напоследок перечислю функционал, который остался за кадром, ради удержания размеров статьи в рамках разумного.
Напоминаю, что весь приведенный в статье код — своего рода дистиллят, созданный путем
многократной перегонкиочистки из реального кода нескольких разных проектов, с разной спецификой.
Так что все описанное ниже на самом деле работает в реальных проектах.
Признак потребителя
Не каждая интернет-платформа позволяет регистрировать несколько разных вебхуков, не каждая позволяет использовать дополнительные параметры запроса и не каждая отправляет какую-либо информацию о клиенте при вызове вебхука.
Поэтому при известной multi-tenant схеме — когда на платформе зарегистрировано несколько учетных записей для одной компании появляется проблема идентификации текущего профиля.
Для примера, может быть несколько разных продавцов на Авито или разные группы ВКонтакте, по каждой из которых необходимо принимать события и/или сообщения.
Эта задача рано или поздно возникнет и у вас, поэтому механизмы идентификации используемого на стороне онлайн-платформы профиля стоит продумать заранее, до того как приступать к реализации.
Даже если ваша реализация будет использовать MQ, Kafka и лысого черта на метле.
Ручная блокировка
Для управления сервисом вебхуков в реальном проекте, мы используем специальное админское API, позволяющее временно остановить прием входящих сообщений без остановки самого сервиса.
Ничего принципиально отличного от описанной выше логики там нет, приостановка работы реализована через тот же самый механизм искусственной генерации 500й ошибки.
Однако в случае административного интерфейса, признак active устанавливается программно, игнорируя таймер.
Метаданные
В одном из проектов, использующих вебхуки была необходимость фиксации метаданных для каждого входящего сообщения:
HTTP-заголовков, точного времени запроса, IP-адреса внешней системы и т.д.
Для этого пришлось усложнить обработку, сохраняя и передавая помимо сообщения еще и метаданные:
/**
* DTO для хранения временных сообщений
* @param messageType
* тип
* @param messageBody
* данные
*/
public record InputMessage(InputMessageType messageType,
String messageBody,
Map<String,String> params) {}
Из этой же серии была и поддержка разных типов данных:
одна из онлайн-платформ решила, что присылать JSON это недостаточно круто, поэтому они будут отправлять поля с данными непосредственно в виде параметров HTTP-запроса.
Пришлось поддерживать весь этот цирк.
Разные физические интерфейсы
Помимо разных портов, в Spring Boot существует возможность настроить прослушивание разных API на разных интерфейсах, т. е. приватная часть будет например доступна по адресу 192.168.1.100, а публичная — по 10.10.22.50.
По-умолчанию приложение на Spring Boot будет прослушивать все интерфейсы, что разумеется не очень хорошо с точки зрения ИБ.
P.S.
Более вольный оригинал как обычно в нашем блоге, исходный код проекта доступен в репозитории на Github.
В принципе этот сервис готов к реальному использованию после минимальных доработок и точно поможет закрыть любую проблематику поддержки вебхуков в небольшом проекте.
Пользуйтесь на здоровье.
Комментарии (4)

Cthulhu_II
13.12.2025 10:53А без шедулера в
ConnectionCheckникак? Поточнее дложно быть, но про логи надо подумать.@Service public static class ConnectionCheck { private long lastCheck; // last time checked (millis) @Value("${app.webhooka.connectExpireMin}") private int connectExpireMin; public void markAlive() { this.lastCheck = System.currentTimeMillis(); } public boolean isAlive() { return System.currentTimeMillis() - lastCheck > 1000L * 60 * connectExpireMin; } }
vierra_shoor
какие конкретные случаи из вашего опыта показали, что выбранный подход с вебхуками сработал лучше, чем, например, стандартный polling или другие интеграции API, и почему? спасибо
alex0x08 Автор
Поллинг если что и так используется для внутреннего API.
Какие конкретно случаи? Ну например "механический шагоход инженера Ларина" из очередей, отдельного MQ-брокера и СУБД, закрученнных в отдельные Docker-контейнеры внутри кластера Kubenetes, используемый на одном из проектов для ровно такой же задачи проброса сообщений с вебхука.
Думаю разницу между кластером и 300 строчками кода сможете оценить самостоятельно.