Los eventos de ciclo de vida permiten que tu aplicación informe a Mailoo de que un usuario identificado se registró o se activó. Mailoo hace upsert de un contacto APP_EVENT y puede enviar una campaña On-Event vía SMTP de la integración.
Los nombres de evento y la forma del payload están fijos en @mailoo/forms. Tu código decide cuándo emitir (tras tu propio alta o activación). No inventes nombres de evento ni claves de atributos.
Esto es distinto del envío anónimo de newsletter (FORM) y del feedback (CONTACT_FORM). Esos usan otros webhooks y alcances.
Aislamiento
Audiencias y campañas están acotadas a proyecto + integración FORM o CONTACT_FORM. No hay entidad «App» aparte: dos productos necesitan dos integraciones (y normalmente dos claves con lifecycle.ingest). Contactos y consultas de estado no cruzan integrationId. El submit de newsletter y el ingest de lifecycle en la misma FORM comparten la tabla de suscriptores (único (integrationId, email)); usa integraciones separadas cuando los productos no deban compartir contactos.
Claves de identidad
Un contacto tiene tres identificadores. No los mezcles:
externalUserId--- el id de usuario de tu app (p. ej. Firebase UID). Obligatorio en el ingest. Clave estable para status, retry, idempotencia de bienvenida yconsent.changed.email--- clave de unión única normalizada en la integración. Sirve para fusionar una fila de newsletter existente con el usuario de la app en el primer ingest. La consulta bulk de status también aceptaemails=para contactos que aún no tienenexternalUserId.contactId/Subscriber.id--- cuid de Mailoo. Se devuelve en el JSON de ingest solo para depuración. Nunca lo trates como id de usuario del host; nunca coincide con un Firebase UID.
Orden de merge en ingest: coincidir externalUserId, si no coincidir email y adjuntar el UID, si no crear. Si UID y email apuntan a filas distintas → 400.
No pongas la dirección de correo en externalUserId. La idempotencia de bienvenida es welcome:{integrationId}:{externalUserId}; usar el email fallaría al cambiar la dirección y colisionaría con UIDs reales.
Contrato
Catálogo cerrado (@mailoo/forms/events):
user.registered---MAILOO_LIFECYCLE_EVENTS.USER_REGISTEREDuser.activated---MAILOO_LIFECYCLE_EVENTS.USER_ACTIVATED
Claves de attributes permitidas solo: plan, tier, country, signupChannel, referrer. Se guardan para segmentación futura; hoy no cambian qué campaña se elige.
Campos obligatorios del body:
eventName--- valor del catálogo (sin valor por defecto)idempotencyKey--- único por emisión lógica (máx. 256)externalUserId--- id de usuario de tu appemailmarketingConsent--- booleano o{ granted, at?, source? }
Opcionales: locale, name, attributes.
Un eventName o atributo desconocido responde 400 (fail-fast).
Consentimiento ↔ membresía de lista. marketingConsent.granted: true borra unsubscribedAt y fija marketingConsentAt. granted: false fija unsubscribedAt y borra el consentimiento. Mailoo empuja consent.changed en cada cambio de membresía (ver abajo).
Comportamiento de envío
Tras un ingest válido, Mailoo puede crear un TransactionalSend con purpose: LIFECYCLE y vincularlo al LifecycleEvent.
Consentimiento. Si marketingConsent no está concedido, el evento se guarda y el estado de procesamiento pasa a SKIPPED con skipReason SKIPPED_NO_CONSENT. No se envía correo.
Baja. Si el contacto tiene unsubscribedAt, el evento es SKIPPED con SKIPPED_UNSUBSCRIBED.
Campaña On-Event. Mailoo busca una campaña ON_EVENT en la misma integración cuyo triggerEvent sea USER_REGISTERED o USER_ACTIVATED (según el nombre del evento) y cuyo estado sea ``SCHEDULED``. Crear en el panel empieza en DRAFT; actívala (estado SCHEDULED) o el ingest omite con SKIPPED_NO_CAMPAIGN. Solo una campaña On-Event SCHEDULED por integración y trigger.
Idempotencia (dos capas):
- Evento: único
(integrationId, idempotencyKey). Repetir la misma clave devuelve 200 conidempotentReplay: truey no crea un segundo evento ni un segundo envío para esa clave. - Envío:
welcome:{integrationId}:{externalUserId}parauser.registered, yactivated:{integrationId}:{externalUserId}parauser.activated. Una segunda clave de evento distinta para el mismo usuario sigue sin poder enviar una segunda bienvenida para ese trigger.
API
POST /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/events
Alcance: lifecycle.ingest (o FULL). Integración FORM o CONTACT_FORM activa. No reutilices una clave RESTRICTED solo de formulario.
data de éxito (también del retry): eventId, contactId, idempotentReplay, processingStatus, skipReason, send.
Retry (admin / envío fallido):
POST /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/events/{eventId}/retry
Vuelve a ejecutar match/envío de un evento almacenado. No genera un nuevo idempotencyKey. Si un envío de bienvenida ya está SENT, devuelve ese resultado sin un segundo correo. SKIPPED_NO_CONSENT / SKIPPED_UNSUBSCRIBED no se anulan. Para envíos FAILED con mensaje persistido, reintenta SMTP; si no, borra la fila de envío no SENT y vuelve a emparejar (p. ej. tras corregir campaña/SMTP).
Estado:
GET /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/status?externalUserId=
GET .../status?externalUserIds=id1,id2&emails=a@b.c,d@e.f
- Un solo
externalUserId--- contacto + últimos 20 eventos/envíos (forma legacy). - Bulk
externalUserIdsy/oemails--- máx. 50 ids combinados; la respuesta incluyesummaries[]con filas compactas por clave (matchedBy,contactId,marketingConsentGranted, último evento, último envío lifecycle). Prefiere claves UID tras el ingest; usa email para filas de backfill que aún no tienenexternalUserId.
Panel (Bearer JWT):
GET /api/v1/projects/{uid}/integrations/{id}/lifecycle/status
En la pestaña Setup de la integración FORM: resumen Lifecycle Welcome (campaña de bienvenida, último evento/envío, contadores). El panel está centrado en FORM en la UI; el ingest sigue aceptando CONTACT_FORM.
Consent.changed (webhook de entrega)
Cuando cambia la membresía de la lista (baja, Restore, re-opt-in FORM, concesión/revocación de consentimiento lifecycle), Mailoo encola:
{
"event": "consent.changed",
"integrationId": "...",
"externalUserId": "firebase-uid-or-null",
"recipientEmail": "user@example.com",
"marketingConsentGranted": true,
"unsubscribedAt": null,
"timestamp": "2026-09-10T12:00:00.000Z"
}
Salir de la lista también mantiene el payload legacy event: "unsubscribed" para consumidores antiguos. Configura deliveryWebhook en la integración. En @mailoo/forms usa verifyMailooDeliveryWebhook / createDeliveryWebhookHandler y copia marketingConsentGranted a tu flag del host (p. ej. newsletterConsent).
Seguimiento
Los envíos lifecycle usan el mismo seguimiento open/click que el correo transaccional. Las filas TransactionalSend guardan campaignId, lifecycleEventId, openedAt y clickedAt (solo el primer clic --- único; sin contador raw; ver transactional-email{.interpreted-text role="doc"}). Las URLs del píxel de apertura y de redirección de clic son endpoints públicos de seguimiento transaccional.
Emisión en servidor (recomendado)
Tras crear el usuario en tu base de datos:
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
Prefijo env (por defecto): MAILOO_LIFECYCLE_{API,API_KEY,PROJECT_UID,ID}.
Navegador → BFF
Monta createLifecycleEventHandler, createLifecycleStatusHandler y createLifecycleRetryHandler desde @mailoo/forms/routes.
Seguridad: createLifecycleEventHandler es un proxy abierto --- cualquier cliente que alcance el BFF puede hacer POST de emails. Para usuarios identificados, verifica la sesión y sobrescribe externalUserId / email desde la sesión antes de reenviar (o envuelve el handler).
Hooks de cliente (@mailoo/forms/hooks):
useMailooLifecycleEvent({ endpoint, getHeaders })--- resultado de ingest tipadouseMailooLifecycleStatus({ endpoint, getHeaders })--- status bulk/únicouseMailooLifecycleRetry({ endpoint, getHeaders })--- retry poreventId
Pasa getHeaders para adjuntar auth del host (p. ej. Authorization: Bearer token ID de Firebase). El navegador nunca ve X-API-Key.
Relacionado
website-forms{.interpreted-text role="doc"} --- submit de newsletter FORMwebsite-forms-nextjs-example{.interpreted-text role="doc"} --- BFF Next.jstransactional-email{.interpreted-text role="doc"} --- API de envío a un destinatario + webhooks de entreganextjs-packages{.interpreted-text role="doc"} --- visión general de paquetes