Основы

Вебхуки

Вебхук - запрос POST, которым Dialogi сообщает вашему серверу о событии: пришло сообщение, закрыли диалог, оставили заявку на звонок, клиент записался на услугу. Опрашивать API по расписанию не нужно - изменения приходят сами.

Как это работает:

  1. Вы добавляете адрес - https://… на своём сервере - и выбираете события и проекты.
  2. Dialogi выдаёт секрет whsec_…. Им подписан каждый запрос на этот адрес.
  3. Когда происходит событие, Dialogi отправляет на адрес JSON с событием.
  4. Ваш сервер проверяет подпись и в течение 10 секунд отвечает кодом 2xx. Если ответа нет или код другой, запрос повторяется.

Какие события бывают и что в каждом из них - на странице События вебхуков.

Вебхуки работают, пока у компании подключено дополнение «Публичный API». Без него новые события не создаются, а ожидающие повторы отменяются. Адреса при этом сохраняются.

Настройка в личном кабинете

«Настройки» → «Разработчикам» → «Вебхуки» → «Добавить адрес». Вкладку видят те же сотрудники, что и ключи API: по умолчанию владелец и администратор компании.

  • URL - адрес на вашем сервере. Требования к нему - в разделе Требования к адресу.
  • Описание - для себя: «CRM», «1С - заявки».
  • События - «Все» или «Выбрать» по группам: Диалоги, Клиенты, Заявки, Записи. «Все» включает и новые типы событий.
  • Проекты - «Все» или «Выбрать».

После сохранения секрет показывают один раз. Скопируйте его и сохраните на сервере рядом с кодом проверки подписи.

В карточке адреса:

  • журнал доставок: время, тип события, код ответа, длительность, номер попытки и время следующей;
  • по клику на доставку - тело события, заголовки запроса, начало ответа вашего сервера (до 4 КБ) и ошибка. Кнопка «Переотправить» делает внеочередную попытку - см. Переотправка. Журнал хранится 30 дней;
  • меню: изменить, отправить тестовое событие, перевыпустить секрет, выключить или включить, удалить.

У компании может быть до 10 адресов.

Настройка через API

Для этого нужен ключ с правом webhooks:manage - см. Права.

Метод и путь Что делает
GET /v1/webhook_endpoints Список адресов
POST /v1/webhook_endpoints Добавить адрес. Нужен заголовок Idempotency-Key. В ответе - секрет
GET /v1/webhook_endpoints/{id} Один адрес
PATCH /v1/webhook_endpoints/{id} Изменить адрес: меняются только переданные поля. "status": "enabled" включает адрес, в том числе после авто-отключения, "disabled" - выключает
DELETE /v1/webhook_endpoints/{id} Удалить адрес
POST /v1/webhook_endpoints/{id}/roll_secret Перевыпустить секрет - см. Ротация секрета
POST /v1/webhook_endpoints/{id}/test Отправить тестовое событие - см. Тестовое событие
GET /v1/events Лента событий - см. Лента событий
GET /v1/events/{id} Одно событие

Добавить адрес:

curl https://api.ru.dialogi.io/v1/webhook_endpoints \
  -H "Authorization: Bearer dk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: webhook-crm-1" \
  -d '{"url": "https://example.com/webhooks/dialogi", "description": "CRM", "enabled_events": ["lead.created", "lead.updated"], "project_ids": ["019cdbe8-e800-76c7-bd8d-a1cee5cdcca0"]}'
  • enabled_events - список типов событий или ["*"] - все, в том числе новые типы. Если в списке есть и *, и отдельные типы, сохранится ["*"]. Неизвестный тип или повтор типа в списке - 422 validation.
  • project_ids - список проектов или null - все проекты компании, в том числе созданные позже. Пустой список [] - 422 validation. Без поля - все проекты, как с null.

Изменения адреса действуют и на ожидающие повторы: после смены url они уходят на новый адрес, а если из адреса убрали тип события или проект, ожидающие доставки таких событий отменяются с причиной endpoint_unsubscribed.

POST …/roll_secret и POST …/test принимают необязательный заголовок Idempotency-Key: повтор с тем же ключом вернёт первый ответ и не выполнит действие второй раз. См. Идемпотентность.

Ответ 201 - адрес и его секрет:

