Вебхуки WhatsApp: как принимать входящие сообщения и статусы доставки
Вебхук — адрес вашего сервера, на который wapi.kz сразу пересылает события номера: входящее сообщение, ответ менеджера с телефона, статус доставки, отключение номера. Без вебхука программе пришлось бы каждые несколько секунд спрашивать API «есть что-то новое?» — это polling, и для чат-ботов и CRM он неудобен: задержка, лишние запросы, сложнее код.
В гайде — как подключить вебхук WhatsApp и принимать входящие сообщения через API: формат событий, правила приёма и готовые обработчики на Node.js, Python и PHP.
Как подключить вебхук
Шаг 1. Поднимите обработчик
Нужен URL, который принимает POST с JSON и отвечает 200. Адрес — только https:// и только публичный: localhost, внутренние IP и адреса за VPN не подойдут. Для разработки есть туннели — о них в конце гайда.
Шаг 2. Добавьте секрет в адрес
У запросов wapi.kz нет подписи, поэтому отправителя проверяют по секрету в самом адресе: https://crm.example.kz/whatsapp/webhook?token=СЕКРЕТ. Длинную случайную строку даст, например, openssl rand -hex 24. Запросы без правильного token отклоняйте.
Шаг 3. Укажите адрес в кабинете
Для одного номера — на карточке номера, для всех номеров сразу — в разделе «API и MCP».
Шаг 4. Проверьте
Напишите на подключённый номер с другого телефона — в обработчик придёт событие message. Ответьте на это сообщение с самого рабочего телефона — придёт message.out.
Формат запроса
wapi.kz отправляет POST с заголовками Content-Type: application/json и User-Agent: wapi.kz-webhook. Тело — событие WhatsApp как есть плюс поле wapi: к какому вашему номеру оно относится.
{
"event": "message",
"timestamp": 1792154662123,
"payload": {
"id": "false_77011234567@c.us_3EB0B7C1D2E3F4A5",
"from": "77011234567@c.us",
"fromMe": false,
"body": "Здравствуйте, заказ ещё в силе?",
"hasMedia": false
},
"me": { "id": "77010000000@c.us", "pushName": "Магазин" },
"wapi": { "number_id": 1, "phone": "77010000000", "message_id": 1843, "peer": "77011234567", "peer_lid": null, "name": "Айгерим", "is_group": false }
}| Поле | Что значит |
|---|---|
event | Тип события |
payload.from | Чат — всегда здесь, и у входящих, и у исходящих: …@c.us — личный чат, …@lid — личный чат со скрытым номером, …@g.us — группа |
payload.body | Текст сообщения |
payload.fromMe | true, если сообщение с вашего номера |
payload.hasMedia | В сообщении есть вложение — файл сохраняется сам, см. входящие файлы |
payload.participant | В группе — кто именно написал |
payload.id | id в WhatsApp; третья часть между «_» — это wa_message_id |
wapi.number_id | id вашего номера — тот же, что в поле from API |
wapi.message_id | id сообщения в журнале wapi.kz: для message и message.out — новое сообщение, для message.ack — ваше отправленное (если оно есть в журнале), иначе null |
wapi.peer, wapi.peer_lid | Для сообщений: телефон собеседника по журналу (для скрытого номера — если известен, иначе null) и его скрытый идентификатор …@lid |
wapi.name, wapi.is_group | Имя собеседника из WhatsApp или название группы и признак группы |
В настоящем событии есть и служебные поля — их можно пропускать. Полный формат — в документации по вебхукам.
Шесть событий
| event | Когда приходит |
|---|---|
message | Собеседник написал вам |
message.out | Человек сам написал с вашего номера — с телефона, в WhatsApp Web или Desktop |
message.ack | Изменился статус доставки исходящего сообщения |
session.status | Изменился статус подключения номера |
export.ready | Готова выгрузка архивом: в payload — статус и ссылка на ZIP |
storage.warning | Предупреждение по хранилищу файлов: не хватает баланса на место сверх 5 ГБ, скоро удаление или файлы удалены |
Сообщения, отправленные через API или MCP, отдельным событием не приходят — о них вы знаете из ответа на запрос. Повторные уведомления WhatsApp об одном и том же сообщении мы отсеиваем.
message.out: ответил человек
Номер остаётся на телефоне, и менеджер может ответить клиенту сам. Для ИИ-бота это сигнал отступить: разговор перехватил человек, и автоответ поверх его сообщения будет выглядеть странно. Чат клиента — в payload.from, как и у входящих. В истории такие сообщения отмечены source: "phone".
message.ack: статусы доставки
| payload.ack | Статус |
|---|---|
1 | Отправлено (одна галочка) |
2 | Доставлено (две галочки) |
3 | Прочитано |
4 | Голосовое прослушано — считаем прочитанным |
-1 | Ошибка доставки |
Чтобы связать статус со своей отправкой, сравните wapi.message_id с id из ответа на POST /messages/send или возьмите из payload.id третью часть (между вторым и третьим «_») — это wa_message_id из того же ответа. Если получатель отключил отчёты о прочтении, «прочитано» не придёт — так устроен WhatsApp.
session.status: подключение номера
payload.status: WORKING — номер работает, SCAN_QR_CODE и STARTING — идёт подключение, FAILED или STOPPED — номер отключился и его нужно переподключить в кабинете. Мы тоже пришлём уведомление, но если от WhatsApp зависят заказы, повесьте на FAILED своё оповещение.
Личный чат или группа
Если номер состоит в группах, их сообщения тоже придут событием message. У личного чата payload.from заканчивается на @c.us или @lid, у группы — на @g.us, а автора в группе показывает payload.participant. Отправить ответ в группу через API нельзя, поэтому ботам группы лучше сразу пропускать.
Изредка WhatsApp скрывает телефон собеседника — тогда чат оканчивается на @lid. Это тоже личный чат, но цифры в нём — не телефон: телефон, если он известен, придёт в wapi.peer, иначе там null (см. LID).
Правила приёма
- Отвечайте
200в течение 10 секунд. Ответ ИИ, запись в CRM и другую тяжёлую работу делайте после ответа — в фоне или через очередь. - Повторной отправки нет. Если сервер лежал или ответил ошибкой, событие не придёт снова — заберите пропущенное из истории (пример ниже).
- Перенаправления (301, 302) мы не выполняем. Указывайте конечный адрес — с
https, нужным доменом и слешем на конце, если сервер его требует. - Подписи нет: проверяйте секрет в адресе и не пишите его в публичные логи.
Догнать пропущенное после простоя — один запрос к журналу:
curl -G https://wapi.kz/api/v1/messages \
-H "Authorization: Bearer $WAPI_KEY" \
--data-urlencode "since=2026-10-17T06:00:00Z" \
-d direction=in -d limit=500Пагинация, фильтры и дедупликация по id — в гайде про историю переписки.
Обработчик на Node.js
Express: сначала проверка секрета и ответ 200, потом обработка.
import express from "express";
const HOOK_TOKEN = process.env.WAPI_HOOK_TOKEN;
if (!HOOK_TOKEN) throw new Error("Задайте WAPI_HOOK_TOKEN");
const app = express();
app.post("/whatsapp/webhook", express.json({ limit: "1mb" }), (req, res) => {
if (req.query.token !== HOOK_TOKEN) return res.sendStatus(403);
res.sendStatus(200); // ответить сразу, обработать — после
handleEvent(req.body).catch((err) => console.error("webhook:", err));
});
async function handleEvent(e) {
const p = e.payload || {};
if (e.event === "message" && /@(c\.us|lid)$/.test(String(p.from))) {
// у @c.us телефон — в адресе чата; у скрытого номера (@lid) цифры — не телефон, берём wapi.peer
const client = p.from.endsWith("@c.us") ? p.from.split("@")[0] : e.wapi.peer ?? p.from;
console.log(`#${e.wapi.number_id} ← ${client}: ${p.body ?? ""}${p.hasMedia ? " [вложение]" : ""}`);
} else if (e.event === "message.out") {
console.log(`Менеджер сам ответил ${e.wapi.peer ?? p.from} — бот молчит`);
} else if (e.event === "message.ack") {
const waMessageId = String(p.id).split("_")[2];
console.log(`Статус ${waMessageId}: ${p.ack}`);
} else if (e.event === "session.status" && ["FAILED", "STOPPED"].includes(p.status)) {
console.error(`Номер ${e.wapi.phone} отключился: ${p.status}`);
}
}
app.listen(3000);Обработчик на Python
Flask 2+. Обработка уходит в отдельный поток, чтобы ответ ушёл сразу; в продакшене удобнее очередь задач.
import hmac
import os
import threading
from flask import Flask, abort, request
app = Flask(__name__)
HOOK_TOKEN = os.environ["WAPI_HOOK_TOKEN"]
@app.post("/whatsapp/webhook")
def webhook():
token = request.args.get("token", "")
if not hmac.compare_digest(token.encode(), HOOK_TOKEN.encode()):
abort(403)
event = request.get_json(silent=True) or {}
threading.Thread(target=handle_event, args=(event,), daemon=True).start()
return "", 200
def handle_event(e):
p = e.get("payload") or {}
kind = e.get("event")
if kind == "message" and p.get("from", "").endswith(("@c.us", "@lid")):
# у скрытого номера (@lid) цифры — не телефон: берём wapi.peer, если он известен
client = p["from"].split("@")[0] if p["from"].endswith("@c.us") else e["wapi"].get("peer") or p["from"]
print(f"#{e['wapi']['number_id']} <- {client}: {p.get('body') or ''}")
elif kind == "message.ack":
parts = p.get("id", "").split("_")
print("Статус", parts[2] if len(parts) >= 3 else None, "->", p.get("ack"))
elif kind == "session.status" and p.get("status") in ("FAILED", "STOPPED"):
print("Номер отключился:", e["wapi"]["phone"], p["status"])
if __name__ == "__main__":
app.run(port=3000)Обработчик на PHP
PHP 8+. На PHP-FPM fastcgi_finish_request() отдаёт ответ сразу, и дальше скрипт работает без спешки.
<?php
// Адрес вебхука: https://example.kz/whatsapp-webhook.php?token=СЕКРЕТ
$secret = getenv("WAPI_HOOK_TOKEN");
if (!$secret || !hash_equals($secret, (string) ($_GET["token"] ?? ""))) {
http_response_code(403);
exit;
}
$event = json_decode(file_get_contents("php://input"), true) ?: [];
http_response_code(200);
if (function_exists("fastcgi_finish_request")) {
fastcgi_finish_request();
}
$p = $event["payload"] ?? [];
switch ($event["event"] ?? "") {
case "message":
$from = $p["from"] ?? "";
if (str_ends_with($from, "@c.us") || str_ends_with($from, "@lid")) {
// у скрытого номера (@lid) цифры — не телефон: берём wapi.peer, если он известен
$client = str_ends_with($from, "@c.us") ? explode("@", $from)[0] : ($event["wapi"]["peer"] ?? $from);
error_log("WhatsApp от {$client}: " . ($p["body"] ?? ""));
}
break;
case "message.ack":
$waMessageId = explode("_", $p["id"] ?? "")[2] ?? null;
error_log("Статус {$waMessageId}: " . ($p["ack"] ?? ""));
break;
case "session.status":
error_log("Номер {$event['wapi']['phone']}: " . ($p["status"] ?? ""));
break;
}Входящие файлы
Фото, видео, голосовые, документы и стикеры от клиентов сохраняются сами. В вебхуке у такого сообщения payload.hasMedia: true, а payload.media.url пустой: это внутренняя ссылка, она вам не нужна. Через несколько секунд файл появляется в истории — GET /messages/{wapi.message_id} вернёт сообщение с полем media, а в media.url будет ссылка на скачивание, она живёт 15 минут. Пока файл сохраняется, у сообщения media_pending: true:
async function getMedia(messageId) {
for (let i = 0; i < 5; i++) {
const m = await fetch(`https://wapi.kz/api/v1/messages/${messageId}`, {
headers: { Authorization: `Bearer ${process.env.WAPI_KEY}` },
}).then((r) => r.json());
if (m.media) return m.media; // { id, kind, mimetype, filename, size, url }
if (!m.media_pending) return null; // не сохранён — причина в m.media_skip_reason
await new Promise((r) => setTimeout(r, 3000));
}
return null;
}Поля media и хранение файлов — в гайде про историю переписки и в документации. Отправлять клиентам фото, документы, голосовые и видео — см. гайд как отправить файлы через API.
Локальная отладка
Код обработчика удобно сначала проверить без WhatsApp — отправить себе тестовое событие. Когда он заработал, откройте к локальному порту временный публичный https-адрес туннелем, например ngrok или cloudflared, и пропишите его в кабинете:
# Тестовое событие на локальный обработчик
curl -X POST "http://localhost:3000/whatsapp/webhook?token=$WAPI_HOOK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"event":"message","payload":{"id":"false_77011234567@c.us_TEST1","from":"77011234567@c.us","fromMe":false,"body":"Тест","hasMedia":false},"wapi":{"number_id":1,"phone":"77010000000","message_id":null}}'
# Временный публичный https-адрес для порта 3000 (любой из вариантов)
ngrok http 3000
cloudflared tunnel --url http://localhost:3000Адрес туннеля обычно меняется при перезапуске, а пока туннель закрыт, события теряются. Для работы с клиентами нужен постоянный адрес на сервере.
Подключить номер и задать вебхук можно после регистрации. Вебхуки и запросы к API бесплатны: вы платите за номер, а за хранение файлов — только если они займут больше бесплатных 5 ГБ, см. цены.
Частые вопросы
Почему в вебхук не приходят сообщения, отправленные через API?
Так задумано: о своей отправке вы знаете из ответа на POST /messages/send. Вебхук сообщит только её статусы — событием message.ack.
Что будет, если мой сервер был недоступен?
События за это время в вебхук уже не придут — повторной отправки нет. Заберите их из журнала запросом GET /messages?since=… и обработайте те, которых у вас ещё нет.
Можно ли задать разные адреса для разных номеров?
Да. Адрес для одного номера задаётся на его карточке, общий для всех номеров — в разделе «API и MCP».
Как убедиться, что запрос пришёл именно от wapi.kz?
Подписи у запросов нет, поэтому добавьте в адрес секретный token и сверяйте его в обработчике. Заголовок User-Agent: wapi.kz-webhook подделать легко — для проверки он не годится.
Приходят ли сообщения, которые менеджер пишет с телефона?
Да, событием message.out. Так ваша система или ИИ-агент узнают, что клиенту уже ответил человек.
Подключите WhatsApp к своим системам
9 900 ₸ в месяц за номер, без платы за сообщения. Сервер в Казахстане, оплата Kaspi или счётом на ТОО/ИП. Подключение — около 5 минут.