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

Приёмник вебхуков на Node.js

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

Что получится: сервер на Node.js, который принимает уведомления канала вебхуком. Он проверяет секрет, отвечает Gratio сразу, отсекает повторы, раскладывает уведомления по видам — сообщения, статусы, состояние канала, звонки — и отвечает на входящие.

Gratio ──POST /gratio/hook──► проверить секрет ──► 200 сразу
                                     │
                                     ▼
                          повтор? (eventId) ──да──► пропустить
                                     │нет
                                     ▼
            сообщение │ статус │ состояние │ звонок │ прочее

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

  • канал с ключом — Шаг 1. Подключите номер;
  • переменные GRATIO_URL, GRATIO_ID, GRATIO_TOKEN;
  • Node.js 18+;
  • публичный HTTPS-адрес: сервер с доменом или туннель к локальному порту.

Шаг 1. Проект

mkdir gratio-hook && cd gratio-hook
npm init -y && npm pkg set type=module
npm install express

Придумайте секрет вебхука — длинную случайную строку:

export GRATIO_HOOK_TOKEN=$(openssl rand -hex 24)

Шаг 2. Сервер

Файл server.js. Обработчик отвечает 200 до обработки: Gratio ждёт ответа 30 секунд, а следующее уведомление уходит только после ответа на текущее.

import express from "express";

const { GRATIO_URL, GRATIO_ID, GRATIO_TOKEN, GRATIO_HOOK_TOKEN } = process.env;
const base = `${GRATIO_URL}/waInstance${GRATIO_ID}`;
const seen = new Map();                       // eventId → время; в работе — Redis или таблица

const app = express();
app.use(express.json({ limit: "1mb" }));

app.post("/gratio/hook", (req, res) => {
  if (req.get("authorization") !== `Bearer ${GRATIO_HOOK_TOKEN}`) return res.sendStatus(401);
  res.sendStatus(200);
  const body = req.body;
  const key = body.eventId ?? `${body.typeWebhook}:${body.idMessage}:${body.status ?? ""}`;
  if (seen.has(key)) return;                  // повтор после потерянного ответа
  seen.set(key, Date.now());
  route(body).catch((e) => console.error("обработка:", e));
});

app.listen(8080, () => console.log("Слушаю :8080"));

Шаг 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}/${GRATIO_TOKEN}`, {
    method: "POST",
    headers: { "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 и укажите его в настройках канала вместе с секретом. Замените адрес и секрет на свои:

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);

Вы увидите: {"saveSettings": true}.

Шаг 5. Проверьте

Напишите на номер канала с телефона.

Вы увидите в консоли:

Мария: Здравствуйте!
статус 117428956522361912: sent
статус 117428956522361912: read

…а на телефоне — ответ «Спасибо, Мария!».

Если обработчик падал

Уведомление, которое ваш сервер не принял 20 раз подряд или час, Gratio откладывает в отравленные, чтобы не держать очередь. После починки верните их:

curl "$GRATIO_URL/waInstance$GRATIO_ID/getDeadNotifications/$GRATIO_TOKEN"
curl -X POST "$GRATIO_URL/waInstance$GRATIO_ID/requeueDeadNotifications/$GRATIO_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{}'

Подробно — getDeadNotifications.

Полный код

import express from "express";

const { GRATIO_URL, GRATIO_ID, GRATIO_TOKEN, GRATIO_HOOK_TOKEN } = process.env;
const base = `${GRATIO_URL}/waInstance${GRATIO_ID}`;
const seen = new Map();

async function send(method, payload) {
  const r = await fetch(`${base}/${method}/${GRATIO_TOKEN}`, {
    method: "POST",
    headers: { "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.use(express.json({ limit: "1mb" }));

app.post("/gratio/hook", (req, res) => {
  if (req.get("authorization") !== `Bearer ${GRATIO_HOOK_TOKEN}`) return res.sendStatus(401);
  res.sendStatus(200);
  const body = req.body;
  const key = body.eventId ?? `${body.typeWebhook}:${body.idMessage}:${body.status ?? ""}`;
  if (seen.has(key)) return;
  seen.set(key, Date.now());
  route(body).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"));

Что дальше

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