Перейти к содержимому

Шаг 4. События на свой сервер

Обновлено 18 октября 2026

В этом шаге Gratio начнёт присылать уведомления на ваш сервер сам — подпиской. Каждую доставку Gratio подписывает: обработчик проверяет подпись и принимает только то, что прислал Gratio. Опрашивать очередь не нужно.

Что понадобится

  • переменные GRATIO_URL, GRATIO_ID, GRATIO_TOKEN из Шаг 2. Первый запрос;
  • Python 3.10+ (pip install fastapi uvicorn) или Node.js 18+ (npm install express);
  • публичный HTTPS-адрес, который ведёт на ваш обработчик. На своём компьютере подойдёт любой сервис туннелей, который выдаёт публичный HTTPS-адрес для локального порта.

Как работает подписка

Gratio                                       Ваш сервер
   │ POST /gratio/events                        │
   │ X-Gratio-Signature: t=…,v1=…               │
   │ {уведомление} ────────────────────────────►│ проверить подпись
   │                                            │ ответить сразу
   │◄──────────────────────────────── 200 OK ───│
   │                                            │ обработать в фоне
   │ следующее уведомление ────────────────────►│
   │                                            │
   │  нет ответа или 5xx:                       │
   │  повтор через 1, 5, 15, 60, 300 с …        │

1. Придумайте секрет

Секрет подписи — случайная строка от 16 до 128 символов. Обработчик возьмёт его из переменной GRATIO_SUB_SECRET, а Gratio — из подписки.

macOS, Linux, Git Bash:

export GRATIO_SUB_SECRET=$(openssl rand -hex 32)
echo $GRATIO_SUB_SECRET

Windows PowerShell:

$env:GRATIO_SUB_SECRET = [guid]::NewGuid().ToString("N") + [guid]::NewGuid().ToString("N")
$env:GRATIO_SUB_SECRET

2. Запустите обработчик

Обработчик читает тело байтами, проверяет подпись, сразу отвечает 200 и обрабатывает уведомление после ответа. Как устроена подпись — в статье Проверка подписи.

Python

Файл events.py, запуск — uvicorn events:app --port 8080:

import hashlib
import hmac
import json
import os
import time

from fastapi import BackgroundTasks, FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["GRATIO_SUB_SECRET"].encode()


def signed(body: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t = parts.get("t", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    want = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts.get("v1", ""))


def process(event: dict) -> None:
    print(event["typeWebhook"], event.get("senderData", {}).get("chatId"), event.get("messageData"))


@app.post("/gratio/events")
async def events(request: Request, tasks: BackgroundTasks) -> dict:
    body = await request.body()  # сырые байты — до разбора JSON
    if not signed(body, request.headers.get("x-gratio-signature", "")):
        raise HTTPException(status_code=401)
    tasks.add_task(process, json.loads(body))
    return {"ok": True}

Node.js

Файл events.mjs, запуск — node events.mjs:

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.GRATIO_SUB_SECRET;

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 want = Buffer.from(crypto.createHmac("sha256", SECRET).update(`${t}.`).update(body).digest("hex"));
  const got = Buffer.from(parts.v1 || "");
  return want.length === got.length && crypto.timingSafeEqual(want, got);
}

const app = express();

// express.raw — тело остаётся байтами: подпись считают по ним
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 event = JSON.parse(req.body.toString("utf8"));
  console.log(event.typeWebhook, event.senderData?.chatId, event.messageData);
});

app.listen(8080);

Вы увидите: сервер слушает порт 8080.

3. Откройте публичный адрес

Направьте туннель на порт 8080 или разместите обработчик на сервере с HTTPS. Адрес обработчика — например, https://example.ru/gratio/events.

Адрес должен быть публичным: внутренние и локальные адреса Gratio не принимает (400 bad_url).

4. Подпишите адрес на канал

Метод subscriptions: имя подписки, адрес и секрет. Замените адрес на свой, а секрет — на значение 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:

{
  "id": "sub_9f2c41d07be35a1c86e4f0b2",
  "name": "app",
  "url": "https://example.ru/gratio/events",
  "createdAt": 1791823700,
  "pending": 0,
  "oldestPendingAt": null,
  "dead": 0,
  "lastDeliveredAt": null,
  "lastError": null,
  "lastErrorAt": null,
  "system": false,
  "created": true
}

5. Проверьте

Напишите на подключённый номер с телефона.

Вы увидите в консоли обработчика:

incomingMessageReceived 8695836 {'typeMessage': 'textMessage', 'textMessageData': {'textMessage': 'Здравствуйте!'}}

Как идёт доставка, показывает список подписок — pending, dead, lastDeliveredAt и lastError:

curl "$GRATIO_URL/v1/instances/$GRATIO_ID/subscriptions" \
  -H "Authorization: Bearer $GRATIO_TOKEN"

Если не получилось

Что видно Причина Что сделать
в консоли ничего, в списке растёт pending и есть lastError адрес не открывается или обработчик падает проверьте туннель и адрес; lastError назовёт причину
обработчик отвечает 401 секрет в подписке и в GRATIO_SUB_SECRET разный повторите шаг 4 с секретом из переменной. Уведомление, отвергнутое 5 раз, уходит в отравленные — dead
401 и секрет верный часы сервера отстают или спешат больше чем на 5 минут включите синхронизацию времени
400 bad_url адрес ведёт во внутреннюю сеть дайте публичный адрес

Правила для обработчика

  • Отвечайте сразу, обработку делайте в фоне: Gratio ждёт ответа 30 секунд, а следующее уведомление шлёт после ответа на текущее.
  • Проверяйте подпись по сырому телу — до разбора JSON.
  • Отсекайте повторы по заголовку X-Gratio-Delivery или по eventId: если ответ потерялся, уведомление придёт снова.
  • Временный сбой — отвечайте 5xx: Gratio повторит. Ответ 4xx — отказ: после 5 отказов уведомление уходит в отравленные (dead в списке подписок), очередь идёт дальше.
  • Незнакомое уведомление — тоже 200.

Подписка — основной способ получать уведомления на сервер: их у канала до пяти, у каждой своя очередь доставки, так что боту, CRM и аналитике хватит своей подписки на каждого. Для прототипа есть вариант проще — Вебхук: один адрес с токеном вместо подписи. Подробно о доставке — в статье subscriptions.

Что дальше

Канал подключён, сообщения уходят и приходят. Выберите сценарий:

Чат-бот на PythonБот, который отвечает клиентам в MAX, Telegram или WhatsApp по меню, присылает файл и зовёт оператора, — на очереди уведомлений, без публичного сервера. Переписка в своей CRMВстроить чаты MAX, Telegram и WhatsApp в свою CRM — входящие в карточку, ответ оператора, статусы, сообщения с телефона, правки, история и разрыв истории. Напоминания и рассылки без блокировокСкрипт напоминаний клиентам — выбор канала по номеру, рабочие часы, темп, правила WhatsApp, отложенные повторы и защита от дублей.

Или изучите основы: Как проходят сообщения и уведомления, Темп и лимиты, Ответы и ошибки.

Была ли статья полезна?