Hogsend is brand new.Chat to Doug
Guide

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.

Why it matters

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.

Signed up100%Set up62%Activated37%
37%

average SaaS activation rate — most signups never reach value

40–60%

of trial users never return after the first session

23%

of customer churn is blamed on weak onboarding

Start here

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.

Slack2,000 messages → ~93% retained
Facebook7 friends in 10 days
Twitter~30 follows

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
// 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;
The core mechanic

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.

user.createdenrolsetup.completedwithin 3 days?timedOut: truetimedOut: falseACTIVATION_NUDGEACTIVATION_FEATURE_HIGHLIGHT
branch
// the wait resolves the instant the event arrivesconst { timedOut } = await ctx.waitForEvent({  event: Events.SETUP_COMPLETED,  timeout: days(3),}) if (timedOut) sendNudge()      // stalledelse          sendNextStep()  // moved
The build

The 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

wait + branch
// 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

src/journeys/onboarding.ts
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.

In motion

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.

src/journeys/onboarding.ts
export const onboarding = defineJourney({
meta: { trigger: { event: Events.USER_SIGNED_UP } },
run: async (user, ctx) => {
await sendEmail({ template: "quickstart" });
const { timedOut } = await ctx.waitForEvent({
event: Events.PROJECT_CREATED,
timeout: days(3),
});
await sendEmail({
template: timedOut
? "activation-nudge"
: "feature-highlight",
});
getPostHog()?.identify(user.id, { activated: true });
},
});
The run
eventuser.signed_up · doug@hogsend.comenrolled
send
Welcome — your shortest path to a first windeliveredopened
emit
PostHog
wait
project.created · timeout 3dwaiting.
arrived · 41h
send
Nice — here's what to try nextdeliveredopenedclicked
identify
PostHog
What to build first

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.

  1. 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

  2. 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),})
  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

  4. 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_HIGHLIGHT
  5. Timing 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
Two people, two emails

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.

ctx.waitForEventtimeout: days(3)

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

Timing and restraint

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.when

Resolves 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.

exitOn

Removes a user the instant they activate, even mid-wait, so the journey never congratulates someone for what it was about to nudge them about.

timing
// 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),
What to avoid

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.

Start

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.