{
  "object": "webhook_endpoint",
  "id": "01a0e3b2-4c1d-7e8f-9a0b-1c2d3e4f5a6b",
  "url": "https://example.com/webhooks/dialogi",
  "description": "CRM",
  "enabled_events": ["lead.created", "lead.updated"],
  "project_ids": ["019cdbe8-e800-76c7-bd8d-a1cee5cdcca0"],
  "status": "enabled",
  "disabled_reason": null,
  "disabled_at": null,
  "created_at": "2026-09-28T11:00:02Z",
  "updated_at": "2026-09-28T11:00:02Z",
  "secret": "whsec_…"
}

Поле secret бывает только в ответах на создание и на перевыпуск секрета. Остальные поля - в разделе Формат данных.

Удаление отвечает 200 и заглушкой {"object": "webhook_endpoint", "id": "…", "deleted": true}. Ожидающие повторы на удалённый адрес отменяются с причиной endpoint_deleted.

Больше 10 адресов у компании быть не может: включённые и выключенные считаются вместе. Одиннадцатый получит 409 webhook-endpoint-limit.

Формат события

Событие приходит запросом POST с JSON в теле:

{
  "id": "01a0e3b4-7d2e-7f10-8b3c-5d6e7f8a9b0c",
  "object": "event",
  "type": "lead.created",
  "created_at": "2026-09-28T11:01:30Z",
  "project_id": "019cdbe8-e800-76c7-bd8d-a1cee5cdcca0",
  "test": false,
  "data": {
    "object": {
      "object": "lead",
      "id": "01a0e3b4-6a10-7c2d-9e4f-0a1b2c3d4e5f",
      "project_id": "019cdbe8-e800-76c7-bd8d-a1cee5cdcca0",
      "department_id": null,
      "phone": "+79101234567",
      "name": "Анна",
      "status": "queued",
      "status_reason": null,
      "source": "widget",
      "source_url": "https://example.com/contacts",
      "utm": null,
      "qualifier": null,
      "scheduled_at": null,
      "call": null,
      "rating": null,
      "handled_at": null,
      "metadata": null,
      "created_at": "2026-09-28T11:01:29Z",
      "updated_at": "2026-09-28T11:01:29Z"
    }
  }
}
Поле Что в нём
id id события. Он одинаковый во всех попытках доставки - по нему отсекайте повторы
object Всегда event
type Тип события, например lead.created. Список - на странице События вебхуков
created_at Когда произошло событие, UTC
project_id Проект, в котором произошло событие
test true у тестового события, у остальных false
data.object Объект целиком, в том же виде, что в ответе API: заявка, запись, диалог, сообщение или контакт. Что приходит в каждом событии - на странице События вебхуков

Если объект не помещается в тело размером 256 КБ, в data.object приходят только object и id. Прочитайте объект через API.

Заголовки запроса:

Заголовок Что в нём
Content-Type application/json; charset=utf-8
User-Agent Dialogi-Webhooks/1.0 (+https://dialogi.io/developers/webhooks)
Dialogi-Signature Подпись: t=1790593290,v1=4f80…3ca6 - см. Проверка подписи
Dialogi-Event-Id id события, тот же, что id в теле
Dialogi-Event-Type Тип события, тот же, что type в теле

Cookies и заголовка Authorization в запросе нет. Отличить запрос Dialogi от чужого можно только по подписи.

Проверка подписи

Проверяйте подпись у каждого запроса. Без неё любой, кто узнал адрес, может прислать поддельное событие.

Заголовок Dialogi-Signature состоит из частей через запятую:

  • t - время отправки, Unix-время в секундах;
  • v1 - подпись: HMAC-SHA256 в hex. Ключ - секрет целиком, вместе с whsec_. Подписана строка из t, точки и тела запроса: 1790593290.{"id":…}.

Частей v1 бывает несколько: пока старый секрет после перевыпуска ещё действует, запрос подписан обоими. Подпись верна, если совпала хотя бы одна.

Как проверить:

  1. Возьмите тело запроса как есть, в байтах, до разбора JSON. Если разобрать JSON и собрать заново, изменятся пробелы или экранирование, и подпись не совпадёт.
  2. Разберите заголовок: t и все v1.
  3. Проверьте, что t отличается от текущего времени не больше чем на 300 секунд. Так перехваченный запрос нельзя отправить повторно позже. Часы сервера должны идти точно.
  4. Посчитайте HMAC-SHA256 от t + "." + тело своим секретом и сравните с каждым v1 функцией сравнения за постоянное время.
  5. Подпись не совпала или t устарел - ответьте 400 и не обрабатывайте событие.

t и подпись новые у каждой попытки доставки, id события - тот же.

Секрет храните на сервере, как ключ API: в переменной окружения или хранилище секретов. В примерах ниже он в переменной окружения DIALOGI_WEBHOOK_SECRET. Убедитесь, что переменная видна процессу, который обрабатывает запрос: если секрет пустой, ни одна подпись не сойдётся. Например, PHP-FPM по умолчанию не передаёт переменные окружения воркерам - задайте её в настройках пула: env[DIALOGI_WEBHOOK_SECRET] = ….

PHP

<?php

function verify_dialogi_signature(string $payload, string $header, string $secret, int $tolerance = 300, ?int $now = null): bool
{
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't') {
            $timestamp = $value;
        } elseif ($key === 'v1') {
            $signatures[] = $value;
        }
    }

    if ($timestamp === null || !ctype_digit($timestamp) || $signatures === []) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

