События жизненного цикла

Обновлено: Sep 11, 2026Раздел: Интеграции

События жизненного цикла позволяют вашему приложению сообщить Mailoo, что идентифицированный пользователь зарегистрировался или активировался. Mailoo создаёт/обновляет контакт APP_EVENT и может отправить On-Event кампанию через SMTP интеграции.

Имена событий и форма payload зафиксированы в @mailoo/forms. Ваш код решает когда вызывать (после своей регистрации или активации). Не придумывайте имена событий и ключи атрибутов.

Это отдельно от анонимной подписки (FORM) и обратной связи (CONTACT_FORM). У тех другие вебхуки и scope.

Изоляция

Аудитории и кампании привязаны к проекту + интеграции FORM или CONTACT_FORM. Отдельной сущности «приложение» нет: два продукта --- две интеграции (и обычно два ключа с lifecycle.ingest). Контакты и запросы status не пересекают integrationId. Submit newsletter и ingest lifecycle на одной FORM делят таблицу подписчиков (уникальность (integrationId, email)); разделяйте интеграции, если продукты не должны делить контакты.

Ключи идентичности

У контакта три идентификатора. Не смешивайте их:

  • externalUserId --- id пользователя вашего приложения (например, Firebase UID). Обязателен при ingest. Стабильный ключ для status, retry, идемпотентности welcome и consent.changed.
  • email --- нормализованный уникальный ключ объединения на интеграции. Используется, чтобы при первом ingest привязать существующую строку newsletter к пользователю приложения. Bulk-запрос status также принимает emails= для контактов без externalUserId.
  • contactId / Subscriber.id --- cuid Mailoo. Возвращается в JSON ingest только для отладки. Никогда не считайте его id пользователя хоста; он никогда не равен Firebase UID.

Порядок merge при ingest: совпадение по externalUserId, иначе по email с привязкой UID, иначе создание. Если UID и email указывают на разные строки → 400.

Не кладите адрес email в externalUserId. Идемпотентность welcome --- welcome:{integrationId}:{externalUserId}; email вместо UID ломается при смене адреса и конфликтует с настоящими UID.

Контракт

Закрытый каталог (@mailoo/forms/events):

  • user.registered --- MAILOO_LIFECYCLE_EVENTS.USER_REGISTERED
  • user.activated --- MAILOO_LIFECYCLE_EVENTS.USER_ACTIVATED

Допустимые ключи attributes только: plan, tier, country, signupChannel, referrer. Атрибуты сохраняются для будущей сегментации; на выбор кампании сейчас не влияют.

Обязательные поля тела:

  • eventName --- значение из каталога (без значения по умолчанию)
  • idempotencyKey --- уникален на логический emit (макс. 256)
  • externalUserId --- id пользователя вашего приложения
  • email
  • marketingConsent --- boolean или { granted, at?, source? }

Опционально: locale, name, attributes.

Неизвестный eventName или ключ атрибута → 400 (fail-fast).

Согласие ↔ членство в списке. marketingConsent.granted: true очищает unsubscribedAt и задаёт marketingConsentAt. granted: false задаёт unsubscribedAt и очищает согласие. Mailoo отправляет consent.changed при каждом переключении членства (см. ниже).

Поведение отправки

После успешного ingest Mailoo может создать TransactionalSend с purpose: LIFECYCLE и связать его с LifecycleEvent.

Согласие. Если marketingConsent не выдан, событие сохраняется, статус обработки становится SKIPPED с skipReason SKIPPED_NO_CONSENT. Письмо не уходит.

Отписка. Если у контакта задан unsubscribedAt, событие --- SKIPPED с SKIPPED_UNSUBSCRIBED.

On-Event кампания. Mailoo ищет кампанию ON_EVENT на той же интеграции с triggerEvent USER_REGISTERED или USER_ACTIVATED (по имени события) и статусом ``SCHEDULED``. Создание в дашборде даёт DRAFT; без активации (статус SCHEDULED) ingest пропускает отправку (SKIPPED_NO_CAMPAIGN). На пару интеграция + триггер допускается только одна SCHEDULED On-Event кампания.

Идемпотентность (два уровня):

  1. Событие: уникальность (integrationId, idempotencyKey). Повтор того же ключа → 200 с idempotentReplay: true; второе событие и вторая отправка для этого ключа не создаются.
  2. Отправка: welcome:{integrationId}:{externalUserId} для user.registered и activated:{integrationId}:{externalUserId} для user.activated. Второй другой ключ события для того же пользователя всё равно не может отправить второй welcome для этого триггера.

API

POST /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/events

Scope: lifecycle.ingest (или FULL). Активная интеграция FORM или CONTACT_FORM. Не используйте form-only RESTRICTED-ключ.

