Вебхуки — входящие к вам

Регистрация вебхука. 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 и несут новое состояние сообщения.