// Сырое тело запроса - до json_decode.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_DIALOGI_SIGNATURE'] ?? '';

if (!verify_dialogi_signature($payload, $header, getenv('DIALOGI_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$event = json_decode($payload, true);
// Сохраните событие в свою очередь и сразу ответьте 2xx.
http_response_code(200);

В Laravel сырое тело - $request->getContent(), заголовок - $request->header('Dialogi-Signature', '').

Node.js

const crypto = require('node:crypto');
const express = require('express');

function verifyDialogiSignature(rawBody, header, secret, toleranceSec = 300, now = Math.floor(Date.now() / 1000)) {
  let timestamp = null;
  const signatures = [];
  for (const part of String(header || '').split(',')) {
    const i = part.indexOf('=');
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value);
  }

  if (!/^\d+$/.test(timestamp || '') || signatures.length === 0) return false;
  if (Math.abs(now - Number(timestamp)) > toleranceSec) return false;

  const expected = crypto.createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const app = express();

// express.raw отдаёт тело как Buffer, без разбора JSON.
// limit - не меньше 256 КБ: по умолчанию express.raw принимает только 100 КБ.
app.post('/webhooks/dialogi', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
  const ok = verifyDialogiSignature(req.body, req.get('Dialogi-Signature'), process.env.DIALOGI_WEBHOOK_SECRET);
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString('utf8'));
  // Сохраните событие в свою очередь и сразу ответьте 2xx.
  res.sendStatus(200);
});

app.listen(3000);

Если в приложении подключён express.json() для всех маршрутов, он разберёт тело раньше и сырых байтов не останется. Подключите маршрут вебхука до app.use(express.json()).

Python

import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request


def verify_dialogi_signature(payload: bytes, header: str, secret: str, tolerance: int = 300, now: int | None = None) -> bool:
    timestamp = None
    signatures = []
    for part in (header or "").split(","):
        key, sep, value = part.strip().partition("=")
        if not sep:
            continue
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)

    if timestamp is None or not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
        return False
    if abs((int(time.time()) if now is None else now) - int(timestamp)) > tolerance:
        return False

    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + payload, hashlib.sha256).hexdigest().encode()
    return any(hmac.compare_digest(expected, signature.encode()) for signature in signatures)


app = Flask(__name__)


@app.post("/webhooks/dialogi")
def dialogi_webhook():
    # Сырое тело запроса - до разбора JSON.
    payload = request.get_data()
    header = request.headers.get("Dialogi-Signature", "")
    if not verify_dialogi_signature(payload, header, os.environ["DIALOGI_WEBHOOK_SECRET"]):
        abort(400)

    event = json.loads(payload)
    # Сохраните событие в свою очередь и сразу ответьте 2xx.
    return "", 200

В Django сырое тело - request.body, заголовок - request.headers.get("Dialogi-Signature", "").

Go

