Вебхуки и проверка подписи
Вебхук — вызов от CRM к сайту, когда изменились объекты, цены или занятость. По нему сайт сбрасывает кэш, и ему не нужно часто опрашивать API.
Настройка
Настройки → Аренда → Сайт:
- Укажите адрес вебхука на сайте, например
https://site.example.com/api/type-webhook. Принимается толькоhttps://с публичным адресом. - Нажмите «Новый секрет» — CRM покажет секрет подписи один раз. Сохраните его в переменной окружения
TYPE_WEBHOOK_SECRETна сервере сайта. - Нажмите «Проверить» — CRM отправит тестовый вызов
rental.site_ping.
Запрос
CRM отправляет POST на адрес вебхука с телом JSON:
{"event":"rental.site_changed","changes":["occupancy","prices"],"unit_ids":["11111111-2222-4333-8444-555555555555"],"at":"2026-12-23T10:00:00Z"}| Поле | Что значит |
|---|---|
event | rental.site_changed — данные изменились; rental.site_ping — проверка из CRM |
changes | что сбросить: units (тексты, фото, удобства, архив), prices (цены, особые даты, скидки), occupancy (брони и блокировки) |
unit_ids | затронутые объекты; null — все |
at | когда произошло изменение |
- Изменения за 10 секунд CRM собирает в один вызов.
- Ответьте любым
2xx. Если ответ другой или сайт не ответил, CRM повторит вызов через 1, 5 и 30 минут. - На
rental.site_pingпросто ответьте2xx, кэш не трогайте.
Подпись
Заголовки запроса:
| Заголовок | Значение |
|---|---|
X-Type-Timestamp | время отправки, секунды Unix |
X-Type-Signature | hex HMAC-SHA256 по секрету вебхука от строки "{timestamp}.{body}" |
body — сырые байты тела запроса, а не разобранный и снова собранный JSON: любой лишний пробел меняет подпись.
Как проверять:
- Прочитайте сырое тело.
- Отклоните вызов, если
X-Type-Timestampотличается от текущего времени больше чем на 300 секунд, — так повтор перехваченного запроса не сработает. - Посчитайте подпись и сравните с
X-Type-Signatureза постоянное время (timingSafeEqual,hash_equals). Не совпала — ответьте401и ничего не сбрасывайте.
Подпись вебхука — защита сайта от поддельных вызовов. Это не ключ доступа к API: ключ API в вебхуке не передаётся.
С пакетом
// app/api/type-webhook/route.ts
import { revalidateTag } from 'next/cache';
import { verifyWebhook } from '@type/site-client';
export async function POST(req: Request) {
const body = await req.text(); // сырое тело
const res = await verifyWebhook(body, req.headers, process.env.TYPE_WEBHOOK_SECRET!);
if (!res.ok) return Response.json({ error: res.reason }, { status: 401 });
if (res.payload.event === 'rental.site_changed') {
for (const change of res.payload.changes) revalidateTag(`rental:${change}`); // units | prices | occupancy
}
return Response.json({ ok: true });
}verifyWebhook возвращает { ok: true, payload } или { ok: false, reason }, где reason — missing (нет заголовков), timestamp (вне окна 300 с), signature или payload (тело не JSON вебхука).
Если кэшируете по объектам, ставьте теги вида rental:occupancy:<unit_id> и сбрасывайте только затронутые; при unit_ids: null — все.
Без пакета (Node.js)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(body: string, ts: string | null, sig: string | null, secret: string): boolean {
if (!ts || !sig || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${ts}.${body}`).digest();
const got = Buffer.from(sig.toLowerCase(), 'hex');
return got.length === expected.length && timingSafeEqual(got, expected);
}Без пакета (PHP)
function type_webhook_ok(string $body, string $ts, string $sig, string $secret): bool {
if ($ts === '' || abs(time() - (int) $ts) > 300) {
return false;
}
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
return hash_equals($expected, strtolower($sig));
}Пример для проверки
Все значения выдуманы — используйте их в тесте своей проверки (без окна 300 с):
- секрет:
whsec_example_0123456789abcdef0123456789abcdef; X-Type-Timestamp:1798000000;- тело — JSON из раздела «Запрос» выше, байт в байт.
Ожидаемая подпись X-Type-Signature:
99f5257e8baf058067be3cbc81276a70060c67ada36dec102cfcbe116bb71ece