How to do onboarding properly
The onboarding sequence runs between signup and a user's first real value. This guide covers the one event to aim at, why it reacts to behavior instead of the clock, what to ship first, and how it all reads as one TypeScript file.
Most signups never reach the point of the product
The average SaaS activation rate is about 37 percent. The drop is front-loaded into the first session, and weak onboarding is blamed for roughly 23 percent of churn — the largest leak in the funnel.
It is also the highest-leverage email you send — a welcome earns about four times the opens and eight times the revenue of a bulk message — so a little work returns a lot.
average SaaS activation rate — most signups never reach value
of trial users never return after the first session
of customer churn is blamed on weak onboarding
Onboard toward one activation event
Before any email, name the single event that means a user got value — a first project, message, or teammate invited. The sequence watches for it, and exits on it.
Round figures picked where each retention curve bends — a place to aim, not a law. Find your bend and make that event the target.
// src/journeys/constants/events.ts
export const Events = {
USER_CREATED: "user.created", // they signed up
SETUP_COMPLETED: "setup.completed", // they cleared the first step
USER_ACTIVATED: "user.activated", // they hit first value — the goal
USER_DELETED: "user.deleted",
} as const;React to the event, not the clock
A fixed schedule can't see the product, so a day-3 email treats the all-morning power user and the no-show identically. Behavior-triggered email waits for what the user did — ctx.waitForEvent parks the journey until the event lands or the timeout passes.
It resolves the instant the event arrives, so the branch is an ordinary if on the returned timedOut flag. The wait survives restarts and deploys, so a user three days in is never dropped.
// the wait resolves the instant the event arrivesconst { timedOut } = await ctx.waitForEvent({ event: Events.SETUP_COMPLETED, timeout: days(3),}) if (timedOut) sendNudge() // stalledelse sendNextStep() // movedThe whole sequence is one file
One file: enrol on the signup event, send the welcome, park on a durable wait for setup, branch on whether the user moved, and exit at activation. entryLimit, suppress, and exitOn handle re-signups, spacing between sends, and stopping.
From: ada@yourapp.com
Welcome — here's your first step
You signed up. The fastest path to a first win is one step away.
Sent to user@example.com · Unsubscribe
// park until setup, or 3 days — durablyconst { timedOut } = await ctx.waitForEvent({ event: Events.SETUP_COMPLETED, timeout: days(3),})From: ada@yourapp.com
You're set up — here's what's next
Nice work. Here's the next thing worth trying.
Sent to user@example.com · Unsubscribe
import { days, hours } from "@hogsend/core";
import { defineJourney, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "./constants/index.js";
export const onboarding = defineJourney({
meta: {
id: "onboarding",
name: "Onboarding",
enabled: true,
trigger: { event: Events.USER_CREATED }, // PostHog signup → enrol
entryLimit: "once", // a re-signup never restarts it
suppress: hours(12), // never two emails within 12h
exitOn: [{ event: Events.USER_ACTIVATED }, { event: Events.USER_DELETED }],
},
run: async (user, ctx) => {
// Welcome, immediately. One next step.
await sendEmail({
to: user.email,
userId: user.id,
journeyStateId: user.stateId,
template: Templates.ACTIVATION_WELCOME,
subject: "Welcome — here's your first step",
journeyName: user.journeyName,
});
// Park — durably — until they set up, or 3 days pass.
const { timedOut } = await ctx.waitForEvent({
event: Events.SETUP_COMPLETED,
timeout: days(3),
});
// Two people, two emails.
await sendEmail({
to: user.email,
userId: user.id,
journeyStateId: user.stateId,
template: timedOut
? Templates.ACTIVATION_NUDGE // stalled → specific help
: Templates.ACTIVATION_FEATURE_HIGHLIGHT, // moved → first win
subject: timedOut
? "Stuck on setup?"
: "You're set up — here's what's next",
journeyName: user.journeyName,
});
},
});The bare minimum is the first two stages — a welcome, then one nudge to whoever stalls. Ship just those; it already beats a timed drip, because it can see the product.
The same file, executing
The journey above, running as a trace — the signup enrols, the welcome sends, the durable wait parks until the setup event or the timeout, the branch picks the next email, and the result writes back to PostHog. The code on the left is the same file.
Build it in this order
Highest leverage first — get through the first three and you've captured most of the result. Each step names what to build and shows the artifact you're adding.
The welcome email
Your best-performing send — about 4x the opens and 8x the revenue of a bulk message. One CTA, from a person who reads replies.
From: ada@yourapp.com
Welcome — here's your first step
You signed up. The fastest path to a first win is one step away.
Sent to user@example.com · Unsubscribe
An activation event
The single moment a user got value — a first project, message, or teammate invited. The durable wait watches for it and exitOn removes the user when it fires. Instrument it in PostHog as a typed constant so every branch reads a stable name.
check// has this user already activated?const { found } = await ctx.history.hasEvent({ userId: user.id, event: Events.USER_ACTIVATED, within: days(3),})One triggered nudge
Fires only for people who stalled. A single behavioral nudge beats another batch — triggered email is about 2 percent of volume but a third of revenue.
From: ada@yourapp.com
Stuck on setup?
You haven't cleared the first step yet — here's the 2-minute version.
Sent to user@example.com · Unsubscribe
The branch
Plain TypeScript — an if/else on the timedOut flag. Stalled users get ACTIVATION_NUDGE; moving users get ACTIVATION_FEATURE_HIGHLIGHT. One file, two emails from the same wait. Sending different emails to different people roughly doubles clicks.
template: timedOut ? ACTIVATION_NUDGE : ACTIVATION_FEATURE_HIGHLIGHTTiming and a stop
ctx.when sends at a civil hour in their timezone; suppress floors the gap between sends; exitOn stops the instant they activate. Then measure activation, not opens.
- PostHog identify
- suppress floor
- civil-hour window
- exitOn at activation
The same wait, two outcomes
One journey, two experiences from the same parked wait. Stalled users get ACTIVATION_NUDGE with one concrete next step; moving users get ACTIVATION_FEATURE_HIGHLIGHT pointing at the next win, not a repeat of the first.
The timeout elapsed
timedOut is true, so the journey sends ACTIVATION_NUDGE — one concrete next step and the single CTA that unblocks them.
From: ada@yourapp.com
Stuck on setup?
You haven't cleared the one blocking step yet — here's the 2-minute version.
Sent to user@example.com · Unsubscribe
Send at a civil hour, then stop
Frequency doesn't by itself raise unsubscribes — irrelevance does. Send more, but only to people still moving. Gmail and Yahoo now require a complaint rate under about 0.3 percent, so blasting quiet users is a deliverability liability. The three controls below make stopping the default.
ctx.whenResolves the send time at a civil hour in the user own timezone inside a window you set, so nothing lands at 3am.
suppress: hours(12)A hard floor under the gap between any two sends, so a burst of activity cannot fire two emails in an hour.
exitOnRemoves a user the instant they activate, even mid-wait, so the journey never congratulates someone for what it was about to nudge them about.
// civil hour in the user own timezoneawait ctx.sleepUntil(ctx.when.tomorrow().at("09:00")) // floor between any two sends, in the journey metasuppress: hours(12),Four ways it goes wrong
The failure modes the mechanics above are built to prevent.
A timed drip
A day-3 email to everyone reaches someone active all morning with a generic check-in. Trigger on the event instead.
A feature tour
Six links produces near-zero clicks — emails with three or more CTAs convert worse. One email, one job.
Never stopping
Blasting non-activators forever burns your sending domain against the under-0.3-percent complaint rule. exitOn and a finite sequence end it at activation.
A no-reply sender
A no-reply@ address kills the reply signals providers read as engagement and hurts deliverability. Send from a person who reads replies.
Build it in your repo
The journey on this page is a real Hogsend file. Scaffold a project, define your activation event in PostHog, and the welcome-then-branch minimum runs against your own data.