Нужен Go 1.22 или новее, и в go.mod - строка go 1.22 или выше: иначе маршрут "POST /webhooks/dialogi" не сработает и все запросы получат 404.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verifyDialogiSignature(payload []byte, header, secret string, tolerance time.Duration, now time.Time) bool {
	var timestamp string
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
		if !ok {
			continue
		}
		switch key {
		case "t":
			timestamp = value
		case "v1":
			signatures = append(signatures, value)
		}
	}

	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || len(signatures) == 0 {
		return false
	}
	if age := now.Sub(time.Unix(ts, 0)); age > tolerance || age < -tolerance {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(payload)
	expected := mac.Sum(nil)

	for _, signature := range signatures {
		given, err := hex.DecodeString(signature)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}

func main() {
	secret := os.Getenv("DIALOGI_WEBHOOK_SECRET")

	http.HandleFunc("POST /webhooks/dialogi", func(w http.ResponseWriter, r *http.Request) {
		// Сырое тело запроса - до разбора JSON.
		payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			http.Error(w, "cannot read body", http.StatusBadRequest)
			return
		}
		if !verifyDialogiSignature(payload, r.Header.Get("Dialogi-Signature"), secret, 5*time.Minute, time.Now()) {
			http.Error(w, "invalid signature", http.StatusBadRequest)
			return
		}

		var event struct {
			ID   string `json:"id"`
			Type string `json:"type"`
		}
		if err := json.Unmarshal(payload, &event); err != nil {
			http.Error(w, "invalid json", http.StatusBadRequest)
			return
		}
		// Сохраните событие в свою очередь и сразу ответьте 2xx.
		w.WriteHeader(http.StatusOK)
	})

	log.Fatal(http.ListenAndServe(":3000", nil))
}

Проверьте себя на примере

Прежде чем включать адрес, проверьте свою функцию на готовом примере. Все четыре функции выше проходят эту проверку.

Секрет:

whsec_2cGx4kYp9QmT7vR1nB5sW8dF3hJ6zL0a

Тело запроса - ровно эти байты в UTF-8, в одну строку, без перевода строки в конце, кириллица не экранирована:

{"id":"01926f3c-1a2b-7c3d-8e4f-5a6b7c8d9e0f","object":"event","type":"lead.created","created_at":"2026-09-28T11:01:30Z","project_id":"01926f3c-0000-7000-8000-000000000001","test":true,"data":{"object":{"object":"lead","id":"00000000-0000-7000-8000-000000000000","name":"Анна"}}}

Заголовок:

Dialogi-Signature: t=1790593290,v1=4f80ff3c9204a41871c706f5fcf5f8c5cf7deb9b6565351c069d9b6b86c23ca6

В header передайте значение заголовка без имени - t=1790593290,v1=4f80…3ca6, без Dialogi-Signature: . Время t в примере уже прошло, поэтому передайте его как текущее:

Язык Вызов
PHP verify_dialogi_signature($body, $header, $secret, 300, 1790593290)
Node.js verifyDialogiSignature(Buffer.from(body, 'utf8'), header, secret, 300, 1790593290)
Python verify_dialogi_signature(body.encode(), header, secret, now=1790593290)
Go verifyDialogiSignature([]byte(body), header, secret, 5*time.Minute, time.Unix(1790593290, 0))

Ожидаемый результат:

  • с этими телом и заголовком - true;
  • если в теле поменять хотя бы один символ, например Анна на Анны, - false;
  • если в v1 поменять хотя бы одну цифру - false;
  • если перед верным v1 добавить неверный: t=1790593290,v1=0000…,v1=4f80…3ca6 - true;
  • если передать время на 301 секунду больше t - false.

Чтобы проверить обработчик целиком, отправьте на адрес тестовое событие.

Ответ на событие

  • Ответьте кодом 2xx в течение 10 секунд. Любой код 2xx - успех, тело ответа не важно. Из ответа читаются и попадают в журнал доставок только первые 4 КБ.
  • Подключение к вашему серверу ждём до 5 секунд. 10 секунд считаются вместе с подключением.
  • Редиректы не выполняются: ответ 3xx считается неудачей с ошибкой redirect: <адрес из Location>.
  • Тяжёлую обработку делайте после ответа: сохраните событие в очередь или базу, ответьте 200 и обрабатывайте отдельно. Запрос в вашу CRM или 1С внутри обработчика легко выходит за 10 секунд.
  • На один адрес одновременно идёт не больше одного запроса. Медленные ответы задерживают следующие события на этот адрес. Тестовое событие идёт вне этой очереди.

