Вебхуки — входящие к вам
Регистрация вебхука. PUT /webhook/ — укажите свой https-URL (не приватный адрес) и на какие потоки подписаться. В ответе hmac_secret — им подписаны все доставки к вам, сохраните его. PUT заменяет подписку ЦЕЛИКОМ, это не частичное обновление: subscribe_templates по умолчанию false, поэтому в каждом PUT, где нужны шаблоны, повторяйте subscribe_templates: true — иначе следующий PUT молча выключит поток шаблонов:
curl -X PUT https://dialogs.tehprof.kz/api/v1/gw/webhook/ \
-H "Authorization: Bearer gw_xxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-service.example/wahub",
"subscribe_messages": true,
"subscribe_statuses": true,
"subscribe_templates": true,
"channel_ids": [32]
}'
200 {
"hmac_secret": "gw_9a8b7c…",
"url": "https://your-service.example/wahub",
"subscribe_messages": true, "subscribe_statuses": true,
"subscribe_templates": true, "channel_ids": ["32"]
}Подписка на конкретные номера. channel_ids сужает поток до перечисленных каналов; пустой список (по умолчанию) — весь поток тенанта, как было раньше. Чужой или отключённый channel_id вернёт 404, а не молчаливую подписку в никуда. Эксклюзива нет: на один канал может быть подписано несколько потребителей, событие получат все.
Проверить и отключить. GET /webhook/ показывает текущую регистрацию — URL, потоки, каналы; секрет там не отдаётся (чтение настроек и право подделывать наши доставки — разные вещи). DELETE /webhook/ очищает URL и останавливает доставку целиком; повторный DELETE тоже 204.
Входящие вебхуки. Мы шлём на ваш URL три потока. Поток messages — новые сообщения. Входящие приходят с direction: "incoming". Ответы операторов из Диалогов и CRM, сообщения с телефона и отправки через API другими ключами приходят как message.created с direction: "outgoing" и source (wahub — Диалоги или API, crm — CRM, channel_echo — телефон). То, что ваш ключ отправил сам через POST /messages/, в вебхук не дублируется:
{
"messages": [
{
"event": "message.created",
"message_id": "8841",
"channel_id": "32",
"chat_type": "whatsapp",
"chat_id": "77010009988",
"occurred_at": "2026-10-20T09:12:44.512000+00:00",
"direction": "incoming",
"source": "customer",
"text": "Привет!",
"content_type": "text",
"media_url": "",
"file_name": "",
"file_mimetype": "",
"reply_to": "",
"sender_name": "Айгерим",
"sender_phone": "77010009988",
"metadata": {},
"edited": false
}
]
}direction — incoming или outgoing. source — откуда сообщение: customer (написал клиент), wahub (отправлено из Dialogs, включая ваши же отправки через этот API), channel_echo (отправлено прямо с телефона, мимо Dialogs), crm (отправлено из подключённой CRM). Фильтруйте по source и direction, иначе бот ответит на собственное сообщение. Пустых значений нет: media_url, file_name, file_mimetype, reply_to и sender_name приходят пустой строкой, а не null.
Медиа. media_url — это НЕ публичная ссылка, а путь на нашем хосте (/media/…). Дописывайте к нему адрес API и качайте с тем же заголовком Authorization: Bearer gw_… — в браузере без заголовка он не откроется. Входящий файл WhatsApp скачивается уже после того, как сообщение пришло: сначала придёт event message.created с пустым media_url, а когда файл готов — event message.updated с тем же message_id, ссылкой, file_name и file_mimetype. Если файл получить не удалось, message.updated придёт с content_type text и пояснением в text. Храните сообщение по message_id и заменяйте его целиком при message.updated. Правка текста тоже приходит как message.updated, с edited: true. Если сообщение удалили у всех, придёт event message.deleted с тем же message_id: text, файл и metadata в нём пустые — удалите сообщение у себя. Неотправленные вам события удалённого сообщения мы уже не шлём, а в истории удалённых нет.
sender_phone — кто написал: в группе это участник, в личном чате — номер самого чата. metadata — что значит сообщение, ключи есть только когда относятся к нему: audio {voice, duration} — voice true у голосового; video {note, gif, duration} — note true у кружка; location {lat, lon, name, address}; contact {name, phones, business}; album {id} — общий у фото одного альбома; call {kind, outcome, duration_sec}; forwarded {many}; mentions — {id из текста: телефон или null}; reaction {emoji, message_id} — message_id нашего сообщения, на которое реакция, если оно у нас есть. Первым смотрите notice {kind}: если он есть, это служебная запись, а не текст клиента — unsupported_type (WhatsApp прислал то, что мы не показываем) или media_unavailable (файл получить не удалось). Незнакомые ключи пропускайте: список будет расти.
Поток statuses — обновления доставки для отправленных вами сообщений (sent → delivered → read либо failed). message_id — тот же, что вернул POST /messages/. Доставки могут прийти не по порядку: применяйте статус, только если status_rank больше сохранённого (pending 0, sent 1, delivered 2, read 3, played 4). failed имеет ранг −1 и означает отдельный конечный исход; у него есть error: code — наш код причины (recipient_not_registered — у номера нет WhatsApp, recipient_unreachable, file_too_large, type_unsupported, provider_unavailable, provider_error, unknown), provider_code — код провайдера (например, 131042 у WhatsApp Cloud — проблема с оплатой у Meta), description — текст провайдера, reason — причина отказа провайдера точнее, чем code (например, marketing_per_user_limit, payment_method_problem; пустая строка, если причина не распознана; значения могут добавляться). То же поле есть у failed в GET /messages/. occurred_at — когда мы зафиксировали событие; X-Wahub-Timestamp — лишь время HTTP-попытки:
{
"statuses": [
{
"message_id": "msg_7f3c",
"channel_id": "32",
"chat_type": "whatsapp",
"chat_id": "77010009988",
"status": "delivered",
"status_rank": 2,
"occurred_at": "2026-10-20T09:13:05.104000+00:00"
}
]
}Поток templates — изменения каталога шаблонов (по умолчанию выключен, включается subscribe_templates). Приходит на каждый вердикт Meta, так что опрашивать вердикт не нужно. Поле change говорит, ЧТО изменилось: status (одобрен/отклонён), components (правка тела), quality (рейтинг качества) или category — реагируйте по нему, не сравнивая версии. status — всегда текущий. Одно событие приходит на каждый канал, который обслуживает эта WABA:
{
"templates": [
{
"template_id": "123",
"channel_id": "32",
"change": "status",
"name": "order_shipped",
"language": "ru",
"status": "APPROVED",
"category": "UTILITY",
"quality_rating": "GREEN",
"rejection_reason": ""
}
]
}Проверка подписи. Каждая доставка несёт X-Wahub-Timestamp (Unix-секунды), X-Wahub-Delivery-ID (UUID доставки) и X-Wahub-Signature-V2: sha256=<hmac_sha256(hmac_secret, timestamp + "." + delivery_id + "." + СЫРОЕ тело)>. Считайте HMAC по сырым байтам запроса — до любого JSON-парсинга, иначе подпись не сойдётся. Отклоняйте доставки, чей timestamp расходится с вашими часами больше чем на 5 минут: так перехваченный запрос нельзя прислать заново. Заголовок разбивайте по запятой и принимайте, если совпала ЛЮБАЯ часть: на время смены секрета там два значения (см. ниже), обычно — одно. Прежний заголовок X-Wahub-Signature (HMAC только по телу, без времени) ещё приходит для совместимости; новые обработчики проверяют V2.
import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers["X-Wahub-Timestamp"]
delivery_id = headers["X-Wahub-Delivery-ID"]
if abs(time.time() - int(ts)) > 300:
return False
signed = f"{ts}.{delivery_id}.".encode() + raw_body
expected = "sha256=" + hmac.new(
secret.encode(), signed, hashlib.sha256
).hexdigest()
sent = headers["X-Wahub-Signature-V2"].split(",")
return any(hmac.compare_digest(expected, s) for s in sent)import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, headers, secret) {
const ts = headers["x-wahub-timestamp"];
const deliveryId = headers["x-wahub-delivery-id"];
if (!(Math.abs(Date.now() / 1000 - Number(ts)) <= 300)) return false;
const expected =
"sha256=" +
createHmac("sha256", secret)
.update(`${ts}.${deliveryId}.`)
.update(rawBody)
.digest("hex");
const want = Buffer.from(expected);
return (headers["x-wahub-signature-v2"] ?? "").split(",").some((sent) => {
const got = Buffer.from(sent);
return got.length === want.length && timingSafeEqual(got, want);
});
}Смена секрета без простоя. POST /webhook/rotate-secret/ выдаёт новый hmac_secret и оставляет прежний действительным ещё 24 часа. В это время каждая доставка подписана обоими секретами, а оба заголовка подписи содержат по два значения через запятую — поэтому выкатить новый секрет можно в любой удобный момент, не потеряв ни одного вебхука. Заголовок Idempotency-Key обязателен: придумайте уникальное значение для этой смены и повторяйте его при ретраях — повтор вернёт тот же секрет, а не сменит его второй раз. Зовите сразу, как секрет мог утечь, и по своему регламенту. Повторная смена внутри окна вытесняет именно тот секрет, который меняли последним, — утёкший не переживёт своё окно. Новый секрет показывается только в этом ответе, из GET /webhook/ его не прочитать.
curl -X POST https://dialogs.tehprof.kz/api/v1/gw/webhook/rotate-secret/ \
-H "Authorization: Bearer gw_xxx" \
-H "Idempotency-Key: rotate-2026-10-20"
200 {
"hmac_secret": "gw_new1a2b…",
"previous_secret_valid_until": "2026-08-09T09:12:44+00:00"
}
# пока окно открыто:
X-Wahub-Signature-V2: sha256=<новым>,sha256=<прежним>Повторы и дубли. Отвечайте 2xx быстро, а тяжёлую работу делайте после. Если ваш сервер не ответил 2xx за 10 секунд, Диалоги повторят отправку ещё 9 раз примерно за 27 часов (через ~30 с, 2 мин, 10 мин, 30 мин, 1 ч, 2 ч, 4 ч, 8 ч и 12 ч) и учтут более долгий Retry-After. Сразу прекращаем только при ответе 410 Gone. После этого событие можно забрать из ленты подписки и повторить вручную (см. «Добрать пропущенное»). Повтор приходит с тем же X-Wahub-Delivery-ID — по нему и отсеивайте дубли: запомните принятые ID и на повтор отвечайте 2xx. Отсеивать по message_id нельзя: message.updated и message.deleted приходят с тем же message_id и несут новое состояние сообщения.