Успешный data (также от retry): eventId, contactId, idempotentReplay, processingStatus, skipReason, send.

Retry (админ / неудачная отправка):

POST /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/events/{eventId}/retry

Повторно выполняет match/send для сохранённого события. Не создаёт новый idempotencyKey. Если welcome-отправка уже SENT, возвращает этот результат без второго письма. SKIPPED_NO_CONSENT / SKIPPED_UNSUBSCRIBED не переопределяются. Для FAILED-отправок с сохранённым сообщением повторяет SMTP; иначе очищает строку отправки не в статусе SENT и заново сопоставляет (например, после исправления кампании/SMTP).

Статус:

GET /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/status?externalUserId=
GET .../status?externalUserIds=id1,id2&emails=a@b.c,d@e.f
  • Один externalUserId --- контакт + последние 20 событий/отправок (legacy-форма).
  • Bulk externalUserIds и/или emails --- максимум 50 id суммарно; ответ включает summaries[] с компактными строками по ключу (matchedBy, contactId, marketingConsentGranted, последнее событие, последняя lifecycle-отправка). После ingest предпочитайте UID; email --- для строк backfill без externalUserId.

Дашборд (Bearer JWT):

GET /api/v1/projects/{uid}/integrations/{id}/lifecycle/status

На вкладке Setup интеграции FORM --- сводка Lifecycle Welcome (welcome-кампания, последнее событие/отправка, счётчики). В UI панель ориентирована на FORM; ingest по-прежнему принимает CONTACT_FORM.

Consent.changed (вебхук доставки)

При каждом переключении членства в списке (отписка, Restore, повторный opt-in FORM, выдача/отзыв согласия lifecycle) Mailoo ставит в очередь:

{
  "event": "consent.changed",
  "integrationId": "...",
  "externalUserId": "firebase-uid-or-null",
  "recipientEmail": "user@example.com",
  "marketingConsentGranted": true,
  "unsubscribedAt": null,
  "timestamp": "2026-09-10T12:00:00.000Z"
}

При выходе из списка сохраняется и legacy-payload event: "unsubscribed" для старых потребителей. Настройте deliveryWebhook на интеграции. В @mailoo/forms используйте verifyMailooDeliveryWebhook / createDeliveryWebhookHandler и копируйте marketingConsentGranted в флаг хоста (например, newsletterConsent).

Трекинг

Lifecycle-отправки используют тот же open/click-трекинг, что и транзакционная почта. Строки TransactionalSend хранят campaignId, lifecycleEventId, openedAt и clickedAt (только первый клик --- unique; без raw-счётчика; см. transactional-email{.interpreted-text role="doc"}). URL open-пикселя и click-редиректа --- публичные эндпоинты транзакционного трекинга.

Вызов с сервера (рекомендуется)

После создания пользователя в вашей БД:

import { ingestMailooLifecycleEvent } from '@mailoo/forms/server'
import { MAILOO_LIFECYCLE_EVENTS } from '@mailoo/forms/events'

const result = await ingestMailooLifecycleEvent({
  eventName: MAILOO_LIFECYCLE_EVENTS.USER_REGISTERED,
  idempotencyKey: `reg:${user.id}`,
  externalUserId: user.id,
  email: user.email,
  marketingConsent: { granted: user.acceptedMarketing, source: 'signup' },
  locale: user.locale,
})
// result.ok && result.data.processingStatus / skipReason / send

Префикс env (по умолчанию): MAILOO_LIFECYCLE_{API,API_KEY,PROJECT_UID,ID}.

Браузер → BFF

Подключите createLifecycleEventHandler, createLifecycleStatusHandler и createLifecycleRetryHandler из @mailoo/forms/routes.

Безопасность: createLifecycleEventHandler --- открытый прокси: любой клиент, доходящий до BFF, может POST-ить email. Для идентифицированных пользователей проверьте сессию и перезапишите externalUserId / email из сессии перед форвардом (или оберните handler).

Клиентские хуки (@mailoo/forms/hooks):

  • useMailooLifecycleEvent({ endpoint, getHeaders }) --- типизированный результат ingest
  • useMailooLifecycleStatus({ endpoint, getHeaders }) --- bulk/одиночный status
  • useMailooLifecycleRetry({ endpoint, getHeaders }) --- retry по eventId

Передайте getHeaders, чтобы добавить auth хоста (например, Authorization: Bearer Firebase ID token). Браузер никогда не видит X-API-Key.

Связанное

  • website-forms{.interpreted-text role="doc"} --- submit newsletter FORM
  • website-forms-nextjs-example{.interpreted-text role="doc"} --- Next.js BFF
  • transactional-email{.interpreted-text role="doc"} --- API одиночной отправки + вебхуки доставки
  • nextjs-packages{.interpreted-text role="doc"} --- обзор пакетов
📚