Повторы

Если ответ не 2xx, его нет за 10 секунд или подключиться не удалось, запрос повторяется:

Попытка Пауза после предыдущей
1 сразу после события
2 1 минута
3 5 минут
4 30 минут
5 2 часа
6 5 часов
7 10 часов
8 24 часа
9 24 часа

Всего 9 попыток, последняя - примерно через 66 часов после первой, почти через 3 дня. Повторы запускаются раз в минуту, поэтому каждая пауза может быть до минуты длиннее указанной: пауза перед второй попыткой - от 1 до 2 минут. Начиная с паузы перед третьей попыткой пауза, кроме того, может отличаться от указанной на 15% в обе стороны. После девятой неудачи событие на этот адрес больше не отправляется. Отправить его ещё раз можно кнопкой «Переотправить» в журнале доставок - см. Переотправка.

Ошибки попыток видны в журнале доставок и в поле error ответа на тестовое событие. После них запрос повторяется по расписанию:

Ошибка Что случилось
timeout Не удалось подключиться за 5 секунд или нет ответа за 10 секунд. У тестового события - за 5 секунд
connect_failed Не удалось подключиться к серверу: соединение отклонено или оборвалось
dns_error Имя сервера не нашлось в DNS
tls_error Ошибка TLS: сертификат не от публичного центра сертификации, истёк или выдан на другое имя, версия TLS ниже 1.2
redirect: <адрес> Сервер ответил 3xx. После двоеточия - адрес из заголовка Location, например redirect: https://example.com/new. Если Location нет - просто redirect
url_blocked Адрес перестал проходить проверку, например имя стало указывать на внутренний IP или только на IPv6. Запрос не отправлялся
http_<код> Сервер ответил кодом не 2xx, например http_500
secret_unavailable Сбой на стороне Dialogi: запрос не отправлялся. На отсчёт авто-отключения не влияет

Разбирайте ошибку по началу строки: у redirect после двоеточия идёт адрес.

Доставка может быть и отменена - тогда повторов больше не будет, а код причины стоит в том же поле error журнала:

Причина отмены Что случилось
endpoint_disabled Адрес выключили: вручную, автоматически или потому, что удалили все его проекты
endpoint_deleted Адрес удалили
endpoint_unsubscribed Адрес больше не подписан на этот тип события или на проект события: его события или проекты изменили
addon_inactive У компании отключено дополнение «Публичный API»
event_missing Тело события уже удалено по сроку хранения - 30 дней

Авто-отключение

Если доставки на адрес не проходят 72 часа подряд, адрес выключается:

  • status становится disabled, disabled_reason - failing;
  • ожидающие повторы отменяются с причиной endpoint_disabled;
  • администраторам компании приходит уведомление со ссылкой на адрес.

Отсчёт 72 часов начинается с первой неудачи после успешной доставки, после создания адреса или после его включения. Любой ответ 2xx сбрасывает отсчёт. Ответ 410 - обычная неудача, адрес он сразу не выключает. Тестовые события на отсчёт не влияют.

Адрес выключается на первой неудачной попытке, которая случилась, когда с начала отсчёта прошло 72 часа. Эта попытка тоже получает причину endpoint_disabled. Если после 72 часов попыток на адрес не было - например, событие было одно и его повторы закончились примерно через 66 часов, - адрес остаётся включённым до следующей неудачи.

Ожидающие повторы отменяются при любом выключении адреса - и автоматическом, и ручном. После включения они сами не возобновляются.

Включить адрес можно только вручную: кнопкой «Включить» в карточке адреса или запросом PATCH /v1/webhook_endpoints/{id} с "status": "enabled". События, пропущенные, пока адрес был выключен, не досылаются. Что изменилось за это время, прочитайте через API.

Если удалить проект, он пропадает из project_ids адреса. Если в списке не осталось проектов, адрес выключается с disabled_reason - manual. Включить такой адрес можно, только выбрав проекты заново: PATCH с "status": "enabled" без нового project_ids получит 422 validation с полем errors.project_ids. Через API такой адрес видит только ключ, который работает со всеми проектами.

Переотправка

