Lalexi
API

Вебхуки

События API Lalexi на ваш адрес: какие бывают, что приходит в теле, как проверить подпись, повторы и автоотключение.

Вебхук — это ваш адрес (HTTPS), на который Lalexi отправляет POST при событии: пришло сообщение, изменился статус доставки, диалог закрыли, канал отключился. Так интеграции не нужно опрашивать API.

Вебхук принадлежит человеку — тому, чьим токеном он создан, — и получает только то, что этот человек видит в Lalexi: оператор — события своих каналов, администратор — всего пространства. Участник, которого удалили из пространства, больше ничего не получает.

Как подключить

curl https://app.lalexi.com/v1/webhooks \
  -H "Authorization: Bearer <ваш токен>" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://crm.example.com/hooks/lalexi", "events": ["message.created", "conversation.updated"]}'

В ответе — secret вида whsec_…. Он показывается один раз: сохраните его, им вы будете проверять подпись. Новый секрет выдаёт PATCH /webhooks/{id} с {"rotate_secret": true} — старый сразу перестаёт подписывать.

  • Адрес — только https:// в открытом интернете; частные и локальные адреса не принимаются.
  • До 10 вебхуков на человека в пространстве.
  • POST /webhooks/{id}/test присылает тестовое событие ping — подписанное и с теми же повторами, что и настоящие.
  • Свои вебхуки: GET /webhooks; изменить адрес или события — PATCH /webhooks/{id}; удалить — DELETE /webhooks/{id}.

События

СобытиеКогда приходитЧто в data
message.createdВ видимом вам диалоге появилось сообщение: входящее, исходящее (из Lalexi или с подключённого телефона) или заметка. Служебные строки («закрыт …») не приходят.message, conversation
message.updatedУ сообщения изменился статус доставки (delivered, read, failed с error_code) или текст, либо его удалили.message, conversation
conversation.createdПоявился новый видимый вам диалог.conversation
conversation.updatedИзменились статус, приоритет, ответственный, команда, метки, поля или время «отложен до».conversation, changed
contact.createdСоздан контакт.contact
contact.updatedИзменились имя, e-mail, телефон, идентификатор, поля или блокировка контакта.contact, changed
channel.createdПодключён новый номер — через POST /channels или в Lalexi. Приходит тому, кто его подключил, и тем, кто работает в канале.channel
channel.status_changedИзменился статус подключения канала мессенджера, в котором вы работаете.channel, previous_status, status
pingТестовая доставка из POST /webhooks/{id}/test. На неё не подписываются.message

changed — список изменившихся полей объекта, например ["status", "assignee"]. Одно изменение — одна доставка. Объекты в data — те же, что отдаёт API (GET /conversations/{id}, GET /contacts/{id}, GET /channels/{id}); их поля описаны в справочнике.

Что приходит

POST /hooks/lalexi HTTP/1.1
Content-Type: application/json
User-Agent: Lalexi-Webhooks/1
X-Lalexi-Event: message.created
X-Lalexi-Delivery: 6f1c2b9e-1d2a-4c55-9d7e-3f0a8b7c6d5e
X-Lalexi-Timestamp: 1793093400
X-Lalexi-Signature: sha256=9dfbf0a43511ead4b14f1cd9594bf54a94e01783962dc739a63e0f13d19461ed
{
  "id": "6f1c2b9e-1d2a-4c55-9d7e-3f0a8b7c6d5e",
  "event": "message.created",
  "created_at": "2026-10-27T09:30:00Z",
  "workspace_id": 7,
  "data": {
    "message": {
      "id": 88213,
      "conversation_id": 1042,
      "type": "incoming",
      "text": "Здравствуйте! Заказ уже отправили?",
      "deleted": false,
      "sender": { "type": "contact", "id": 5120, "name": "Анна Петрова" },
      "status": "sent",
      "error_code": null,
      "attachments": [],
      "created_at": "2026-10-27T09:30:00Z",
      "updated_at": "2026-10-27T09:30:00Z"
    },
    "conversation": {
      "id": 1042,
      "status": "open",
      "priority": null,
      "channel": { "id": 12, "name": "Продажи", "type": "whatsapp" },
      "contact": { "id": 5120, "name": "Анна Петрова", "phone": "+79001234567", "email": null },
      "assignee": { "id": 3, "name": "Мария" },
      "team": null,
      "labels": ["заказ"],
      "custom_attributes": {},
      "snoozed_until": null,
      "created_at": "2026-10-20T08:00:00Z",
      "updated_at": "2026-10-27T09:30:00Z",
      "last_activity_at": "2026-10-27T09:30:00Z"
    }
  }
}
ЗаголовокЧто это
X-Lalexi-EventСобытие, то же, что event в теле.
X-Lalexi-DeliveryНомер доставки, тот же, что id в теле. Не меняется при повторах — по нему отсеивайте дубли.
X-Lalexi-TimestampКогда подписана эта попытка, в секундах Unix. Входит в подпись.
X-Lalexi-Signaturesha256= и подпись в hex.

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

