Вебхук — адрес вашего сервера, на который Gratio сам присылает каждое уведомление запросом POST. Опрашивать очередь не нужно: уведомление приходит, как только событие записано.
Что получится: обработчик, который проверяет токен, сразу отвечает 200 и обрабатывает уведомление в фоне, без дублей.
Что понадобится
- канал с ключом — Шаг 1. Подключите номер
- Python 3.10+ / Node.js 18+ / PHP 8+
- публичный HTTPS-адрес; для разработки хватит туннеля — шаг 3
Как это работает
Каждое уведомление — отдельный запрос на ваш адрес:
Gratio Ваш сервер
│ POST /gratio/hook │
│ Authorization: Bearer s3cr3t-hook-token │
│ {"typeWebhook": "incomingMessageReceived"…} │
│ ───────────────────────────────────────────►│ проверить токен
│◄──────────────────────────────────── 200 OK │ ответить сразу
│ │ обработать в фоне
│ POST /gratio/hook следующее уведомление ──►│
- Уведомления идут по одному и по порядку: следующее — после того, как принято текущее.
- Ответ
2xx— доставлено. Тело ответа Gratio не читает. - Gratio ждёт ответа до 30 секунд. Отвечайте сразу, долгую работу делайте после ответа.
- Не
2xx, тайм-аут или сбой сети — повтор. Расписание — ниже, в разделе «Ответ и повторы».
Шаг 1. Напишите обработчик
Обработчик делает четыре вещи:
- Сверяет заголовок
Authorizationс вашим токеном — так он принимает уведомления только от Gratio. - Отсекает повтор по ключу события:
eventId, а у уведомлений без него — хеш тела. Повтор приходит тем же телом. - Отвечает
200сразу. - Обрабатывает уведомление после ответа.
Токен — любая длинная случайная строка. В примерах он в переменной GRATIO_HOOK_TOKEN, значение — s3cr3t-hook-token.
Python (FastAPI)
Файл hook.py:
import hashlib
import hmac
import json
import os
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException
app = FastAPI()
EXPECTED = f"Bearer {os.environ['GRATIO_HOOK_TOKEN']}"
seen: set[str] = set() # в работе — таблица в базе или Redis
def event_key(body: dict) -> str:
"""eventId, а если его нет (статусы, состояние канала) — хеш тела: повтор приходит тем же телом."""
if body.get("eventId"):
return body["eventId"]
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
def process(body: dict) -> None:
if body["typeWebhook"] == "incomingMessageReceived":
sender = body["senderData"]
print(sender["chatId"], sender["senderName"], body["messageData"]["typeMessage"])
@app.post("/gratio/hook")
async def gratio_hook(body: dict, tasks: BackgroundTasks, authorization: str = Header("")) -> dict:
if not hmac.compare_digest(authorization, EXPECTED):
raise HTTPException(status_code=401)
key = event_key(body)
if key not in seen:
seen.add(key)
tasks.add_task(process, body) # выполнится после ответа
return {"ok": True}
Запуск:
pip install fastapi uvicorn
GRATIO_HOOK_TOKEN=s3cr3t-hook-token uvicorn hook:app --port 8000
Node.js (Express)
Файл hook.mjs:
import crypto from "node:crypto";
import express from "express";
const expected = Buffer.from(`Bearer ${process.env.GRATIO_HOOK_TOKEN}`);
const seen = new Set(); // в работе — таблица в базе или Redis
function authorized(header = "") {
const got = Buffer.from(header);
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
// eventId, а если его нет (статусы, состояние канала) — хеш тела: повтор приходит тем же телом
function eventKey(body) {
return body.eventId || crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex");
}
function processEvent(body) {
if (body.typeWebhook === "incomingMessageReceived") {
const { chatId, senderName } = body.senderData;
console.log(chatId, senderName, body.messageData.typeMessage);
}
}
const app = express();
app.use(express.json({ limit: "1mb" })); // письма бывают больше 100 КБ — предела по умолчанию
app.post("/gratio/hook", (req, res) => {
if (!authorized(req.get("authorization"))) return res.sendStatus(401);
const key = eventKey(req.body);
res.json({ ok: true }); // ответить сразу
if (seen.has(key)) return;
seen.add(key);
setImmediate(() => processEvent(req.body));
});
app.listen(8000);
Запуск:
npm install express
GRATIO_HOOK_TOKEN=s3cr3t-hook-token node hook.mjs
PHP
Файл hook.php на вашем веб-сервере:
<?php
$expected = 'Bearer ' . getenv('GRATIO_HOOK_TOKEN');
if (!hash_equals($expected, $_SERVER['HTTP_AUTHORIZATION'] ?? '')) {
http_response_code(401);
exit;
}
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
exit;
}
// eventId, а если его нет (статусы, состояние канала) — хеш тела: повтор приходит тем же телом
$key = $body['eventId'] ?? hash('sha256', $raw);
header('Content-Type: application/json');
echo '{"ok":true}';
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // ответ ушёл, дальше — обработка
}
if (!already_seen($key)) {
process_event($body);
}
function already_seen(string $key): bool
{
// в работе — уникальный ключ в таблице базы
$mark = sys_get_temp_dir() . '/gratio-' . hash('sha256', $key);
if (file_exists($mark)) {
return true;
}
touch($mark);
return false;
}
function process_event(array $body): void
{
if ($body['typeWebhook'] === 'incomingMessageReceived') {
error_log($body['senderData']['chatId'] . ' ' . $body['messageData']['typeMessage']);
}
}
Apache с PHP-FPM иногда не передаёт PHP заголовок Authorization. Тогда добавьте в настройки сайта CGIPassAuth On.
Шаг 2. Проверьте обработчик локально
Пришлите обработчику уведомление сами — так, как его пришлёт Gratio:
curl -i -X POST http://localhost:8000/gratio/hook \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer s3cr3t-hook-token' \
-d '{"typeWebhook": "incomingMessageReceived", "instanceData": {"idInstance": 7100000001, "wid": "79990000001@c.us", "typeInstance": "v3", "channel": "max"}, "timestamp": 1791823658, "idMessage": "117428955282341903", "eventId": "msg:8695836:117428955282341903", "senderData": {"chatId": "8695836", "chatName": "Мария", "chatType": "user", "sender": "8695836", "senderName": "Мария", "senderType": "user", "senderContactName": "", "senderPhoneNumber": 0}, "messageData": {"typeMessage": "textMessage", "textMessageData": {"textMessage": "Здравствуйте!"}}}'
Вы увидите: HTTP/1.1 200 OK, а в выводе обработчика — строку 8695836 Мария textMessage. Повторите запрос — ответ снова 200, а строки в выводе не прибавится: повтор отсечён. С другим токеном — 401.
Шаг 3. Откройте туннель для разработки
Gratio доставляет только на публичный адрес: localhost и адреса внутренней сети не подходят (400 bad_url). Для разработки откройте туннель — сервис, который выдаёт публичный HTTPS-адрес и пересылает запросы на ваш компьютер. Подойдёт любой, например ngrok или cloudpub.
Направьте туннель на порт 8000 и возьмите выданный адрес. Адрес вебхука — он плюс путь обработчика: https://<адрес туннеля>/gratio/hook.
Вы увидите: тот же curl из шага 2, отправленный на адрес туннеля, отвечает 200.
Шаг 4. Задайте адрес вебхука
Передайте адрес и токен в setSettings:
curl
curl -X POST "$GRATIO_URL/waInstance$GRATIO_ID/setSettings/$GRATIO_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"webhookUrl": "https://example.ru/gratio/hook",
"webhookUrlToken": "s3cr3t-hook-token"
}'
Python
import os
import requests
BASE = f"{os.environ['GRATIO_URL']}/waInstance{os.environ['GRATIO_ID']}"
TOKEN = os.environ["GRATIO_TOKEN"]
url = f"{BASE}/setSettings/{TOKEN}"
payload = {
"webhookUrl": "https://example.ru/gratio/hook",
"webhookUrlToken": "s3cr3t-hook-token",
}
response = requests.post(url, json=payload, timeout=60)
print(response.status_code, response.json())
Node.js
const { GRATIO_URL, GRATIO_ID, GRATIO_TOKEN } = process.env;
const base = `${GRATIO_URL}/waInstance${GRATIO_ID}`;
const url = `${base}/setSettings/${GRATIO_TOKEN}`;
const payload = {
webhookUrl: "https://example.ru/gratio/hook",
webhookUrlToken: "s3cr3t-hook-token",
};
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
console.log(response.status, await response.json());
PHP
<?php
$base = getenv('GRATIO_URL') . '/waInstance' . getenv('GRATIO_ID');
$url = $base . '/setSettings/' . getenv('GRATIO_TOKEN');
$payload = [
'webhookUrl' => 'https://example.ru/gratio/hook',
'webhookUrlToken' => 's3cr3t-hook-token',
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$response = json_decode(curl_exec($ch), true);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), PHP_EOL;
print_r($response);
Ответ 200 OK:
{"saveSettings": true}
Настройка действует сразу. Уведомления, которые ждали в очереди, уйдут на вебхук по порядку. Адрес во внутренней сети — 400 bad_url; Gratio проверяет адрес и перед каждой доставкой.
Шаг 5. Проверьте доставку
Напишите на номер канала с другого телефона.
Вы увидите: в выводе обработчика — строку с chatId собеседника, его именем и textMessage. Очередь пуста:
curl "$GRATIO_URL/waInstance$GRATIO_ID/getWebhooksCount/$GRATIO_TOKEN"
Ответ {"count": 0}. Число растёт — Gratio не может доставить уведомление: проверьте ответы обработчика.
Что присылает Gratio
Запрос POST на webhookUrl, тело — уведомление целиком, как body в receiveNotification:
| Заголовок | Значение |
|---|---|
Content-Type |
application/json |
Authorization |
Bearer {webhookUrlToken} — если токен задан |
Подписи у вебхука нет — сверяйте токен. Нужна подпись каждой доставки — используйте subscriptions.
Ответ и повторы
| Ответ вашего сервера | Что сделает Gratio |
|---|---|
2xx за 30 секунд |
доставлено, следующее уведомление — сразу |
3xx |
неудача: перенаправления Gratio не выполняет — укажите конечный адрес |
4xx, 5xx |
неудача, повтор по расписанию |
| нет ответа за 30 секунд, сбой сети или сертификата | неудача, повтор по расписанию |
| адрес стал вести во внутреннюю сеть | неудача с причиной internal_host, повтор |
| неудачи час подряд или 20 попыток | уведомление уходит в отравленные, очередь идёт дальше |
Расписание повторов одного уведомления:
| Попытка | После первой |
|---|---|
| 1 | сразу |
| 2 | через 1 с |
| 3 | через 6 с |
| 4 | через 21 с |
| 5 | через 1 мин 21 с |
| 6 | через 6 мин 21 с |
| 7 и дальше | каждые 5 минут |
При таких паузах раньше наступает час, а не 20 попыток: около 17-й неудачной попытки уведомление уходит в отравленные. Пока уведомление в повторах, следующие ждут за ним — порядок не нарушается.
Повторы и дубли
Gratio повторяет уведомление, если не получил 2xx, — даже когда вы его обработали, но ответ потерялся. Храните ключи обработанных уведомлений:
eventId— есть у сообщений, правок, удалений, реакций, звонков, событий групп иsyncGap;- у
outgoingAPIMessageReceived,outgoingMessageStatusиstateInstanceChangedего нет — берите хеш тела или паруidMessage+status.
Подробно о ключах — Формат уведомления.
Если сервер был недоступен
- Уведомления ждут в очереди до 24 часов, затем истекают.
- Уведомление в голове очереди после часа неудач уходит в отравленные, следующее начинает свой час. За каждый час простоя в отравленные уходит одно уведомление.
- Сервер снова отвечает — посмотрите getDeadNotifications и верните их requeueDeadNotifications.
- Истёкшие уведомления не вернуть, но сообщения остались в истории чата — getChatHistory.
Вернуться к очереди
Пустой webhookUrl выключает вебхук. Недоставленные уведомления остаются в очереди — их отдаст receiveNotification:
curl -X POST "$GRATIO_URL/waInstance$GRATIO_ID/setSettings/$GRATIO_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"webhookUrl": ""}'
Что дальше
- subscriptions — вторая система на той же ленте, с подписью.
- Приёмник вебхуков на Node.js — полный приёмник с базой и очередью задач.
- Формат уведомления — что внутри тела.
