Lifecycle events let your application tell Mailoo that an identified user registered or activated. Mailoo upserts an APP_EVENT contact and may send an On-Event campaign via the integration's outbound SMTP.
The event names and payload shape are fixed in @mailoo/forms. Your code decides when to emit (after your own signup or activation). Do not invent event names or attribute keys.
This is separate from anonymous FORM newsletter submit and CONTACT_FORM feedback. Those use different webhooks and scopes.
Isolation
Audiences and campaigns are scoped to a project + FORM or CONTACT_FORM integration. There is no separate "App" entity: two products need two integrations (and usually two API keys with lifecycle.ingest). Contacts and status lookups never cross integrationId. Newsletter submit and lifecycle ingest on the same FORM share the subscriber table ((integrationId, email) unique); use separate integrations when products must not share contacts.
Identity keys
A contact has three identifiers. Do not mix them:
externalUserId--- your app user id (e.g. Firebase UID). Required on ingest. This is the stable key for status, retry, welcome idempotency, andconsent.changed.email--- normalized unique join key on the integration. Used to merge an existing newsletter row onto the app user on first ingest. Status bulk lookup also acceptsemails=for contacts that do not yet have anexternalUserId.contactId/Subscriber.id--- Mailoo cuid. Returned in ingest JSON for debugging only. Never treat it as the host user id; it never equals Firebase UID.
Ingest merge order: match externalUserId, else match email and attach the UID, else create. If UID and email point at different rows β 400.
Do not put the email address into externalUserId. Welcome send idempotency is welcome:{integrationId}:{externalUserId}; using email would break on address changes and collide with real UIDs.
Contract
Closed catalog (@mailoo/forms/events):
user.registered---MAILOO_LIFECYCLE_EVENTS.USER_REGISTEREDuser.activated---MAILOO_LIFECYCLE_EVENTS.USER_ACTIVATED
Allowed attributes keys only: plan, tier, country, signupChannel, referrer. Attributes are stored for future segmentation; they do not change which campaign is selected today.
Required body fields:
eventName--- catalog value (no default)idempotencyKey--- unique per logical emit (max 256)externalUserId--- your app's user idemailmarketingConsent--- boolean or{ granted, at?, source? }
Optional: locale, name, attributes.
Unknown eventName or attribute keys return 400 (fail-fast).
Consent β list membership. marketingConsent.granted: true clears unsubscribedAt and sets marketingConsentAt. granted: false sets unsubscribedAt and clears consent. Mailoo pushes consent.changed on every membership flip (see below).
Send behaviour
After a valid ingest, Mailoo may create a TransactionalSend with purpose: LIFECYCLE and link it to the LifecycleEvent.
Consent. If marketingConsent is not granted, the event is stored and processing status becomes SKIPPED with skipReason SKIPPED_NO_CONSENT. No email is sent.
Unsubscribe. If the contact has unsubscribedAt set, the event is SKIPPED with SKIPPED_UNSUBSCRIBED.
On-Event campaign. Mailoo looks for an ON_EVENT campaign on the same integration whose triggerEvent is USER_REGISTERED or USER_ACTIVATED (matching the event name) and whose status is ``SCHEDULED``. Creating a campaign in the dashboard starts as DRAFT; activate it (status SCHEDULED) or ingest skips with SKIPPED_NO_CAMPAIGN. Only one SCHEDULED On-Event campaign is allowed per integration and trigger.
Idempotency (two layers):
- Event: unique
(integrationId, idempotencyKey). Replaying the same key returns 200 withidempotentReplay: trueand does not create a second event or a second send for that key. - Send:
welcome:{integrationId}:{externalUserId}foruser.registered, andactivated:{integrationId}:{externalUserId}foruser.activated. A second different event key for the same user still cannot send a second welcome for that trigger.
API
POST /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/events
Scope: lifecycle.ingest (or FULL). Bind to an active FORM or CONTACT_FORM integration. Do not reuse a form-only RESTRICTED key.
Success data (also returned by retry): eventId, contactId, idempotentReplay, processingStatus, skipReason, send.
Retry (admin / failed send):
POST /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/events/{eventId}/retry
Re-runs match/send for a stored event. Does not mint a new idempotencyKey. If a welcome send is already SENT, returns that result without a second email. SKIPPED_NO_CONSENT / SKIPPED_UNSUBSCRIBED are not overridden. For FAILED sends with a persisted message, retries SMTP; otherwise clears a non-SENT send row and rematches (e.g. after campaign/SMTP was fixed).
Status:
GET /api/v1/webhooks/lifecycle/{projectUid}/{integrationId}/status?externalUserId=
GET .../status?externalUserIds=id1,id2&emails=a@b.c,d@e.f
- Single
externalUserId--- contact + last 20 events/sends (legacy shape). - Bulk
externalUserIdsand/oremails--- max 50 ids combined; response includessummaries[]with compact per-key rows (matchedBy,contactId,marketingConsentGranted, last event, last lifecycle send). Prefer UID keys after ingest; use email for backfill rows that still lackexternalUserId.
Dashboard (Bearer JWT):
GET /api/v1/projects/{uid}/integrations/{id}/lifecycle/status
The FORM integration Setup tab shows a Lifecycle Welcome summary (welcome campaign, last event/send, counts). The panel is FORM-focused in the UI; ingest still accepts CONTACT_FORM.
Consent.changed (delivery webhook)
Whenever list membership flips (unsubscribe, Restore, FORM re-opt-in, lifecycle consent grant/revoke), Mailoo enqueues:
{
"event": "consent.changed",
"integrationId": "...",
"externalUserId": "firebase-uid-or-null",
"recipientEmail": "user@example.com",
"marketingConsentGranted": true,
"unsubscribedAt": null,
"timestamp": "2026-09-10T12:00:00.000Z"
}
Leaving the list also keeps the legacy event: "unsubscribed" payload for older consumers. Configure deliveryWebhook on the integration. In @mailoo/forms use verifyMailooDeliveryWebhook / createDeliveryWebhookHandler and copy marketingConsentGranted onto your host flag (e.g. newsletterConsent).
Tracking
Lifecycle sends use the same open/click tracking as transactional mail. TransactionalSend rows store campaignId, lifecycleEventId, openedAt, and clickedAt (first click only --- unique; no raw counter; see transactional-email{.interpreted-text role="doc"}). Open pixel and click redirect URLs are public transactional tracking endpoints.
Server emit (recommended)
After you create the user in your database:
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 prefix (default): MAILOO_LIFECYCLE_{API,API_KEY,PROJECT_UID,ID}.
Browser β BFF
Mount createLifecycleEventHandler, createLifecycleStatusHandler, and createLifecycleRetryHandler from @mailoo/forms/routes.
Security: createLifecycleEventHandler is an open proxy --- any client that can reach the BFF can POST emails. For identified users, verify a session and overwrite externalUserId / email from the session before forwarding (or wrap the handler).
Client hooks (@mailoo/forms/hooks):
useMailooLifecycleEvent({ endpoint, getHeaders })--- typed ingest resultuseMailooLifecycleStatus({ endpoint, getHeaders })--- bulk/single statususeMailooLifecycleRetry({ endpoint, getHeaders })--- retry byeventId
Pass getHeaders to attach host auth (e.g. Authorization: Bearer Firebase ID token). The browser never sees X-API-Key.
Related
website-forms{.interpreted-text role="doc"} --- newsletter FORM submitwebsite-forms-nextjs-example{.interpreted-text role="doc"} --- Next.js BFFtransactional-email{.interpreted-text role="doc"} --- single-recipient send API + delivery webhooksnextjs-packages{.interpreted-text role="doc"} --- package overview