Проверяйте каждую доставку, прежде чем ей верить:

  1. возьмите заголовок X-Lalexi-Timestamp и сырое тело запроса — байты, как они пришли, до разбора JSON;
  2. посчитайте HMAC-SHA256 от строки {timestamp}.{тело}, ключ — весь секрет целиком, вместе с whsec_; результат в hex;
  3. сравните с тем, что после sha256= в X-Lalexi-Signature, сравнением за постоянное время;
  4. отклоните доставку, если время подписи отличается от ваших часов больше чем на 5 минут, — это повтор старого запроса.
import crypto from 'node:crypto';
import http from 'node:http';

const secret = process.env.LALEXI_WEBHOOK_SECRET; // whsec_…

function verify(rawBody, timestamp, signature) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!timestamp || !(age <= 300)) return false;
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

http
  .createServer((req, res) => {
    const chunks = [];
    req.on('data', (chunk) => chunks.push(chunk));
    req.on('end', () => {
      const raw = Buffer.concat(chunks);
      if (!verify(raw, req.headers['x-lalexi-timestamp'], req.headers['x-lalexi-signature'])) {
        res.writeHead(401).end();
        return;
      }
      const delivery = JSON.parse(raw);
      // Отсейте дубль по delivery.id и поставьте событие в свою очередь.
      console.log(delivery.event, delivery.id);
      res.writeHead(204).end();
    });
  })
  .listen(3000);
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["LALEXI_WEBHOOK_SECRET"].encode()  # whsec_…


def verify(body: bytes, timestamp: str, signature: str) -> bool:
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/hooks/lalexi")
def lalexi_webhook():
    body = request.get_data()  # сырые байты, до разбора JSON
    if not verify(body, request.headers.get("X-Lalexi-Timestamp", ""), request.headers.get("X-Lalexi-Signature", "")):
        abort(401)
    delivery = request.get_json()
    # Отсейте дубль по delivery["id"] и поставьте событие в свою очередь.
    print(delivery["event"], delivery["id"])
    return "", 204

Не пересобирайте JSON перед проверкой: после разбора и обратной сборки байты другие, и подпись не сойдётся.

Ответ, повторы и автоотключение

  • Ответьте любым кодом 2xx в течение 5 секунд (доставка целиком, с редиректами, обрывается через 15 секунд). Тело ответа не читается. Тяжёлую обработку делайте после ответа — в своей очереди.
  • Всё остальное — неудачная попытка: другой код, тайм-аут, обрыв соединения. Доставку повторят с растущими паузами — от секунд до нескольких минут, всего до 5 попыток.
  • После 50 неудачных попыток подряд (повторы считаются) вебхук выключается: active: false. Включите его снова через PATCH /webhooks/{id} с {"active": true} — счётчик неудач обнулится. Пока вебхук выключен, события на него не копятся.
  • Итог последней попытки виден в GET /webhooks/{id}: last_delivery_at, last_status, last_error, consecutive_failures.
  • Порядок не гарантирован, а доставка изредка приходит дважды. Отсеивайте дубли по X-Lalexi-Delivery (id в теле), а порядок определяйте по updated_at объектов.

Содержание