Кнопка «Переотправить» в журнале доставок делает внеочередную попытку той же доставки: с тем же id события и новой подписью. Расписание повторов она не сбрасывает, номер попытки продолжает счёт.

  • Работает для доставки в любом состоянии, кроме той, что отправляется прямо сейчас. Уже успешная доставка придёт на адрес ещё раз.
  • Если внеочередная попытка не прошла и попыток вместе с ней меньше 9, дальше идут автоматические повторы с того места расписания, где остановилась доставка, в том числе отменённая. Иначе доставка снова заканчивается неудачей.
  • Перед попыткой проверяется то же, что перед обычной: адрес включён, подписан на тип и проект события, дополнение «Публичный API» подключено. Если нет - доставка снова отменяется с соответствующей причиной. Поэтому на выключенном адресе кнопка неактивна: сначала включите адрес.
  • Тестовое событие переотправляется и на выключенный адрес, один раз, без повторов.

Неудачная внеочередная попытка участвует в отсчёте авто-отключения, успешная - сбрасывает его.

Дубли, порядок и свежесть данных

Одно событие может прийти дважды. Доставка работает по принципу «хотя бы один раз»: если ваш сервер обработал событие, но ответ до нас не дошёл, событие придёт снова. Храните id обработанных событий хотя бы 3 дня - столько идут автоматические повторы - и пропускайте уже обработанные. Событие, переотправленное вручную из журнала доставок, может прийти и позже, в пределах 30 дней.

Порядок не гарантирован. message.updated может прийти раньше message.created, conversation.closed - раньше последнего сообщения диалога. События разных разделов независимы: lead.created и contact.created по той же заявке приходят в любом порядке.

Объект - снимок на момент формирования события. Тип события - это факт: заявку создали, диалог закрыли. А data.object собирается через 1-3 секунды после изменения и показывает состояние на этот момент. Поэтому lead.created может прийти уже со статусом in_progress, а conversation.closed для диалога, который через секунду открыли снова, - со статусом open. Актуальное состояние читайте через GET.

  • Если объект удалили раньше, чем собралось событие о нём, это событие не отправляется. Например, контакт создали и сразу удалили - придёт только contact.deleted.
  • Если раздел Dialogi, где хранится объект, временно не отвечает, событие собирается позже, когда он ответит. Если он не ответил за 24 часа, событие не отправляется. Объект в собранном событии - на момент сборки, created_at события - момент изменения.

Свежесть - по updated_at. Прежде чем перезаписать объект у себя, сравните его updated_at с тем, что у вас уже есть, и не заменяйте более новые данные более старыми. updated_at меняется и без события - когда меняются служебные данные, которых нет в объекте, - поэтому по нему нельзя судить, пропущено ли событие. У сообщений updated_at нет - их актуальное состояние отдаёт GET /v1/conversations/{id}/messages.

*.updated без изменений не приходит. Если объект такой же, каким был в прошлом событии о нём, доставку которого создавали для этого адреса, событие *.updated на этот адрес не отправляется. Поле updated_at при этом сравнении не учитывается. Прошлым может быть и событие другого типа, например message.created.

Неизвестные типы событий пропускайте. Новые типы событий добавляются без смены версии API, а адрес с событиями «Все» получает их сразу. На событие незнакомого типа отвечайте 2xx и ничего не делайте, иначе оно будет повторяться.

Ротация секрета

Перевыпустите секрет, если он мог попасть в чужие руки. В личном кабинете: меню адреса → «Перевыпустить секрет». Через API:

curl https://api.ru.dialogi.io/v1/webhook_endpoints/01a0e3b2-4c1d-7e8f-9a0b-1c2d3e4f5a6b/roll_secret \
  -H "Authorization: Bearer dk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"expires_in_hours": 24}'

Ответ - адрес с новым секретом в поле secret. Новый секрет показывают один раз.

Старый секрет подписывает запросы ещё столько, сколько вы выбрали:

expires_in_hours Старый секрет
0 Перестаёт действовать сразу
1 Действует ещё 1 час
24 Действует ещё 24 часа

Пока старый секрет действует, в Dialogi-Signature две части v1: по одной на каждый секрет. Примеры выше проверяют все v1, поэтому порядок такой: перевыпустите секрет с запасом в 1 или 24 часа, замените секрет на своём сервере - и всё. Если секрет утёк, выбирайте 0.

Тестовое событие

