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.
Example F: Announcing new features, once per user, without a broadcast per release
vitelnk ships something most weeks. For a while each release got its own broadcast. That worked, but it had three gaps:
- A broadcast reaches whoever is in the audience that day. Someone who signs up a week later, or who was skipped that week (frequency cap, a bounce that later got fixed), never hears about the feature. And nothing tells you they missed it.
- Weekly releases meant writing and sending a broadcast by hand every week. Skip a week and the feature goes unannounced.
- There was no record of who had heard about what, so "send it to the people who missed it" wasn't possible.
Why not a sequence you keep adding to?
It's the obvious idea: one long sequence, with a new email appended for each feature. It doesn't work:
- Finished contacts never get the new step. Once someone has received the last email, their enrolment is complete. An email you add next week only reaches people still partway through.
- New contacts get the whole backlog. Someone who signs up today walks through every announcement since the sequence started, one after another.
A flow with one node per feature has the same problem.
The pattern: your app decides who gets what, Mailtani sends it
Only your application knows when each user signed up and which announcements they've already had. So the choice happens there, and Mailtani sends one simple email.
1. Keep a list of announcements in your code. vitelnk keeps it next to its changelog, so one edit covers both:
2. Run a weekly job that picks one announcement per user. For each user it takes the oldest announcement that:
- launched after the user signed up (older features aren't news to them; teach those in your onboarding instead),
- launched in the last 60 days (stale news isn't worth an email),
- hasn't been sent to them (check your record of sent emails),
and only if they haven't had one in the last week. Then it fires one event and records it:
If the event fails, don't record it, so next week's run tries again.
3. One flow with one email. Trigger whats_new.ready, a cooldown of 6 days, and an email built
from the properties:
Create the five custom fields before the first event (whatsNewId, whatsNewTitle,
whatsNewText, whatsNewCta, whatsNewUrl). Event properties are only stored when a field with
that exact name exists. If you forget one, Mailtani holds the email instead of sending the fallback,
and emails you (see the last section).
Why this works
- Each feature reaches each user once. The record of what was sent makes a repeat impossible.
- Anyone who missed one catches up. A user who wasn't reachable this week gets it next week, until it's 60 days old. With one email a week, a backlog goes out in launch order.
- New signups don't get stale news. Features from before they joined belong in onboarding.
- At most one email a week, and none when there's nothing new. No manual send to forget.
- Adding a feature is one entry in a list. No new campaign, no new copy in Mailtani.
Judge it on adoption of each announced feature among the people who got the email, not on opens.
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 your emails and branches reference actually exists. Event properties without a matching field are discarded. Mailtani now holds a send or branch that uses a field your app sends but the project doesn't have (or that differs from an existing field only by capitalisation), and emails you the first time. The flow page shows a Fix missing fields button that creates the field, restores the values from past events and sends the held emails. Held emails that aren't fixed go out with their fallback after 3 days. A field nobody sends simply uses its fallback.
- 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
