Hogsend is brand new.Chat to Doug
Recipes — Onboarding & activation

Onboarding & activation

Get new signups to the first moment of value — and react to whether they got there.

Onboarding recipes turn a signup into an activated user. Each one reacts to what the person actually does: it sends the next message when they hit a milestone, and nudges only the ones who stall.

Every recipe below is the working code — copy it straight in, or open the full write-up for the wiring and the reasoning.

4 recipes

The recipes

Welcome series

Greet on signup, resume the instant they activate, and nudge only the ones who don't.

waitForEvent is the activation detector: timedOut: false is the tips path, timedOut: true is the nudge path — a plain if statement.

Full write-up
src/journeys/welcome-series.ts
export const welcomeSeries = defineJourney({
  meta: {
    id: "welcome-series",
    name: "Onboarding — welcome series",
    enabled: true,
    trigger: { event: Events.USER_SIGNED_UP },
    entryLimit: "once",
    suppress: hours(12),
    exitOn: [{ event: Events.USER_DELETED }],
  },

  run: async (user, ctx) => {
    // Day 0 — welcome
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ONBOARDING_WELCOME,
      subject: "Welcome — here's how to get set up",
      journeyName: user.journeyName,
    });

    // Park on the first key action — resumes the instant it fires.
    const activated = await ctx.waitForEvent({
      event: Events.PROJECT_CREATED,
      timeout: days(3),
      lookback: minutes(30),
    });

    if (!(await ctx.guard.isSubscribed())) return;

    if (!activated.timedOut) {
      // They activated — deepen instead of nudging.
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.ONBOARDING_TIPS,
        subject: "Your first project is live — three things to try next",
        journeyName: user.journeyName,
      });
      return;
    }

    // Three days, no project.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ONBOARDING_NUDGE,
      subject: "Your workspace is still empty",
      journeyName: user.journeyName,
    });

    // Give the nudge two days to work before the last touch.
    const second = await ctx.waitForEvent({
      event: Events.PROJECT_CREATED,
      timeout: days(2),
    });
    if (!second.timedOut) return; // the nudge worked — end quietly

    // Final send: a day later, clamped into business hours, their timezone.
    await ctx.sleepUntil(
      ctx.when.window("09:00", "17:00").in(days(1)).at("10:00"),
    );
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ONBOARDING_RESOURCES,
      subject: "Docs, examples, and a ten-minute setup guide",
      journeyName: user.journeyName,
    });
  },
});

Activation milestones

Walk setup step by step and nudge only the milestone the user is actually stuck on.

Adding a fourth milestone is one array entry plus its template — the loop, the guards, and the observability come along for free.

Full write-up
src/journeys/activation-milestones.ts
const STEPS = [
  {
    id: "project",
    event: Events.PROJECT_CREATED,
    template: Templates.ONBOARDING_STEP_PROJECT,
    subject: "First step: create a project",
  },
  {
    id: "data",
    event: Events.DATA_CONNECTED,
    template: Templates.ONBOARDING_STEP_DATA,
    subject: "Your project is empty — connect a data source",
  },
  {
    id: "team",
    event: Events.TEAM_INVITED,
    template: Templates.ONBOARDING_STEP_TEAM,
    subject: "Working alone? Invite your team",
  },
] as const;

export const activationMilestones = defineJourney({
  meta: {
    id: "activation-milestones",
    name: "Onboarding — activation milestones",
    enabled: true,
    trigger: {
      event: Events.USER_SIGNED_UP,
      // invited teammates aren't responsible for workspace setup
      where: (b) => b.prop("role").eq("owner"),
    },
    entryLimit: "once",
    suppress: hours(24),
    exitOn: [
      { event: Events.ONBOARDING_COMPLETED },
      { event: Events.USER_DELETED },
    ],
  },

  run: async (user, ctx) => {
    for (const step of STEPS) {
      // Done out of order? Skip ahead — waits are forward-looking.
      const done = await ctx.history.hasEvent({
        userId: user.id,
        event: step.event,
      });
      if (done.found) continue;

      // Visible as currentNodeId in journey state: exactly which step.
      await ctx.checkpoint(`milestone:${step.id}`);

      const reached = await ctx.waitForEvent({
        event: step.event,
        timeout: days(2),
        label: `await-${step.id}`,
      });
      if (!reached.timedOut) continue; // on to the next milestone

      // Stalled on THIS step — nudge it specifically, nothing else.
      if (!(await ctx.guard.isSubscribed())) return;
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: step.template,
        subject: step.subject,
        journeyName: user.journeyName,
      });

      const nudged = await ctx.waitForEvent({
        event: step.event,
        timeout: days(3),
        label: `await-${step.id}-nudged`,
        lookback: minutes(30),
      });
      if (nudged.timedOut) return; // still stalled — stop, don't pile on
    }
    // All milestones reached — the run completes. exitOn already covers
    // onboarding.completed landing mid-wait.
  },
});

