Что получится: сервер на Node.js, который принимает уведомления канала подпиской. Он проверяет подпись, отвечает Gratio сразу, отсекает повторы, раскладывает уведомления по видам — сообщения, статусы, состояние канала, звонки — и отвечает на входящие.
Gratio ──POST /gratio/events──► проверить подпись ──► 200 сразу
│
▼
повтор? (X-Gratio-Delivery) ──да──► пропустить
│нет
▼
сообщение │ статус │ состояние │ звонок │ прочее
Что понадобится
- канал с ключом — Шаг 1. Подключите номер;
- переменные
GRATIO_URL,GRATIO_ID,GRATIO_TOKEN; - Node.js 18+;
- публичный HTTPS-адрес: сервер с доменом или туннель к локальному порту.
Шаг 1. Проект
mkdir gratio-events && cd gratio-events
npm init -y && npm pkg set type=module
npm install express
Придумайте секрет подписи — случайную строку от 16 до 128 символов:
export GRATIO_SUB_SECRET=$(openssl rand -hex 32)
Шаг 2. Сервер
Файл server.js. Обработчик проверяет подпись по сырому телу и отвечает 200 до обработки: Gratio ждёт ответа 30 секунд, а следующее уведомление шлёт только после ответа на текущее.
import crypto from "node:crypto";
import express from "express";
const { GRATIO_URL, GRATIO_ID, GRATIO_TOKEN, GRATIO_SUB_SECRET } = process.env;
const base = `${GRATIO_URL}/v1/instances/${GRATIO_ID}`;
const auth = { Authorization: `Bearer ${GRATIO_TOKEN}` };
const seen = new Map(); // номер доставки → время; в работе — Redis или таблица
function signed(body, header = "") {
const parts = {};
for (const piece of header.split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = parts.t || "";
if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const hmac = crypto.createHmac("sha256", GRATIO_SUB_SECRET).update(`${t}.`).update(body);
const want = Buffer.from(hmac.digest("hex"));
const got = Buffer.from(parts.v1 || "");
return want.length === got.length && crypto.timingSafeEqual(want, got);
}
const app = express();
app.post("/gratio/events", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
if (!Buffer.isBuffer(req.body) || !signed(req.body, req.get("x-gratio-signature"))) {
return res.sendStatus(401);
}
res.sendStatus(200);
const delivery = req.get("x-gratio-delivery");
if (seen.has(delivery)) return; // повтор после потерянного ответа
seen.set(delivery, Date.now());
route(JSON.parse(req.body.toString("utf8"))).catch((e) => console.error("обработка:", e));
});
app.listen(8080, () => console.log("Слушаю :8080"));
express.raw оставляет тело байтами — подпись считают по ним. Не ставьте express.json() перед этим маршрутом: он разберёт тело раньше, и подпись не сойдётся.
Шаг 3. Разбор уведомлений
Допишите в server.js маршрутизацию по typeWebhook и ответ на текст:
async function route(body) {
switch (body.typeWebhook) {
case "incomingMessageReceived":
return onIncoming(body);
case "outgoingMessageStatus":
return console.log(`статус ${body.idMessage}: ${body.status}`);
case "stateInstanceChanged":
return console.log(`канал: ${body.stateInstance}`);
case "incomingCall":
return console.log(`звонок от ${body.chatId}: ${body.status}`);
default:
return; // остальное пока не нужно
}
}
async function onIncoming(body) {
const { chatId, chatType, senderName } = body.senderData;
const data = body.messageData;
if (chatType !== "user" || data.typeMessage !== "textMessage") return;
const text = data.textMessageData.textMessage;
console.log(`${senderName}: ${text}`);
await send("sendMessage", {
chatId,
message: `Спасибо, ${senderName}! Ответим в течение 10 минут.`,
clientMessageId: `ack-${body.idMessage}`,
});
}
async function send(method, payload) {
const r = await fetch(`${base}/${method}`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(`${method}: ${r.status} ${await r.text()}`);
return r.json();
}
Запустите:
node server.js
Вы увидите: Слушаю :8080.
Шаг 4. Подпишите адрес на канал
Откройте публичный адрес на порт 8080 и создайте подписку с тем же секретом. Замените адрес на свой, а секрет — на значение GRATIO_SUB_SECRET:
curl
curl -X POST "$GRATIO_URL/v1/instances/$GRATIO_ID/subscriptions" \
-H "Authorization: Bearer $GRATIO_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "app",
"url": "https://example.ru/gratio/events",
"secret": "5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f"
}'
Python
import os
import requests
BASE = f"{os.environ['GRATIO_URL']}/v1/instances/{os.environ['GRATIO_ID']}"
HEADERS = {"Authorization": f"Bearer {os.environ['GRATIO_TOKEN']}"}
url = f"{BASE}/subscriptions"
payload = {
"name": "app",
"url": "https://example.ru/gratio/events",
"secret": "5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f",
}
response = requests.post(url, json=payload, headers=HEADERS, timeout=60)
print(response.status_code, response.json())
Node.js
const { GRATIO_URL, GRATIO_ID, GRATIO_TOKEN } = process.env;
const base = `${GRATIO_URL}/v1/instances/${GRATIO_ID}`;
const auth = { Authorization: `Bearer ${GRATIO_TOKEN}` };
const url = `${base}/subscriptions`;
const payload = {
name: "app",
url: "https://example.ru/gratio/events",
secret: "5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f",
};
const response = await fetch(url, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
console.log(response.status, await response.json());
PHP
<?php
$base = getenv('GRATIO_URL') . '/v1/instances/' . getenv('GRATIO_ID');
$auth = 'Authorization: Bearer ' . getenv('GRATIO_TOKEN');
$url = $base . '/subscriptions';
$payload = [
'name' => 'app',
'url' => 'https://example.ru/gratio/events',
'secret' => '5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f',
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [$auth, '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);
Вы увидите подписку с её id и "created": true. Подписка получает уведомления, записанные после её создания.
Шаг 5. Проверьте
Напишите на номер канала с телефона.
Вы увидите в консоли:
Мария: Здравствуйте!
статус 117428956522361912: sent
статус 117428956522361912: read
…а на телефоне — ответ «Спасибо, Мария!».
Если сервер был недоступен
Пока сервер не отвечает или отвечает 5xx, Gratio повторяет доставку — по одному уведомлению и по порядку, пока уведомлению нет суток. Поднимите сервер — очередь подписки дойдёт сама. Как идёт доставка, видно в списке подписок: pending, oldestPendingAt, lastError.
curl "$GRATIO_URL/v1/instances/$GRATIO_ID/subscriptions" \
-H "Authorization: Bearer $GRATIO_TOKEN"
Ответ 4xx, кроме 408 и 429, Gratio считает отказом: после 5 отказов уведомление уходит в отравленные — их число в dead, вернуть их нельзя. Поэтому на сбой у себя отвечайте 5xx, а не 4xx.
Сервер лежал дольше суток — недоставленные уведомления истекли, но сообщения остались в истории чата: getChatHistory.
Полный код
import crypto from "node:crypto";
import express from "express";
const { GRATIO_URL, GRATIO_ID, GRATIO_TOKEN, GRATIO_SUB_SECRET } = process.env;
const base = `${GRATIO_URL}/v1/instances/${GRATIO_ID}`;
const auth = { Authorization: `Bearer ${GRATIO_TOKEN}` };
const seen = new Map();
function signed(body, header = "") {
const parts = {};
for (const piece of header.split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = parts.t || "";
if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const hmac = crypto.createHmac("sha256", GRATIO_SUB_SECRET).update(`${t}.`).update(body);
const want = Buffer.from(hmac.digest("hex"));
const got = Buffer.from(parts.v1 || "");
return want.length === got.length && crypto.timingSafeEqual(want, got);
}
async function send(method, payload) {
const r = await fetch(`${base}/${method}`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(`${method}: ${r.status} ${await r.text()}`);
return r.json();
}
async function onIncoming(body) {
const { chatId, chatType, senderName } = body.senderData;
const data = body.messageData;
if (chatType !== "user" || data.typeMessage !== "textMessage") return;
console.log(`${senderName}: ${data.textMessageData.textMessage}`);
await send("sendMessage", {
chatId,
message: `Спасибо, ${senderName}! Ответим в течение 10 минут.`,
clientMessageId: `ack-${body.idMessage}`,
});
}
async function route(body) {
switch (body.typeWebhook) {
case "incomingMessageReceived":
return onIncoming(body);
case "outgoingMessageStatus":
return console.log(`статус ${body.idMessage}: ${body.status}`);
case "stateInstanceChanged":
return console.log(`канал: ${body.stateInstance}`);
case "incomingCall":
return console.log(`звонок от ${body.chatId}: ${body.status}`);
default:
return;
}
}
const app = express();
app.post("/gratio/events", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
if (!Buffer.isBuffer(req.body) || !signed(req.body, req.get("x-gratio-signature"))) {
return res.sendStatus(401);
}
res.sendStatus(200);
const delivery = req.get("x-gratio-delivery");
if (seen.has(delivery)) return;
seen.set(delivery, Date.now());
route(JSON.parse(req.body.toString("utf8"))).catch((e) => console.error("обработка:", e));
});
// чистим память о повторах старше суток
setInterval(() => {
const day = Date.now() - 86_400_000;
for (const [k, t] of seen) if (t < day) seen.delete(k);
}, 3_600_000);
app.listen(8080, () => console.log("Слушаю :8080"));
Что дальше
- Все виды уведомлений и их поля — Формат уведомления.
- Отдать ту же ленту ещё одной системе — Вторая система на той же ленте.
- Заголовки доставки, повторы и коды ответа — subscriptions.
- Нужен ровно один адрес без подписи — Вебхук.
