Каждую доставку подписки Gratio подписывает секретом этой подписки. Проверка подписи доказывает, что запрос прислал Gratio и тело не меняли по дороге. Принимайте доставку, только если подпись сошлась и она свежая.
Заголовок
Подпись приходит в заголовке доставки:
X-Gratio-Signature: t=1791823660,v1=7b36aa47219531b4691a6391718232d8b54a9bd3c8f81bbba7d9345c037de163
| Часть | Что |
|---|---|
t |
время подписи, секунды Unix |
v1 |
HMAC-SHA256 в шестнадцатеричном виде, 64 знака в нижнем регистре |
Алгоритм
- Возьмите сырое тело запроса — байты как пришли, до разбора JSON.
- Разберите заголовок: разделите по
,, каждую часть — по первому=. Нужныtиv1. Нет одной из них илиtне число — отклоните. - Сравните
tс текущим временем. Разница больше 300 секунд в любую сторону — отклоните: это старый или повторно отправленный злоумышленником запрос. - Посчитайте HMAC-SHA256 с ключом — секретом подписки — от строки
<t>.<сырое тело>: значениеtкак в заголовке, точка, затем байты тела. - Сравните результат с
v1сравнением за постоянное время (hmac.compare_digest,crypto.timingSafeEqual,hash_equals). Совпало — примите, иначе ответьте401.
Отказ 401 подписка считает отказом: после 5 неудачных попыток уведомление уходит в отравленные. Поэтому проверьте функцию на тестовом примере ниже, прежде чем включать подписку.
Python
Функция проверки:
import hashlib
import hmac
import time
def verify_signature(secret: str, body: bytes, header: str, now: float | None = None, tolerance: int = 300) -> bool:
"""Проверить X-Gratio-Signature. body — сырые байты запроса, как пришли."""
parts = {}
for piece in header.split(","):
key, sep, value = piece.strip().partition("=")
if sep:
parts[key] = value
if not parts.get("t", "").isdigit() or not parts.get("v1"):
return False
t = int(parts["t"])
if abs((time.time() if now is None else now) - t) > tolerance:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, parts["v1"])
В обработчике FastAPI тело читайте байтами — await request.body():
import json
import os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["GRATIO_SUB_SECRET"]
@app.post("/hooks/gratio")
async def gratio_subscription(request: Request) -> dict:
body = await request.body() # сырые байты — до разбора JSON
if not verify_signature(SECRET, body, request.headers.get("x-gratio-signature", "")):
raise HTTPException(status_code=401)
event = json.loads(body)
print(request.headers.get("x-gratio-delivery"), event["typeWebhook"])
return {"ok": True}
Node.js
Функция проверки, rawBody — Buffer:
import crypto from "node:crypto";
export function verifySignature(secret, rawBody, header = "", now = Date.now() / 1000, tolerance = 300) {
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();
}
if (!/^\d+$/.test(parts.t || "") || !parts.v1) return false;
const t = Number(parts.t);
if (Math.abs(now - t) > tolerance) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
const a = Buffer.from(want);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
В Express подключите express.raw на этом маршруте — тогда req.body будет Buffer, а не разобранный объект:
import express from "express";
import { verifySignature } from "./signature.mjs";
const app = express();
app.post("/hooks/gratio", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
const header = req.get("x-gratio-signature") || "";
if (!verifySignature(process.env.GRATIO_SUB_SECRET, req.body, header)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
res.json({ ok: true });
console.log(req.get("x-gratio-delivery"), event.typeWebhook);
});
app.listen(8000);
Не ставьте express.json() глобально перед этим маршрутом: он разберёт тело раньше, и сырых байтов не останется.
PHP
Функция проверки и обработчик — тело читайте из php://input:
<?php
function gratio_verify_signature(string $secret, string $body, string $header, ?int $now = null, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $piece) {
$pair = explode('=', trim($piece), 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
$t = (int) $parts['t'];
if (abs(($now ?? time()) - $t) > $tolerance) {
return false;
}
$want = hash_hmac('sha256', $t . '.' . $body, $secret);
return hash_equals($want, $parts['v1']);
}
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_GRATIO_SIGNATURE'] ?? '';
if (!gratio_verify_signature(getenv('GRATIO_SUB_SECRET'), $body, $header)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
header('Content-Type: application/json');
echo '{"ok":true}';
Тестовый пример
Проверьте свою функцию на этих данных. Секрет:
5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f
Тело — ровно эти 192 байта, одной строкой, без пробелов и перевода строки в конце:
{"typeWebhook":"stateInstanceChanged","instanceData":{"idInstance":7100000001,"wid":"79990000001@c.us","typeInstance":"v3","channel":"max"},"timestamp":1791823658,"stateInstance":"authorized"}
Заголовок, который должен пройти проверку при now = 1791823660:
t=1791823660,v1=7b36aa47219531b4691a6391718232d8b54a9bd3c8f81bbba7d9345c037de163
Проверка на Python:
SECRET = "5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f"
BODY = b'{"typeWebhook":"stateInstanceChanged","instanceData":{"idInstance":7100000001,"wid":"79990000001@c.us","typeInstance":"v3","channel":"max"},"timestamp":1791823658,"stateInstance":"authorized"}'
HEADER = "t=1791823660,v1=7b36aa47219531b4691a6391718232d8b54a9bd3c8f81bbba7d9345c037de163"
assert verify_signature(SECRET, BODY, HEADER, now=1791823660)
assert not verify_signature(SECRET, BODY + b" ", HEADER, now=1791823660) # тело изменили
assert not verify_signature(SECRET, BODY, HEADER, now=1791824000) # подпись старше 5 минут
То же в командной строке — openssl должен вывести то же значение v1:
BODY='{"typeWebhook":"stateInstanceChanged","instanceData":{"idInstance":7100000001,"wid":"79990000001@c.us","typeInstance":"v3","channel":"max"},"timestamp":1791823658,"stateInstance":"authorized"}'
printf '%s' "1791823660.$BODY" | openssl dgst -sha256 -hmac "5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f"
Частые ошибки
| Признак | Причина |
|---|---|
| подпись не сходится ни у одной доставки | тело разобрали и собрали обратно: порядок ключей, пробелы и экранирование кириллицы поменялись. Считайте подпись по сырым байтам |
| не сходится только у сообщений на русском | тело перекодировали; Gratio шлёт UTF-8 без \u-экранирования |
| перестала сходиться после смены адреса | повторный POST subscriptions без secret выдал новый секрет — возьмите его из ответа |
| отказ по времени | часы сервера отстают или спешат больше чем на 5 минут — включите синхронизацию времени |
в Node.js TypeError при подсчёте HMAC |
req.body уже объект: перед маршрутом сработал express.json(). Подключите express.raw |
Что дальше
- subscriptions — создать подписку и получить секрет.
- Вторая система на той же ленте — подписка для CRM по шагам.
- Формат уведомления — что внутри тела.
