ImportOS · Документація

Вебхуки

Події тенанта на ваш 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-Signaturet=<unix-секунди>,v1=<hex>: підпис
X-Event-Idунікальний ідентифікатор події (збігається з id у тілі)
User-AgentRVAuto-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_changeddealId, number, from, to, kind
payment.receivedpaymentId, dealId, amount, currency, purpose, paidAt, provider
document.signeddocumentId, dealId, kind, number, signMethod, signedAt
lead.createdcontactId, requestId, source
shipment.updatedshipmentId, dealId, status, containerNo, vessel, portTo, eta, ata

Значення стадій (from, to) — коди зі стадій угоди. Деякі поля можуть бути відсутні, якщо подія їх не містила.

Підпис

Заголовок X-Signature має вигляд t=1760000000,v1=<64 hex>. Підпис:

v1 = HMAC_SHA256( secret, "<t>.<сире тіло запиту>" )  // у hex

Секрет виглядає як whsec_..., його показано один раз під час створення або ротації. Під час ротації в заголовку може бути кілька значень v1= (старий і новий секрет): достатньо, щоб збігалося будь-яке.

Правила перевірки:

  1. Підписуйте сире тіло (рядок байтів, як прийшло), а не перероблений JSON.
  2. Порівнюйте через сталочасове порівняння (timingSafeEqual).
  3. Перевірте, що |now − t| ≤ 300 секунд: так відсікаються повторно відіслані перехоплені запити.
  4. Запам'ятовуйте оброблені 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.

На цій сторінці