Worked Example: Lifecycle Email for a SaaS Product
The other guides explain each feature on its own. This one shows how they fit together, by walking through the real email setup behind vitelnk, a video-sharing product built by the same team as Mailtani.
It is a useful example because vitelnk has most of the situations a subscription product runs into: a free trial that needs a card, self-serve upgrades, credit packs, team workspaces, and users who quietly stop showing up. Every pattern below is running in production.
Nothing here is vitelnk-specific in a way that matters — swap "video" for whatever your product makes and the shapes hold.
Start by deciding what is a flow and what is a broadcast
This is the decision that causes the most rework, so make it first.
A broadcast goes to a closed group, once. The audience is already known and will not grow. "Everyone who signed up before March" is a closed group — nobody new can join it.
A flow reacts to behaviour, forever. "Anyone whose trial is about to expire" is not a list, it is a condition people enter and leave continuously.
The test: can someone qualify for this tomorrow? If yes, it's a flow. If the group is fixed at the moment you press send, it's a broadcast.
Getting this wrong in the safe direction — a broadcast where a flow belonged — means writing the same email again every month. Getting it wrong in the other direction is worse: a flow pointed at a one-off announcement keeps firing at new signups long after the announcement is stale.
The trigger types, and when each earns its place
vitelnk uses all three. They are not interchangeable.
1. An event fired at the moment something happens
Best when the thing you're reacting to is instantaneous and your app knows about it immediately.
vitelnk uses this for user.registered, video.uploaded, share_link.created,
lead.first_captured, user.subscribed, credits.purchased, and the subscription lifecycle
events. The flow fires within seconds, and the email can reference exactly what just happened.
2. A scheduled job that fires events for whoever qualifies
Best when the trigger is the passage of time, not an action. Nothing happens in your app at the moment a trial reaches "7 days left" — no request, no click. Something has to go looking.
vitelnk runs an hourly job that finds trials expiring soon and fires one event per user:
One event name carrying a status property, rather than three separate event names, so a single
flow can branch on trialStatus instead of three flows duplicating the same copy.
This is the pattern most people miss. If you find yourself wanting a trigger like "3 days after X with no Y", you need a scheduled job on your side that fires the event. Mailtani reacts to events; it does not poll your database. The Flows guide covers this under Cron + Event.
Two things to get right in that job, both of which vitelnk got wrong first:
- Bound the window. A job that asks "who has uploaded a video but has no share link?" with no date bound will, on its first run, match everyone who ever did that — including people from a year ago. vitelnk's version matched 32 users on a flow meant to catch a handful; adding a maximum age brought it to 2. Always ask "…in the last N days".
- Record what you sent. Keep a table of (user, email type) rows and check it before firing. Without it, an hourly job re-fires for the same person every hour.
3. Contact field changes picked up from a sync
Best when the information arrives through a periodic contact sync rather than a discrete event — plan changes, storage usage, feature flags.
vitelnk syncs contact properties every six hours: plan, hasUploadedVideo, hasShareLink,
leadCaptureEnabled, videoCount. Flows branch on these with custom.<name> conditions.
Example A: Onboarding that adapts to what the user actually did
The naive onboarding flow sends five emails on a fixed schedule regardless of behaviour, which means telling someone who has uploaded ten videos how to upload their first.
vitelnk's version branches instead:
The detail that breaks this: the branch compares against the string "true". If your app
sends a JSON boolean true, it must be stored as the string "true" for that comparison to
match. vitelnk shipped this storing booleans as "1", which meant every contact took the no
arm — no error, nothing logged, just the wrong email to everyone for weeks.
Before activating any flow with a branch, simulate it against a real contact and confirm the arm matches what you expect. The Flows guide covers this under Check it before you activate. A branch reading a field that resolves wrongly is invisible in every other way.
Example B: One flow for a trial, branching on urgency and card status
Trials generate several emails that share context, so they belong in one flow rather than four.
Two things worth copying:
Name the amount. The 48-hour email includes renewalAmount in the event payload. "Your card
will be charged" without a number is the kind of email that generates support tickets and
chargebacks. Passing the real figure costs nothing and prevents both.
Exclude people who already cancelled. A cancelled trial often keeps its "trialing" status until the period ends. If your query does not exclude them, you will send "your card will be charged $19" to someone who cancelled specifically to avoid that charge. They belong in a cancellation flow, not this one.
Example C: Stopping a flow the moment it stops being true
A win-back sequence that keeps arriving after someone has already come back is worse than sending nothing. Flows should declare their own stop conditions rather than relying on you remembering to prune them.
Stop on event — end enrolment when something happens:
Stop on field state — end enrolment when the contact's own data changes. This catches conversions that arrive through a sync rather than as an event:
Use both together. The event stops it within seconds when your app fires one; the field check is the safety net for conversions that arrive by other routes — a manual upgrade, a support action, an import.
Do not leave
stop_whenempty as a placeholder. An empty condition set is trivially true, which would stop the flow for everyone.
Example D: A nudge for a feature people don't discover
Some of the highest-value emails do nothing but point out a feature that is already switched off.
vitelnk captures leads from a form on the video page, but the setting is off by default and a lot of people never find it. Product-wide, the number of videos with it enabled was zero.
The audience filter matters — the email only makes sense to someone whose videos are actually being watched. Someone with no views has a different problem and needs a different email.
Judge this flow on videos with lead capture enabled, not on opens. When the baseline is zero, any movement is unambiguous.
Example E: Someone comes back after a promotion
Worth calling out because it is the case most often missed entirely.
A lapsed user clicks a win-back offer and gets a fresh trial. They now look like a trial user to your billing system — but they are not a new signup, and they are not mid-journey either. They already formed an opinion of your product and left.
Two things go wrong here if you don't plan for them:
- They get no onboarding. The signup flow already ran, months ago, and won't re-enrol. So the hardest-won user on your list gets less guidance than a stranger.
- They get no trial-expiry warnings. If your scheduled job skips anyone who already received the trial emails — which is exactly what a naive de-duplication check does — their second trial ends in silence. They may not even notice it lapsed.
Fire a distinct event when someone reactivates (trial.reactivated) and give it its own flow.
Acknowledge the gap rather than pretending it's a fresh start: what changed since they left, and
one concrete next step. Then make sure your de-duplication is scoped per trial, not per user
forever.
Sending settings worth getting right once
On the frequency cap exception: use ignore_frequency_cap only for email that must arrive —
a receipt, a payment failure, a security notice. Exempting a marketing flow is how people end up
receiving four things from you on a Tuesday.
How to tell whether any of this is working
Measure the action each email exists to cause, not the open rate.
Open rates are worth keeping switched on, but as a diagnostic rather than a score: low opens with low action means the email isn't landing or the subject is wrong, while high opens with low action means it was read and didn't persuade. Those need opposite fixes. The Email Marketing guide covers how far to trust the number, given that Apple Mail loads tracking pixels by itself.
For flows that ask for a reply, the reply is the metric — but it only shows up in Mailtani if IMAP sync is configured. Otherwise those flows will look like they did nothing at all.
Before you activate anything
- Simulate each branching flow against a real contact and confirm the arm it takes.
- Check every custom field the branches reference actually exists. A field that was never
created reads as unset, and the flow silently takes the
noarm for everyone. - Confirm booleans are stored as
"true"/"false", not"1"/"". - Bound every scheduled job's window and dry-run it. If it matches far more people than you expected, it is missing a date bound.
- Set stop conditions on anything that should end when the contact converts.
- Send yourself one of each — a real send catches broken links, missing variables and plain-text rendering that no simulation will.
What's Next
- Flow Campaigns — node types and the patterns referenced above
- Broadcasts — one-off sends to a closed audience
- Variables & Personalization — using event properties in email bodies
- API Integration — firing events from your application