Тестовое событие проверяет адрес и обработчик целиком: подпись, код ответа, время ответа.

В личном кабинете: меню адреса → «Тестовое событие» → выберите тип → «Отправить». В окне появятся код ответа, время и начало ответа вашего сервера. Через API:

curl https://api.ru.dialogi.io/v1/webhook_endpoints/01a0e3b2-4c1d-7e8f-9a0b-1c2d3e4f5a6b/test \
  -H "Authorization: Bearer dk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"type": "lead.created"}'

Ответ 200 - итог доставки:

{
  "object": "webhook_delivery",
  "id": "01a0e3c0-2b3c-7d4e-8f5a-6b7c8d9e0f1a",
  "event_id": "01a0e3c0-2a1b-7c3d-9e4f-5a6b7c8d9e0f",
  "status": "succeeded",
  "status_code": 200,
  "duration_ms": 184,
  "error": null,
  "created_at": "2026-09-28T11:05:40Z"
}
Поле Что в нём
status succeeded - сервер ответил 2xx, failed - нет
status_code Код ответа вашего сервера. Ответа не было - null
duration_ms Сколько миллисекунд шёл запрос
error Ошибка попытки из таблицы в разделе Повторы. Успех - null

Ответ 200 приходит и тогда, когда ваш сервер не ответил 2xx: итог - в status и error.

Как устроено тестовое событие:

  • в теле "test": true, у события свой id, а project_id и id в data.object - условные, из нулей: 00000000-0000-7000-8000-000000000000. data.object - образец объекта выбранного типа;
  • подпись и проверки адреса - те же, что у настоящих событий;
  • запрос уходит сразу, одна попытка без повторов. Подключения и ответа ждём до 5 секунд;
  • тестовое событие можно отправить и на выключенный адрес. На отсчёт авто-отключения оно не влияет;
  • через API нужно право чтения раздела выбранного типа, как при подписке: для lead.created - leads:read. Нет права - 403 forbidden. Неизвестный тип - 422 validation с полем errors.type;
  • в ленту событий тестовые события не попадают, в журнале доставок они помечены «тест»;
  • у компании одновременно отправляется не больше одного тестового события. Второе в это время получит 409 webhook-test-in-progress;
  • не больше 10 тестовых событий в минуту на компанию, дальше - 429 rate-limited. Через сколько секунд можно отправить следующее, указано в заголовке Retry-After.

В своём обработчике отличайте тестовые события по test и не запускайте на них бизнес-логику.

Права

Управлять адресами через API и читать ленту событий может ключ с правом webhooks:manage. Ключи, созданные до появления вебхуков, этого права не получили, даже с доступом «Полный» - для вебхуков через API создайте новый ключ. См. Ключи и права.

Одного webhooks:manage мало: события несут данные клиентов, поэтому, чтобы подписать адрес на событие или видеть его в ленте, ключу нужно ещё право чтения раздела этого события:

События Нужное право
conversation.*, message.* conversations:read
contact.* contacts:read
lead.* leads:read
appointment.* appointments:read
  • Если среди enabled_events есть события, на которые у ключа нет права, создание и изменение адреса отвечают 403 forbidden. Эти события и нужные права перечислены в detail:

    {
      "type": "https://dialogi.io/errors/forbidden",
      "title": "Forbidden",
      "status": 403,
      "detail": "The API key has no read access to these events: lead.created, lead.updated. Required permissions: leads:read."
    }
    
  • PATCH проверяет права по событиям, на которые адрес будет подписан после изменения, даже если enabled_events в запросе нет. Ключ без права чтения не может изменить адрес, подписанный на такие события, - в том числе сменить url, описание или выключить его.

  • ["*"] требует все четыре права чтения.

  • project_ids адреса - только проекты ключа. Проект не из ключа - 403. null (все проекты) может поставить только ключ, который работает со всеми проектами, иначе 403 с detail Only an API key with access to all projects can subscribe to all projects (project_ids: null).

  • Ключ видит только адреса, все проекты которых входят в его проекты. Адреса на все проекты видит только ключ, который работает со всеми проектами. Чужой для ключа адрес - 404 not-found.

  • /v1/events отдаёт ключу только события тех типов, на которые у него есть право, и только его проектов.

На сотрудников в личном кабинете эти ограничения не распространяются.

