Hogsend is brand new.Chat to Doug
Recipes — Timing & scheduling

Timing & scheduling

Send at the right moment in each person's own timezone — reminders, anniversaries, and time-of-day windows.

Scheduling recipes land each send at the right local moment: a reminder before an event, an anniversary note, a morning-only send. The timing is computed per person from their timezone, not from a single server clock.

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

2 recipes

The recipes

Timezone-aware scheduling

Turn 'next Tuesday at 9am, their time' into a Date and sleep durably until it.

Two sends, both landing at a deliberate local moment — tomorrow 08:30 wall-clock, then next Tuesday inside business hours.

Full write-up
src/journeys/first-week-schedule.ts
export const firstWeekSchedule = defineJourney({
  meta: {
    id: "first-week-schedule",
    name: "Scheduling — first-week touchpoints",
    enabled: true,
    trigger: { event: Events.TRIAL_STARTED },
    entryLimit: "once",
    suppress: hours(12),
    exitOn: [{ event: Events.SUBSCRIPTION_CREATED }],
  },

  run: async (user, ctx) => {
    // Tomorrow at 08:30 wall-clock in the user's own timezone. The chain
    // returns a plain Date; sleepUntil does the durable waiting.
    await ctx.sleepUntil(ctx.when.tomorrow().at("08:30"), {
      label: "day-1-morning",
    });
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ONBOARDING_DAY_ONE,
      subject: "Day one: the three things worth doing first",
      journeyName: user.journeyName,
    });

    // Next Tuesday at 09:00, clamped into business hours for this chain:
    // an instant outside 09:00–17:00 snaps forward to the next open slot.
    const tuesday = ctx.when.window("09:00", "17:00").next("tue").at("09:00");
    await ctx.sleepUntil(tuesday, { label: "tuesday-tips" });
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ONBOARDING_WEEKLY_TIPS,
      subject: "Three workflows other teams ship in week one",
      journeyName: user.journeyName,
    });
  },
});

Event reminder sequence

T-24h and T-1h reminders computed from the event's own start time, then replay vs thanks.

Reminder instants are plain Date math on the trigger event's start_time; exitOn webinar.cancelled kills the run at any point, even mid-sleep.

Full write-up
src/journeys/event-reminder-sequence.ts
const HOUR = 60 * 60 * 1000;

export const eventReminderSequence = defineJourney({
  meta: {
    id: "event-reminder-sequence",
    name: "Webinar — reminder sequence",
    enabled: true,
    trigger: {
      event: Events.WEBINAR_REGISTERED,
      // a registration without a start time can't be scheduled against
      where: (b) => b.prop("start_time").exists(),
    },
    entryLimit: "unlimited",
    suppress: hours(1),
    exitOn: [{ event: Events.WEBINAR_CANCELLED }],
  },

  run: async (user, ctx) => {
    const title = String(user.properties.title ?? "the session");
    const startsAt = new Date(String(user.properties.start_time ?? ""));
    if (Number.isNaN(startsAt.getTime())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.WEBINAR_CONFIRMATION,
      subject: "You're registered",
      journeyName: user.journeyName,
      props: { title },
    });

    // T-24h. sleepUntil resolves IMMEDIATELY for past instants, so guard
    // each reminder — a late registration takes only the touches ahead.
    if (Date.now() < startsAt.getTime() - 24 * HOUR) {
      await ctx.sleepUntil(new Date(startsAt.getTime() - 24 * HOUR));
      if (!(await ctx.guard.isSubscribed())) return;
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.WEBINAR_REMINDER,
        subject: "Starts tomorrow",
        journeyName: user.journeyName,
        props: { title, hoursToGo: 24 },
      });
    }

    // T-1h — same guard, same template, different props.
    if (Date.now() < startsAt.getTime() - 1 * HOUR) {
      await ctx.sleepUntil(new Date(startsAt.getTime() - 1 * HOUR));
      if (!(await ctx.guard.isSubscribed())) return;
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.WEBINAR_REMINDER,
        subject: "Starting in an hour",
        journeyName: user.journeyName,
        props: { title, hoursToGo: 1 },
      });
    }

    // Did they show up? Resolves the instant webinar.joined lands.
    const joined = await ctx.waitForEvent({
      event: Events.WEBINAR_JOINED,
      timeout: hours(3),
      lookback: minutes(30),
    });

    // Hold the follow-up until the session is over either way.
    await ctx.sleepUntil(new Date(startsAt.getTime() + 2 * HOUR));
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: joined.timedOut
        ? Templates.WEBINAR_REPLAY
        : Templates.WEBINAR_THANKS,
      subject: joined.timedOut
        ? "Sorry we missed you — here's the replay"
        : "Thanks for joining",
      journeyName: user.journeyName,
      props: { title },
    });
  },
});

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