Вебхуки

События ваших диалогов приходят на ваш адрес в тот момент, когда происходят. Это обратное направление к серверному API: там вы зовёте нас, здесь мы зовём вас.

Настраиваются в кабинете: проект → вкладка «Вебхуки». У одного проекта их может быть несколько, каждый со своим набором событий и своим секретом. Ключ API для них не нужен — они ходят к вам, а не вы к нам.

Что приходит

POST на ваш адрес, тело — JSON в UTF-8.

{
    "event": "message.created.in",
    "occurred_at": "2026-08-23T10:15:30+03:00",
    "sequence": 1787472930123456,
    "delivery_id": "0198f3c1-...",
    "project": { "public_key": "pk_...", "name": "Мой сайт" },
    "data": {
        "message": {
            "id": "0198f3c0-...",
            "direction": "in",
            "body": "Сколько стоит доставка?",
            "author": { "type": "contact", "via": null, "name": null },
            "created_at": "2026-08-23T10:15:30+03:00"
        },
        "conversation": {
            "id": "0198f3bf-...",
            "status": "open",
            "led_by": "humans",
            "created_at": "..."
        },
        "contact": {
            "id": "0198f3be-...",
            "number": 5,
            "name": null,
            "email": null
        },
        "identity": {
            "channel": "widget",
            "external_id": "v_...",
            "page_url": "https://shop.example/cart",
            "attributes": { "uid": "7f3a...", "Тариф": "Pro" },
            "created_at": "..."
        }
    }
}

Заголовки:

Заголовок Что это
X-Globask-Event имя события, то же, что в поле event
X-Globask-Delivery уникальный id доставки — ключ идемпотентности
X-Globask-Signature подпись вида t=<unix>,v1=<hex>
User-Agent Globask-Webhook/1

Состав полей message, conversation, contact и identity почти тот же, что у чтения, — описание значений direction, author.type и author.via смотрите там. Три отличия, на которых легко обжечься: в событии у сообщения нет content_type и attachments, а у диалога — нет last_message_at. Всё это отдаёт чтение.

identity приходит в событиях сообщений и диалога. Его attributes — это window.globask.user. У события о сообщении посетителя (message.created.in) там значения того самого сообщения: сменился пользователь на вашем сайте — следующее событие придёт уже с новыми. У остальных событий своего сообщения нет, и приходит текущее состояние. Подделать эти значения можно из консоли браузера; как проверить, что значение выставил ваш сервер, — в разделе «Как сделать значение проверяемым».

Картинок в теле события нет, и признака «здесь картинка» тоже: у сообщения с картинкой без подписи придёт пустой body и ничего больше. Если вам важны вложения, забирайте сообщение чтением — там есть и content_type, и сами файлы.

События

Имя Когда
message.created.in посетитель написал в виджет
message.created.out менеджер или автоответ ответил посетителю
conversation.reopened закрытый диалог ожил: посетитель написал снова
conversation.closed диалог закрыт: командой в теме, кнопкой в кабинете или интеграцией
conversation.claimed диалог взяла на себя интеграция
conversation.handover диалог вернулся менеджерам
widget.opened посетитель открыл панель чата
inbox.failing доставка менеджерам сломана: бота выгнали из группы
ping проверочная доставка кнопкой из кабинета

Про widget.opened важно знать, что именно он считает. Это охват, а не клики: событие уходит при первом открытии панели на загруженной странице. Панель живёт в iframe, который повторно не создаётся, поэтому второе и третье открытие до нас просто не доходят. Если вам нужно «сколько раз кликнули» — этого события у нас нет, и делать вид, что есть, мы не станем.

У трёх событий свой состав data, и он короче общей схемы:

  • widget.opened — {"contact": {…}|null, "page_url": "…"|null}. Ни conversation, ни identity здесь нет: диалога может ещё не быть, панель открыли и только. contact бывает null — посетитель, который ни разу не писал, у нас ещё не заведён; на этом проще всего уронить разбор.
  • inbox.failing — {"inbox": {"type", "title", "status"}, "reason": "…"}.
  • ping — {"message": "Проверочная доставка из кабинета Globask."}.

Проверка подписи

Подписывается метка времени вместе с телом, а не одно тело. Так записанный запрос нельзя проиграть вам повторно через месяц: подпись без метки оставалась бы валидной вечно.