Требования к адресу

  • Только https. Адрес с http не сохранится.
  • Длина адреса - до 2048 символов.
  • Сертификат от публичного центра сертификации. Сертификаты НУЦ Минцифры не принимаются - доставки на такой адрес завершатся ошибкой tls_error. TLS - версии 1.2 или выше.
  • Порт - 443 или от 1024 до 65535.
  • Без логина и пароля в адресе (https://user:pass@…).
  • Имя сервера - с точкой, например hooks.example.com. Имена без точки, такие как localhost, не принимаются.
  • Имя сервера должно указывать на публичный IPv4-адрес: нужна A-запись в DNS, и все A-записи имени должны быть публичными адресами. Адрес, доступный только по IPv6, не принимается. Внутренние и служебные адреса, например 10.0.0.0/8, 192.168.0.0/16, 100.64.0.0/10 или 127.0.0.1, тоже не принимаются.
  • Вместо имени можно указать публичный IPv4-адрес в обычной записи: https://<IPv4>/webhooks. Сертификат тогда должен быть выдан на этот IP. IPv6-адрес в квадратных скобках и другие записи IPv4, например https://2130706433/ или https://0x7f.1/, не принимаются.
  • Адреса на dialogi.io и его поддоменах не принимаются.
  • Без редиректов: ответ 3xx - неудача доставки.
  • Домен на кириллице можно указать как есть: он сохранится в виде xn--…. Имя сервера сохраняется строчными буквами и без точки в конце.

Адрес проверяется при сохранении и перед каждой попыткой доставки. При сохранении неподходящий адрес даёт 422 validation, причина - в errors.url. Имя, которое не нашлось в DNS, тоже не сохранится. Если имя сервера позже стало указывать на недопустимый адрес или только на IPv6, попытка завершится ошибкой url_blocked, а если перестало находиться в DNS - dns_error. Запрос уходит на тот IP, который прошёл проверку перед этой попыткой.

IP-адреса, с которых отправляются вебхуки, не фиксированы и могут меняться. Не ограничивайте доступ к адресу по IP - проверяйте подпись.

Лента событий

GET /v1/events - события, отправленные на ваши адреса. События хранятся 30 дней. Ленту удобно использовать, чтобы найти событие по id или сверить данные после сбоя на своей стороне.

  • В ленте только события, для которых создавалась доставка хотя бы на один адрес компании. Итог доставки не важен: событие остаётся в ленте, даже если доставка не прошла или её отменили. Событие, на которое не подписан ни один включённый адрес, не создаётся и в ленту не попадает. *.updated, который не отправили ни на один адрес, потому что объект не изменился, в ленте тоже нет.
  • Тестовых событий в ленте нет.
  • Порядок - от новых к старым: по created_at события, при равном времени - по id.
  • Событие старше 30 дней - ответ 404 not-found.
  • Параметры: limit - от 1 до 100, по умолчанию 20; starting_after - id последнего события предыдущей страницы, см. Пагинация; project_id; type - точный тип события, например lead.updated. Незнакомый type даёт пустой список.
  • Если события из starting_after нет в ленте ключа - оно старше 30 дней или недоступно ключу, - ответ 422 validation с полем errors.starting_after.
  • project_id проекта, которого нет в ключе, - 403 forbidden.
  • Тело события в ленте - то же, что ушло на адрес. Ссылки на вложения сообщений выдаются заново при каждом запросе.
curl "https://api.ru.dialogi.io/v1/events?type=lead.updated&limit=50" \
  -H "Authorization: Bearer dk_live_…"

Ответ - список событий в формате из раздела Формат события:

{
  "object": "list",
  "data": [
    {
      "id": "01a0e3d1-0c1d-7e2f-8a3b-4c5d6e7f8a9b",
      "object": "event",
      "type": "lead.updated",
      "created_at": "2026-09-28T11:03:12Z",
      "project_id": "019cdbe8-e800-76c7-bd8d-a1cee5cdcca0",
      "test": false,
      "data": { "object": { "object": "lead", "id": "01a0e3b4-6a10-7c2d-9e4f-0a1b2c3d4e5f", "status": "completed" } }
    }
  ],
  "has_more": true
}

В примере объект заявки сокращён - в ленте он приходит целиком, как в вебхуке.