Разработчикам

Вебхуки WhatsApp: как принимать входящие сообщения и статусы доставки

Обновлено 7 мин чтения

Вебхук — адрес вашего сервера, на который 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: к какому вашему номеру оно относится.

JSON
{
  "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.fromMetrue, если сообщение с вашего номера
payload.hasMediaВ сообщении есть вложение — файл сохраняется сам, см. входящие файлы
payload.participantВ группе — кто именно написал
payload.idid в WhatsApp; третья часть между «_» — это wa_message_id
wapi.number_idid вашего номера — тот же, что в поле from API
wapi.message_idid сообщения в журнале 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, нужным доменом и слешем на конце, если сервер его требует.
  • Подписи нет: проверяйте секрет в адресе и не пишите его в публичные логи.

Догнать пропущенное после простоя — один запрос к журналу:

bash
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, потом обработка.

JavaScript
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+. Обработка уходит в отдельный поток, чтобы ответ ушёл сразу; в продакшене удобнее очередь задач.

Python
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
<?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:

JavaScript
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, и пропишите его в кабинете:

bash
# Тестовое событие на локальный обработчик
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 минут.