Опрос событий
Лента событий сообщает о двух фактах: лиду отправлено первое сообщение и лид ответил. Цикл ниже не теряет и не дублирует события, даже после сбоев и простоев.
#Как устроена лента
GET /v1/events возвращает события в порядке их появления в ленте. Типов два:
| Тип | Когда появляется |
|---|---|
lead.first_message_sent | Лиду отправлено первое сообщение, в том числе вручную. |
lead.replied | Лид ответил. |
Свойства ленты:
- Порядок по появлению, а не по времени факта. Поле
timestampпоказывает, когда произошел факт. Событие с более раннимtimestampможет прийти позже соседнего. Не используйтеtimestampкак курсор. - Доставка не реже одного раза. После сбоя страница может прийти повторно. Ключ дедупликации:
event.idвидаevt_и 32 шестнадцатеричных знака. - Идентификаторы совпадают с вебхуками. Если в кабинете настроены вебхуки, у того же факта тот же
id. Ленту и вебхуки можно использовать вместе, дедуплицируя поid. - Курсор есть всегда.
next_cursorникогда не равенnull, даже у пустой страницы. Каждый ответ выдает новый курсор, его нужно сохранить. - Хранение 30 дней. События старше 30 дней из ленты уходят. Курсор живет 30 суток с момента выдачи и продлевается каждым запросом.
Что важно знать о данных события:
lead.repliedприходит на каждый засчитанный ответ, у одного лида их может быть несколько.data.reply.automation_stoppedсейчас всегдаnull: остановку цепочки событие не подтверждает. Полеdata.reply.is_first_replyсейчас не передается.data.first_message_sent_atу события ответа часто равенnull. Время первого сообщения надежнее брать из событияlead.first_message_sentили из карточки лида.data.lead.fields.record_idсейчас равенnull,record_id_statusравенunknown. Как связать событие с CRM, описано в руководстве Связь с CRM.data.lead.usernameприходит в нижнем регистре.
#Первый запуск
Есть два способа начать:
- С текущего момента. Запрос с
start=nowвозвращает пустую страницу и курсор. Лента начнется с событий, которые появятся после него. Прошлое состояние заберите обходом кампаний и лидов. - С доступной истории. Запрос без
cursorи безstartначинает с самого раннего события, которое хранит лента, но не раньше 30 дней и не раньше подключения кабинета к сбору событий.
Для интеграции с CRM обычно удобнее первый способ: сначала получить курсор, затем сверить карточки, затем читать ленту от курсора. Так ни одно изменение во время сверки не потеряется.
#Надежный цикл
- Загрузите сохраненный курсор. Если его нет, выполните первый запуск.
- Запросите
GET /v1/events?cursor=<курсор>&limit=100. - В одной транзакции вашей базы примените события страницы и сохраните новый
next_cursor. Пропускайте события,idкоторых уже применены. - Если
has_moreравенtrue, сразу запросите следующую страницу. - Если
has_moreравенfalse, подождите минуту и повторите с шага 2.
Почему транзакция: если процесс упадет между применением событий и сохранением курсора, после перезапуска он повторит ту же страницу, а дедупликация по id не даст применить событие дважды. Если сохранить курсор раньше, чем применить события, при сбое они потеряются.
#Ошибки при опросе
| Ответ | Что делать |
|---|---|
| 429, 503 | Повторите тот же запрос с тем же курсором после Retry-After. Курсор не продвигается. |
| Сеть, таймаут | Повторите с тем же курсором. |
410 cursor_expired | Курсор старше 30 суток. Выполните восстановление. |
400 cursor_mismatch | Изменились права или охват ключа либо фильтры. Выполните восстановление. |
| 401, 403 | Ключ отозван, истек или доступ выключен. Остановите опрос и проверьте ключ. |
#Восстановление после простоя
Восстановление нужно после ответа 410 или cursor_mismatch, после простоя дольше 30 дней и когда meta.coverage.status сообщает о возможной неполноте.
- Отметьте у себя разрыв: время, когда поток прервался.
- Получите новый якорь:
GET /v1/events?start=now. Сохраните его курсор только после шага 3. - Обойдите текущие кампании и лидов и обновите карточки в своей системе. Поля
first_message_sent_at,first_reply_atиstatusлида покажут, что произошло за время разрыва. - Сохраните курсор якоря и продолжайте обычный цикл.
События, которые появятся во время обхода, придут в ленте после якоря. Совместите их с карточками монотонно: не откатывайте статус назад и не заменяйте более позднее время более ранним.
#Полнота ленты
Каждая страница содержит meta:
meta.available_from: с какого момента в ленте есть события.meta.coverage.status:complete,partialилиunknown. В раннем доступе обычно приходитunknownилиpartial: полнота ленты не гарантируется. Для важных процессов периодически, например раз в сутки, сверяйте карточки лидов.meta.coverage.gaps: интервалы, за которые события могли собраться не полностью. После такого интервала сверьте лидов затронутых кампаний.meta.collection.state: состояние сбора.degradedозначает, что данные могут быть неполными.
#Тексты сообщений
С параметром include_text=true событие содержит текст: у lead.first_message_sent в data.first_message, у lead.replied в data.reply. Нужны право messages:read и включенный у ключа «Текст сообщений». Текст есть, только если сохранен и его принадлежность переписке доказана; иначе text равен null, а причина в text_unavailable_reason. Запрос с текстом весит 3 вместо 2.
#Пример на Node.js
Функции хранилища замените своими. meliora() с повторами при 429 и 503 описана в руководстве Лимиты и повторы.
const POLL_INTERVAL_MS = 60_000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function runEventLoop(db) {
let cursor = await db.loadCursor();
if (!cursor) cursor = await resync(db);
for (;;) {
let page;
try {
page = await meliora('/events', { cursor, limit: 100 });
} catch (err) {
if (err.status === 410 || err.code === 'cursor_mismatch') {
cursor = await resync(db);
continue;
}
throw err; // 401 и 403: остановиться и проверить ключ
}
await db.transaction(async (tx) => {
for (const event of page.data) {
if (await tx.isApplied(event.id)) continue;
if (event.type === 'lead.first_message_sent' || event.type === 'lead.replied') {
await applyToCrm(tx, event);
}
await tx.markApplied(event.id); // незнакомый тип тоже отмечаем
}
await tx.saveCursor(page.next_cursor);
});
cursor = page.next_cursor;
if (!page.has_more) await sleep(POLL_INTERVAL_MS);
}
}
async function resync(db) {
await db.recordGap(new Date());
const anchor = await meliora('/events', { start: 'now' });
await reconcileLeads(db); // обход /v1/leads и обновление карточек
await db.saveCursor(anchor.next_cursor);
return anchor.next_cursor;
}#Пример на Python
import time
from datetime import datetime, timezone
POLL_INTERVAL = 60
def run_event_loop(db):
cursor = db.load_cursor() or resync(db)
while True:
try:
page = meliora("/events", {"cursor": cursor, "limit": 100})
except MelioraError as err:
if err.status == 410 or err.code == "cursor_mismatch":
cursor = resync(db)
continue
raise # 401 и 403: остановиться и проверить ключ
with db.transaction() as tx:
for event in page["data"]:
if tx.is_applied(event["id"]):
continue
if event["type"] in ("lead.first_message_sent", "lead.replied"):
apply_to_crm(tx, event)
tx.mark_applied(event["id"]) # незнакомый тип тоже отмечаем
tx.save_cursor(page["next_cursor"])
cursor = page["next_cursor"]
if not page["has_more"]:
time.sleep(POLL_INTERVAL)
def resync(db):
db.record_gap(datetime.now(timezone.utc))
anchor = meliora("/events", {"start": "now"})
reconcile_leads(db) # обход /v1/leads и обновление карточек
db.save_cursor(anchor["next_cursor"])
return anchor["next_cursor"]Функция meliora() и класс MelioraError приведены в руководстве Лимиты и повторы. Как сопоставить событие с записью CRM, описано в руководстве Связь с CRM.
#Как часто опрашивать
Раз в минуту достаточно для большинства интеграций, это 2 весовые единицы в минуту из лимита ключа. Реже тоже можно, например раз в час: курсор живет 30 суток, а лента хранит события 30 дней. Пока has_more равен true, забирайте страницы подряд без паузы. Более частый опрос не ускоряет появление событий: они попадают в ленту по мере сбора данных, и время этой задержки не гарантируется.