Строка, от которой считается HMAC-SHA256: <timestamp>.<сырое тело>.

$parts = [];
foreach (explode(',', $request->header('X-Globask-Signature')) as $part) {
    [$key, $value] = explode('=', trim($part), 2);
    $parts[$key] = $value;
}

$expected = hash_hmac('sha256', $parts['t'].'.'.$request->getContent(), $secret);

// Окно в 5 минут: часы на наших серверах и на вашем могут разойтись.
$fresh = abs(time() - (int) $parts['t']) <= 300;

if (! $fresh || ! hash_equals($expected, $parts['v1'])) {
    abort(403);
}

Три вещи, на которых обычно ошибаются:

  1. Считайте подпись от сырого тела, до разбора JSON. Перекодированный JSON — уже другие байты, и подпись не сойдётся.
  2. Сравнивайте hash_equals, а не ===: обычное сравнение строк утекает время и позволяет подобрать подпись побайтно.
  3. Проверяйте метку времени. Без неё подпись защищает от подделки, но не от повтора.

Секрет показывается один раз — сразу после создания вебхука. Повторно мы его не покажем: он хранится шифрованным, и ключ, который можно подсмотреть в интерфейсе спустя месяц, защищает хуже, чем кажется. Потеряли — заведите вебхук заново.

Идемпотентность

X-Globask-Delivery не меняется между попытками: если мы отправили событие, не дождались ответа и повторили, у обеих отправок будет один и тот же id. Запоминайте его и отбрасывайте повторы — иначе сетевой таймаут на нашей стороне обернётся вторым лидом в вашей CRM. А если по событию вы выполняете платное действие — ещё и вторым списанием у вашего пользователя.

Мы этот урок выучили на входе: у нас так же дедуплицируются сообщения виджета и апдейты Telegram.

Порядок

Порядок доставки не гарантирован. События независимы, и медленный ответ на одно не должен задерживать остальные.

Для упорядочивания есть два поля: occurred_at (человекочитаемое время события) и sequence (то же время в микросекундах, целым числом). Сортируйте по sequence.

Ретраи и что значит «доставка не проходит»

Отвечайте 2xx — этого достаточно, тело ответа мы не читаем. Любой другой код и любой сетевой сбой считаются неудачей.

  • Повторяем с паузами 5, 15, 60, 180 и 300 секунд; дальше — каждые 300 секунд, пока не кончится бюджет.
  • Общий бюджет одной доставки — 30 минут. После него событие помечается неудачным и больше не повторяется.
  • Три исчерпанных бюджета подряд переводят вебхук в состояние «доставка не проходит»: отправка останавливается совсем.

Остановка — не каприз: мёртвый адрес иначе занимал бы очередь бесконечно. Починив приём, включите вебхук в кабинете. Накопленные за простой события повторно не уйдут — если для вас это важно, вычитайте пропущенное чтением: списки листаются курсором как раз для таких случаев.

Ответьте быстро: у нас 5 секунд на ответ и 3 на соединение. Тяжёлую работу кладите в свою очередь, а нам отвечайте сразу.

Требования к адресу

  • Только https. Подпись защищает от подделки, но не от чтения, а в теле едет переписка ваших посетителей.
  • Адрес должен быть доступен из интернета. Внутренние диапазоны (127.0.0.1, 10.*, 192.168.*, 169.254.* и прочие приватные сети) мы не вызываем — ни напрямую, ни через имя, которое в них резолвится.
  • Редиректы не выполняются: отвечайте по тому адресу, который вы указали.
  • Логин и пароль в адресе не поддерживаются — для авторизации используйте подпись.

Что наружу не уходит никогда

Настоящие имена наших и ваших сотрудников. В поле author.name приходит публичное имя менеджера, а если оно не задано — «Оператор». Внутренние числовые идентификаторы тоже не уходят: все сущности адресуются uuid, проект — своим public_key.

conversation.handover заслуживает отдельного слова

Это событие приходит по двум разным поводам, и оба нужны боту:

  • ваша интеграция сама вызвала POST /conversations/{id}/handover;
  • менеджер ответил в диалоге — из темы Telegram или из кабинета — и тем самым забрал разговор себе.

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

Тело — то же, что у остальных событий диалога; поле conversation.led_by после такого события равно humans.