События жизненного цикла позволяют вашему приложению сообщить 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_REGISTEREDuser.activated---MAILOO_LIFECYCLE_EVENTS.USER_ACTIVATED
Допустимые ключи attributes только: plan, tier, country, signupChannel, referrer. Атрибуты сохраняются для будущей сегментации; на выбор кампании сейчас не влияют.
Обязательные поля тела:
eventName--- значение из каталога (без значения по умолчанию)idempotencyKey--- уникален на логический emit (макс. 256)externalUserId--- id пользователя вашего приложенияemailmarketingConsent--- 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 кампания.
Идемпотентность (два уровня):
- Событие: уникальность
(integrationId, idempotencyKey). Повтор того же ключа → 200 сidempotentReplay: true; второе событие и вторая отправка для этого ключа не создаются. - Отправка:
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 })--- типизированный результат ingestuseMailooLifecycleStatus({ endpoint, getHeaders })--- bulk/одиночный statususeMailooLifecycleRetry({ endpoint, getHeaders })--- retry поeventId
Передайте getHeaders, чтобы добавить auth хоста (например, Authorization: Bearer Firebase ID token). Браузер никогда не видит X-API-Key.
Связанное
website-forms{.interpreted-text role="doc"} --- submit newsletter FORMwebsite-forms-nextjs-example{.interpreted-text role="doc"} --- Next.js BFFtransactional-email{.interpreted-text role="doc"} --- API одиночной отправки + вебхуки доставкиnextjs-packages{.interpreted-text role="doc"} --- обзор пакетов