Waitlist to launch

Code-defined membership, an idempotent launch broadcast, and a chase journey for non-activators.

Join, confirm, broadcast, grant — four calls against one contact record. The events carry the userId the chase journey will wait on.

Full write-up
list, form handler, launch script
// src/lists/index.ts — membership is code-defined
export const waitlist = defineList({
  id: "waitlist",
  name: "Waitlist",
  defaultOptIn: false, // opt-in: only an explicit join counts
});

// your form handler — contact + membership + confirmation
await hs.contacts.upsert({
  email: form.email,
  properties: { company: form.company, source: form.source },
  lists: { waitlist: true },
});
await hs.emails.send({
  to: form.email,
  template: "waitlist/confirmation",
  props: { position: queuePosition },
});

// launch day — one idempotent broadcast to every subscribed member
const { campaignId } = await hs.campaigns.send({
  list: "waitlist",
  template: "waitlist/launch",
  props: { inviteUrl: "https://app.example.com/claim" },
  subject: "You're in — claim your account",
  idempotencyKey: "waitlist-launch-v1",
});

// as you grant access, one event per member starts the chase journey
await hs.events.send({
  name: "launch.access_granted",
  email: member.email,
  userId: member.userId,
  eventProperties: { invite_url: member.inviteUrl },
  idempotencyKey: `access-granted-${member.userId}`,
});

Verification chase

Send the verify-email transactionally, then chase it with two re-sends that stop the instant the token is redeemed.

The success branch is a bare return, so waiting on the same event exitOn covers is safe: either path ends the run with zero further sends.

Full write-up
src/journeys/verification-chase.ts
export const verificationChase = defineJourney({
  meta: {
    id: "verification-chase",
    name: "Onboarding — verification chase",
    enabled: true,
    trigger: { event: Events.USER_SIGNED_UP },
    entryLimit: "once",
    suppress: hours(12),
    exitOn: [
      { event: Events.EMAIL_VERIFIED },
      { event: Events.USER_DELETED },
    ],
  },

  run: async (user, ctx) => {
    const verifyUrl = String(user.properties.verify_url ?? "");
    const firstName = String(user.properties.first_name ?? "");

    // The signup handler already sent the first verify-email. The lookback
    // catches a user who verified while this run was enrolling.
    const first = await ctx.waitForEvent({
      event: Events.EMAIL_VERIFIED,
      timeout: hours(24),
      lookback: minutes(30),
    });
    if (!first.timedOut) return; // verified — done

    // Re-send 1, after a day.
    if (!(await ctx.guard.isSubscribed())) return;
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.TRANSACTIONAL_VERIFY_EMAIL,
      subject: "Reminder: verify your email address",
      journeyName: user.journeyName,
      props: { firstName, verifyUrl },
    });

    const second = await ctx.waitForEvent({
      event: Events.EMAIL_VERIFIED,
      timeout: days(2),
    });
    if (!second.timedOut) return;

    // Re-send 2 — the last one.
    if (!(await ctx.guard.isSubscribed())) return;
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.TRANSACTIONAL_VERIFY_EMAIL,
      subject: "Last reminder — your account isn't active yet",
      journeyName: user.journeyName,
      props: { firstName, verifyUrl },
    });
  },
});

Copy a recipe into your app

Paste any recipe straight into your codebase, or scaffold a fresh app with create-hogsend and build from there.

Free to self-host · One scaffold command · No per-contact billing

terminal
pnpm dlx create-hogsend@latest my-app