API: вебхуки, события и подписи
Webhook отправляет HTTPS POST на ваш сервер, когда в рабочем пространстве происходит событие: новый разговор, продажа, готовность медиа, аккаунт потерял соединение. Вы выбираете события; LanaChat подписывает каждую доставку, чтобы вы могли проверить, что она от нас, и повторяет попытки, если ваш сервер не отвечает.
Endpoints #
| Method | Path | Description | Permission | Status |
|---|---|---|---|---|
| GET | /webhooks | Список webhook-эндпоинтов | owner | Planned |
| POST | /webhooks | Добавить эндпоинт: URL и события (секрет подписи показывается один раз) | owner | Planned |
| GET | /webhooks/{id} | Один эндпоинт и его коэффициент доставки за 7 дней | owner | Planned |
| PATCH | /webhooks/{id} | Изменить URL или события, приостановить или возобновить | owner | Planned |
| DELETE | /webhooks/{id} | Удалить эндпоинт | owner | Planned |
| POST | /webhooks/{id}/rotate-secret | Новый секрет подписи; старый работает ещё 24 часа | owner | Planned |
| POST | /webhooks/{id}/test | Отправить событие ping сейчас | owner | Planned |
| GET | /webhooks/{id}/deliveries | Последние доставки, их статус и ответ | owner | Planned |
| POST | /webhooks/{id}/deliveries/{delivery_id}/retry | Отправить доставку повторно | owner | Planned |
Events #
| Event | Sent when | Permission the data needs |
|---|---|---|
conversation.started | Фан впервые пишет (с source_link_id, если известен) | can_fan_read |
conversation.needs_review | Разговор получил статус Review или Safety hold | can_fan_read |
conversation.taken_over | Разговор взял человек или вернул обратно | can_fan_read |
message.received | Фан отправил сообщение | can_fan_read |
sale.completed | Подтверждённая покупка (Stars или платежная ссылка) | can_sales_read |
sale.refunded | Подтверждён возврат | can_sales_read |
media.ready | Загруженное медиа обработано или ошибка | can_setup |
account.health_changed | Аккаунт стал connected, pending или expired | can_setup |
pool.saturated | Пул достиг 95% часовой ёмкости или лимита рабочего пространства | can_tracking |
brain.published | Черновик brain опубликован | can_engine |
billing.low_balance | Баланс упал ниже суммы на следующие 7 дней лицензий | owner |
ping | Вы нажали Test | none |
Эндпоинт получает событие только если токен, который его создал, имеет нужное разрешение для данных.
Объект webhook-эндпоинта #
{
"id": "wh_12",
"url": "https://hooks.mia-agency.com/lanachat/webhook",
"events": ["sale.completed", "sale.refunded", "conversation.needs_review"],
"status": "active",
"delivery_rate_7d": 0.998,
"created_at": "2026-10-01T09:00:00Z"
}
| Field | Type | Description |
|---|---|---|
id | string | Эндпоинт |
url | string | Ваш HTTPS URL |
events | array of strings | События, которые он получает (см. таблицу выше) |
status | string | active или paused (от вас или после 3 дней сбоев) |
delivery_rate_7d | number | Доля доставок с ответом 2xx за 7 дней, от 0 до 1 |
created_at | string | ISO 8601, UTC |
Управление эндпоинтами #
Все эти вызовы требуют токен owner.
Добавить эндпоинт #
POST /webhooks · owner
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | Да | HTTPS URL вашего сервера |
events | array of strings | Да | События для получения |
curl -X POST https://api.lanachat.ai/v1/webhooks \
-H "Authorization: Bearer $LANACHAT_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.mia-agency.com/lanachat/webhook", "events": ["sale.completed", "sale.refunded"]}'
Ответ, эндпоинт (201) и его секрет подписи, показываемый только в этом ответе: сохраните его сразу рядом с вашим токеном. Неизвестное имя события возвращает 400.
Список, чтение, изменение и удаление #
| Call | What it does |
|---|---|
GET /webhooks | Все эндпоинты рабочего пространства |
GET /webhooks/{id} | Один эндпоинт и его коэффициент доставки за 7 дней |
PATCH /webhooks/{id} | Изменить url или events; приостановить или возобновить через status |
DELETE /webhooks/{id} | Удалить; больше ничего не отправляется |
curl -X PATCH https://api.lanachat.ai/v1/webhooks/wh_12 \
-H "Authorization: Bearer $LANACHAT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "active"}'
Поменять секрет #
POST /webhooks/{id}/rotate-secret · owner
curl -X POST https://api.lanachat.ai/v1/webhooks/wh_12/rotate-secret \
-H "Authorization: Bearer $LANACHAT_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
В ответе новый секрет, один раз. Старый работает ещё 24 часа, чтобы вы могли обновить секрет без потери доставок.
Отправить тест #
POST /webhooks/{id}/test · owner
curl -X POST https://api.lanachat.ai/v1/webhooks/wh_12/test \
-H "Authorization: Bearer $LANACHAT_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
Ваш сервер получит событие ping через несколько секунд, подписанное как любая другая доставка.
Доставки и повторные попытки #
GET /webhooks/{id}/deliveries показывает последние доставки, их статус и ответ вашего сервера. POST /webhooks/{id}/deliveries/{delivery_id}/retry отправляет одну из доставок повторно, например, после исправления ошибки на вашей стороне.
curl -X POST https://api.lanachat.ai/v1/webhooks/wh_12/deliveries/dlv_5f21a9/retry \
-H "Authorization: Bearer $LANACHAT_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
Доставка #
POST /lanachat/webhook HTTP/1.1
Host: hooks.mia-agency.com
Content-Type: application/json
LanaChat-Event: sale.completed
LanaChat-Delivery: dlv_5f21a9
LanaChat-Signature: t=1759762800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_31877",
"type": "sale.completed",
"created_at": "2026-10-06T15:00:00Z",
"workspace_id": "ws_7Hq2",
"data": {
"sale_id": "sale_8812", "fan_id": "fan_921", "creator_id": "cr_lea", "account_id": "acc_1001",
"conversation_id": "conv_1001_88231", "stars": 300, "net_cents": 390, "sent_by": "ai", "kind": "offer"
}
}
Каждое событие имеет одинаковую обёртку: id, type, created_at, workspace_id и data. В data содержится объект, к которому относится событие, с полями, которые возвращает соответствующий API-эндпоинт.
Полезная нагрузка каждого события #
conversation.started: разговор с ссылкой, откуда он пришёл.
{ "id": "evt_31801", "type": "conversation.started", "created_at": "2026-10-06T14:01:12Z", "workspace_id": "ws_7Hq2",
"data": { "id": "conv_1001_88231", "fan_id": "fan_921", "creator_id": "cr_lea", "account_id": "acc_1001", "state": "ai", "source_link_id": "lnk_3" } }
conversation.needs_review: разговор и его новый статус.
{ "id": "evt_31840", "type": "conversation.needs_review", "created_at": "2026-10-06T14:20:00Z", "workspace_id": "ws_7Hq2",
"data": { "id": "conv_1001_88231", "creator_id": "cr_lea", "account_id": "acc_1001", "state": "off", "status": { "tag": "review", "reason": "The fan asked for a refund." } } }
conversation.taken_over: кто сейчас ведёт разговор (state, human, когда человек взял, ai, когда вернул).
{ "id": "evt_31852", "type": "conversation.taken_over", "created_at": "2026-10-06T14:04:00Z", "workspace_id": "ws_7Hq2",
"data": { "id": "conv_1001_88231", "state": "human", "taken_by": "tok_31", "taken_at": "2026-10-06T14:04:00Z" } }
message.received: сообщение фана.
{ "id": "evt_31860", "type": "message.received", "created_at": "2026-10-06T14:02:10Z", "workspace_id": "ws_7Hq2",
"data": { "id": "msg_77119", "conversation_id": "conv_1001_88231", "from": "fan", "text": "show me more", "at": "2026-10-06T14:02:10Z" } }
sale.completed и sale.refunded: продажа, как в примере выше. Возврат содержит тот же sale_id, что и отменяемая продажа.
media.ready: медиа, status, ready или failed.
{ "id": "evt_31870", "type": "media.ready", "created_at": "2026-10-06T14:22:05Z", "workspace_id": "ws_7Hq2",
"data": { "id": "med_5521", "creator_id": "cr_lea", "type": "photo", "status": "ready", "folder_id": "vf_3" } }
account.health_changed: аккаунт Telegram и его новый health.
{ "id": "evt_31881", "type": "account.health_changed", "created_at": "2026-10-06T15:10:00Z", "workspace_id": "ws_7Hq2",
"data": { "id": "acc_1004", "creator_id": "cr_lea", "username": "lea_backup", "health": "expired" } }
pool.saturated: пул и его загрузка.
{ "id": "evt_31890", "type": "pool.saturated", "created_at": "2026-10-06T21:00:00Z", "workspace_id": "ws_7Hq2",
"data": { "id": "pool_lea_fr_instagram", "creator_id": "cr_lea", "sent_last_hour": 381, "capacity_per_hour": 400, "status": "saturated" } }
brain.published: brain и его новая версия.
{ "id": "evt_31901", "type": "brain.published", "created_at": "2026-10-06T14:20:03Z", "workspace_id": "ws_7Hq2",
"data": { "id": "br_lea_main", "version": 13, "published_at": "2026-10-06T14:20:03Z" } }
billing.low_balance: ваш баланс.
{ "id": "evt_31910", "type": "billing.low_balance", "created_at": "2026-10-06T08:00:00Z", "workspace_id": "ws_7Hq2",
"data": { "balance_cents": 2900 } }
ping: отправлено кнопкой Test; data пуст.
{ "id": "evt_31920", "type": "ping", "created_at": "2026-10-06T16:00:00Z", "workspace_id": "ws_7Hq2", "data": {} }
Проверка подписи #
LanaChat-Signature содержит метку времени t и v1, HMAC-SHA256 от t + "." + raw body с секретом подписи эндпоинта, в hex.
В Node (любой фреймворк, дающий raw body):
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body as received (Buffer or string), before any JSON parsing
export function verify(rawBody, header, secret) {
const parts = (header || '').split(',').map((p) => p.trim().split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v); // two during a secret rotation
if (!t || !(Math.abs(Date.now() / 1000 - Number(t)) < 300)) return false; // older than 5 minutes: refuse
const want = createHmac('sha256', secret).update(t + '.').update(rawBody).digest('hex');
return signatures.some((v) => v.length === want.length && timingSafeEqual(Buffer.from(v), Buffer.from(want)));
}
Полный пример с Express:
import express from 'express';
import { verify } from './verify.js';
const app = express();
const seen = new Set(); // use your database in production
app.post('/lanachat/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('LanaChat-Signature'), process.env.LANACHAT_WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(200); // answer fast, work after
if (seen.has(event.id)) return; // the same event can arrive twice
seen.add(event.id);
if (event.type === 'sale.completed') console.log('New sale', event.data.stars, 'Stars for', event.data.creator_id);
});
app.listen(3000);
В Python с Flask:
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["LANACHAT_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, header: str) -> bool:
parts = [p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
signatures = [v for k, v in parts if k == "v1"] # two during a secret rotation
if not t or abs(time.time() - int(t)) >= 300: # older than 5 minutes: refuse
return False
want = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(want, v) for v in signatures)
@app.post("/lanachat/webhook")
def webhook():
raw = request.get_data()
if not verify(raw, request.headers.get("LanaChat-Signature")):
abort(400)
event = json.loads(raw)
if event["type"] == "sale.completed":
print("New sale", event["data"]["stars"], "Stars for", event["data"]["creator_id"])
return "", 200
- Вычисляйте по raw body, до JSON-парсинга.
- Отказывайте, если метка времени старше 5 минут, это предотвращает повтор.
- При смене секрета доставки несут два
v1; принимайте любой.
Повторные попытки #
- Отвечайте любым
2xxв течение 10 секунд. Всё остальное или отсутствие ответа, ошибка. - Ошибочная доставка повторяется 8 раз за примерно 24 часа (1 мин, 5 мин, 30 мин, 2 ч, 5 ч, 10 ч, 14 ч, 24 ч).
- Одно событие может прийти дважды: используйте
idдля игнорирования дубликатов. Порядок не гарантируется: используйтеcreated_at. - После 3 дней сбоев эндпоинт приостанавливается, владелец получает письмо; возобновите через
PATCH /webhooks/{id}.