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

Приёмник событий на Node.js

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

Что получится: сервер на 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"));

Что дальше

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