Hogsend is brand new.Try it
Hogsend
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.

8 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 },
    });
  },
});

Welcome new Discord members

On a Discord join, wait for the member to link an email — then welcome them, or nudge the unlinked in-channel.

waitForEvent on the link event is the gate: linked → email the welcome; timed out → ctx.trigger an in-Discord nudge, because there is still no address to send to.

Full write-up
src/journeys/welcome-new-discord-members.ts
export const welcomeNewDiscordMembers = defineJourney({
  meta: {
    id: "welcome-new-discord-members",
    name: "Onboarding — welcome new Discord members",
    enabled: true,
    // The join event the Discord connector emits on GUILD_MEMBER_ADD.
    trigger: { event: Events.DISCORD_MEMBER_JOINED },
    entryLimit: "once",
    suppress: hours(12),
    exitOn: [{ event: Events.DISCORD_MEMBER_LEFT }],
  },

  run: async (user, ctx) => {
    // A fresh join has a discord_id but usually no linked email yet. Park on
    // the link event the /link email-confirm flow fires when it resolves the
    // contact; lookback covers a member who linked seconds before this wait.
    const linked = await ctx.waitForEvent({
      event: Events.CONTACT_LINKED,
      timeout: days(2),
      lookback: minutes(30),
    });

    if (linked.timedOut) {
      // Two days, still no email: nudge IN Discord via the destination, since
      // there is no address to email. The destination posts to the channel.
      await ctx.trigger({
        event: Events.DISCORD_NUDGE_LINK,
        userId: user.id,
        properties: { reason: "unlinked-2d" },
      });
      return;
    }

    // They linked — user.email is now an address we can send to.
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.DISCORD_WELCOME,
      subject: "Welcome to the community — here's where to start",
      journeyName: user.journeyName,
    });
  },
});

Welcome new Telegram members

Reply on a bare /start with a welcome, and echo every inbound message — an inbound Telegram event becomes an outbound reply from a journey.

telegram.started fires on a bare /start (a /start <token> emits telegram.linked instead). The onboarding welcome replies on the chatId the trigger event carried — no email, no wait.

Full write-up
src/journeys/telegram-onboarding.ts
// src/journeys/telegram-onboarding.ts — fires on a bare /start.
// A /start <token> from a minted deep link emits telegram.linked instead and
// never reaches here. The chat id to reply to rides on the trigger event.
import { hours } from "@hogsend/core";
import { defineJourney, sendConnectorAction } from "@hogsend/engine";
import { TelegramEvents } from "@hogsend/plugin-telegram";

export const telegramOnboarding = defineJourney({
  meta: {
    id: "telegram-onboarding",
    name: "Telegram — Onboarding (/start)",
    enabled: true,
    trigger: { event: TelegramEvents.STARTED }, // "telegram.started"
    entryLimit: "unlimited",
    suppress: hours(0),
  },

  run: async (user, _ctx) => {
    const chatId = user.properties.chatId ? String(user.properties.chatId) : null;
    if (!chatId) return;

    await sendConnectorAction({
      connectorId: "telegram",
      action: "sendMessage",
      args: {
        chatId,
        text:
          "👋 Welcome to Hogsend.\n\n" +
          "This bot is wired into a TypeScript lifecycle engine — send any " +
          "message and a journey replies in real time.",
      },
    });
  },
});

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