Вебхуки
Події тенанта на ваш HTTPS-адрес: формат тіла, підпис X-Signature (t, v1), перевірка в коді, повтори та ідемпотентність.
Вебхуки надсилають ваші системи про події в ImportOS: зміну стадії угоди, отримання платежу, підписання документа, новий лід, оновлення відправлення.
У розробці: реєстрація вебхука через API чи Desk
Доставка, підпис, повтори й журнал доставок реалізовані в ядрі (@core/services: createWebhook,
rotateWebhookSecret, listDeliveries, redeliverWebhook та інші). Але ендпоінтів /v1/webhooks у API
і екрана в Desk ще немає: їх немає й в OpenAPI. Поки вебхук створює оператор платформи, а секрет
передається вам один раз. Формат, підпис і поведінка нижче вже остаточні.
Події
Подія (type) | Коли |
|---|---|
deal.stage_changed | угода перейшла в іншу стадію |
payment.received | отримано платіж |
document.signed | документ підписано |
lead.created | створено нового ліда |
shipment.updated | оновився статус відправлення |
Підписка робиться на перелік типів. Дані подій публічні: без маржі, цін лота та персональних даних клієнтів.
Запит
POST на ваш URL із Content-Type: application/json. Адреса має бути HTTPS і не вказувати на приватні мережі; редіректи не виконуються. Таймаут відповіді — 8 секунд.
Заголовки:
| Заголовок | Значення |
|---|---|
X-Signature | t=<unix-секунди>,v1=<hex>: підпис |
X-Event-Id | унікальний ідентифікатор події (збігається з id у тілі) |
User-Agent | RVAuto-Notify/1.0 |
Тіло:
{
"id": "0b1c2d3e-...",
"type": "deal.stage_changed",
"tenantId": "...",
"createdAt": "2026-10-18T09:30:00.000Z",
"recipient": {},
"message": { "title": "deal.stage_changed", "text": "deal.stage_changed", "buttons": [] },
"data": {
"entity": "deal",
"entityId": "...",
"occurredAt": "2026-10-18T09:29:58.000Z",
"dealId": "...",
"number": "<номер угоди>",
"from": "won",
"to": "paid_to_auction"
}
}Поля recipient і message службові: їх можна ігнорувати. Корисне все в data. Крім спільних entity, entityId, occurredAt, для кожної події є свої поля:
| Подія | Поля data |
|---|---|
deal.stage_changed | dealId, number, from, to, kind |
payment.received | paymentId, dealId, amount, currency, purpose, paidAt, provider |
document.signed | documentId, dealId, kind, number, signMethod, signedAt |
lead.created | contactId, requestId, source |
shipment.updated | shipmentId, dealId, status, containerNo, vessel, portTo, eta, ata |
Значення стадій (from, to) — коди зі стадій угоди. Деякі поля можуть бути відсутні, якщо подія їх не містила.
Підпис
Заголовок X-Signature має вигляд t=1760000000,v1=<64 hex>. Підпис:
v1 = HMAC_SHA256( secret, "<t>.<сире тіло запиту>" ) // у hexСекрет виглядає як whsec_..., його показано один раз під час створення або ротації. Під час ротації в заголовку може бути кілька значень v1= (старий і новий секрет): достатньо, щоб збігалося будь-яке.
Правила перевірки:
- Підписуйте сире тіло (рядок байтів, як прийшло), а не перероблений JSON.
- Порівнюйте через сталочасове порівняння (
timingSafeEqual). - Перевірте, що
|now − t| ≤ 300секунд: так відсікаються повторно відіслані перехоплені запити. - Запам'ятовуйте оброблені
X-Event-Idі не обробляйте подію двічі: доставка «щонайменше один раз».
Приклад перевірки (Node.js, Express)
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRETS = [process.env.IMPORTOS_WEBHOOK_SECRET!]; // під час ротації додайте попередній
const TOLERANCE_SEC = 300;
function verify(header: string | undefined, rawBody: string): boolean {
if (!header) return false;
const parts = header.split(',').map((p) => p.trim());
const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2));
const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!Number.isInteger(t) || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SEC) return false;
for (const secret of SECRETS) {
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
for (const s of signatures) {
const got = Buffer.from(s, 'hex');
if (got.length === expected.length && timingSafeEqual(got, expected)) return true;
}
}
return false;
}
const seen = new Set<string>(); // у продакшені: Redis або таблиця з TTL
const app = express();
app.post('/importos/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(req.header('x-signature'), raw)) return res.status(401).end();
const event = JSON.parse(raw);
if (seen.has(event.id)) return res.status(200).end(); // дубль: уже оброблено
seen.add(event.id);
// ... обробка event.type / event.data
res.status(200).end();
});Python
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])(Спрощений приклад: під час ротації секрету в заголовку кілька v1=, тож розбирайте їх списком, як у прикладі на Node.js.)
Відповідь і повтори
- Відповідайте кодом 2xx якомога швидше (обробку виносьте в чергу).
- Помилка 4xx (крім 408 і 429) вважається остаточною: доставка одразу переходить у стан
deadбез повторів. Це «ви відхилили запит», а не збій. - Таймаут, 5xx, 408, 429 і мережеві помилки повторюються з експоненційною затримкою: 1, 2, 4, … хвилин (не більше години між спробами), доки не буде вичерпано ліміт спроб (за замовчуванням 6). Після цього доставка стає
dead. - Усі спроби пишуться в журнал доставок; ручний повтор доступний власнику компанії.
- Не слідуйте за редіректами й не покладайтесь на порядок подій: використовуйте
occurredAt.