Hogsend
Building

Journeys

React to PostHog events with durable TypeScript flows — welcome sequences, trial nudges, churn recovery, and more.

Journeys are where Hogsend turns your PostHog events into action. A user signs up? Send a welcome sequence. They haven't used the core feature after 3 days? Nudge them. Their payment fails? Start a recovery flow. A Stripe subscription cancels? Trigger a win-back campaign.

Each journey is a durable TypeScript function — no drag-and-drop canvas, no YAML state machines. You write it like regular code, and Hatchet makes it durable. Your await ctx.sleep(days(3)) call literally pauses execution for three days and resumes exactly where it left off, surviving restarts and deploys.

Journeys are your content. You author them in your own scaffolded app (under src/journeys/), import the authoring helpers from @hogsend/engine, and register them in an array that you own. The engine never imports your journeys — you inject them into the engine's factories. See Engine vs content for the content-vs-framework boundary.

If you don't have an app yet, scaffold one with pnpm dlx create-hogsend@latest my-app and follow Getting Started. The scaffold ships an example welcome journey you can copy.

Quick example

// src/journeys/activation-welcome.ts
import { days, hours } from "@hogsend/core";
import { defineJourney, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "./constants/index.js";

export const activationWelcome = defineJourney({
  meta: {
    id: "activation-welcome",
    name: "Activation — Welcome Series",
    enabled: true,
    trigger: { event: Events.USER_CREATED },
    entryLimit: "once",
    suppress: hours(12),
    exitOn: [{ event: Events.USER_DELETED }],
  },

  run: async (user, ctx) => {
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.ACTIVATION_WELCOME,
      subject: "Welcome to Hogsend — let's get you set up",
      journeyName: user.journeyName,
    });

    await ctx.sleep({ duration: days(2), label: "post-welcome" });

    const { found: hasUsedFeature } = await ctx.history.hasEvent({
      userId: user.id,
      event: Events.FEATURE_USED,
    });

    if (hasUsedFeature) {
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.ACTIVATION_ADVANCED,
        subject: "Nice work — here's what to try next",
        journeyName: user.journeyName,
      });
    } else {
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.ACTIVATION_NUDGE,
        subject: "You haven't tried the key feature yet",
        journeyName: user.journeyName,
      });
    }
  },
});

No drag-and-drop canvas, no YAML state machine. Just TypeScript with if, await, and loops.

Everything content needs — defineJourney, sendEmail, the duration helpers — imports from @hogsend/engine (which re-exports @hogsend/core, so days/hours/minutes are available either way). You never reach into engine internals with relative paths.

defineJourney()

Every journey is created with defineJourney() from @hogsend/engine. It takes two things: metadata describing when and how the journey runs, and a run function containing the actual logic.

import { defineJourney } from "@hogsend/engine";

export const myJourney = defineJourney({
  meta: { /* JourneyMeta */ },
  run: async (user, ctx) => { /* your logic */ },
});

defineJourney() returns a DefinedJourney ({ meta, task }) where task is a Hatchet durable task named journey-${meta.id}, wired to onEvents: [meta.trigger.event] with a 720h execution timeout and retries: 0. You export this from your journey file and add it to the journeys array in your src/journeys/index.ts (see Registering a journey).

JourneyMeta

The meta object controls enrollment, triggering, and exit behavior.

interface JourneyMeta {
  id: string;
  name: string;
  description?: string;
  enabled: boolean;

  trigger: {
    event: string;
    where?: PropertyCondition[];
  };

  entryLimit: "once" | "once_per_period" | "unlimited";
  entryPeriod?: DurationObject;

  exitOn?: Array<{
    event: string;
    where?: PropertyCondition[];
  }>;

  suppress: DurationObject;
}

Fields

FieldTypeDescription
idstringUnique identifier. Used in the database, registry, and ENABLED_JOURNEYS filter.
namestringHuman-readable name for logs and observability.
descriptionstring?Optional longer description.
enabledbooleanSet to false to disable without removing code. Checked at runtime before enrollment.
trigger.eventstringThe event name that starts this journey. Hatchet routes matching events automatically.
trigger.wherePropertyCondition[]?Optional property conditions the event must satisfy. All conditions must pass (AND logic).
entryLimit"once" | "once_per_period" | "unlimited"Controls how many times a user can enter.
entryPeriodDurationObject?Required when entryLimit is "once_per_period". The cooldown window.
exitOnArray<{ event, where? }>?Events that immediately terminate the journey for a user. Evaluated by the ingestion pipeline on every incoming event.
suppressDurationObjectMinimum time between sends within this journey. Prevents email flooding.

Entry limits

  • "once" -- the user can only ever enter this journey one time, regardless of how many matching events fire.
  • "once_per_period" -- the user can re-enter after entryPeriod has elapsed since their last entry. Useful for recurring flows like churn recovery.
  • "unlimited" -- no restrictions. Every matching event creates a new journey run.
// User can re-enter churn recovery every 7 days
meta: {
  entryLimit: "once_per_period",
  entryPeriod: days(7),
  // ...
}

Trigger conditions

Add where to filter which events actually start the journey. trigger.where is a PropertyCondition[] — property checks only — and all conditions use AND logic.

trigger: {
  event: Events.SUBSCRIPTION_CANCELLED,
  where: [
    {
      type: "property",
      property: "plan",
      operator: "eq",
      value: "pro",
    },
  ],
}

See the Conditions guide for the full operator reference and the richer condition types available to ctx.history and exit rules.

A journey doesn't have to start from a raw product event. A Bucket emits bucket:entered:<id> when a user joins it and bucket:left:<id> when they leave, both through the same ingest pipeline. Each bucket exposes those as typed refswentDormant.entered / wentDormant.left — so trigger: { event: wentDormant.entered } starts a journey on a membership change, and exitOn: [{ event: wentDormant.left }] exits it when the user no longer qualifies. Import the bucket from its leaf module (../buckets/went-dormant.js) to read the ref. To bind to any bucket's joins instead of one, use the generic Events.BUCKET_ENTERED / Events.BUCKET_LEFT constants and filter on the bucketId property with trigger.where. (The old bucketEntered("id") / bucketLeft("id") string helpers are deprecated in favor of the typed refs.)

Exit conditions

exitOn lets you define events that should immediately end the journey for a user. The ingestion pipeline checks these on every incoming event against all active journeys for that user.

exitOn: [
  { event: Events.PAYMENT_SUCCEEDED },
  { event: Events.SUBSCRIPTION_CANCELLED },
  { event: Events.USER_DELETED },
],

You can also add where conditions to exit rules, so the journey only exits when a matching event has specific properties.

When a journey exits mid-flight — while it's paused inside a ctx.sleep() or ctx.waitForEvent() — Hogsend marks the run "exited" and cancels the durable Hatchet run, so no further steps (and no post-wait emails) fire. The journey stops the instant the exit event lands, even mid-wait.

The run function

The run function receives two arguments:

run: async (user: JourneyUser, ctx: JourneyContext) => {
  // your journey logic
}

JourneyUser

Contains the enrolled user's data, available throughout the journey.

interface JourneyUser {
  id: string;
  email: string;
  properties: Record<string, string | number | boolean | null>;
  stateId: string;
  journeyId: string;
  journeyName: string;
}

properties comes from the event payload that triggered the journey. stateId is the unique identifier for this particular journey run — pass it as journeyStateId to sendEmail() so clicks and opens resolve back to the journey.

JourneyContext

The context object provides durable execution primitives. It does not include service integrations like email or PostHog -- those are standalone imports from @hogsend/engine, keeping the context focused on orchestration. (See How it works for why.)

ctx.sleep()

Pause execution for a duration. This is a durable sleep backed by Hatchet -- the process can restart and the journey resumes exactly where it left off.

sleep(opts: {
  duration: DurationObject;
  label?: string;
}): Promise<{ sleptAt: string; resumedAt: string }>

While sleeping, the journey state is set to "waiting". When it resumes, it flips back to "active". The optional label is recorded as the currentNodeId in the database for observability.

await ctx.sleep({ duration: days(2), label: "post-welcome" });
await ctx.sleep({ duration: hours(4), label: "cooldown" });
await ctx.sleep({ duration: minutes(30), label: "short-wait" });

ctx.sleepUntil()

Durable sleep until an absolute instant instead of a relative duration. Pass a Date or ISO-8601 string; the journey resumes at that moment, surviving restarts just like ctx.sleep(). If the instant is already in the past, it resolves immediately.

sleepUntil(at: Date | string, opts?: {
  label?: string;
}): Promise<{ sleptAt: string; resumedAt: string }>

Same "waiting""active" lifecycle as ctx.sleep(). Reach for ctx.sleep() when you mean "wait N days", and ctx.sleepUntil() when you mean "wait until this date/time".

// Resume at a fixed deadline
await ctx.sleepUntil("2026-07-01T09:00:00Z", { label: "renewal-day" });

// Pair it with ctx.when (below) for timezone-aware scheduling
await ctx.sleepUntil(ctx.when.tomorrow().at("09:00"), { label: "morning-send" });

ctx.when

A timezone-bound fluent scheduler that turns human rules — "next Monday at 9am", "tomorrow at 08:00", "3 days from now" — into an absolute Date you hand to ctx.sleepUntil(). Every chain resolves to a Date; it's pure date math, so there's no await.

ctx.when.next(weekday).at("HH:mm")   // upcoming named weekday → Date
ctx.when.nextLocal("HH:mm")          // next HH:mm (today if still ahead, else tomorrow) → Date
ctx.when.tomorrow().at("HH:mm")      // tomorrow at HH:mm → Date
ctx.when.in(days(3)).at("HH:mm")     // 3 days out, at HH:mm that day → Date

// chainable refinements — each returns a new builder:
ctx.when.tz("Asia/Tokyo")            // override the resolved timezone
ctx.when.window("09:00", "17:00")    // override the send window for this chain
ctx.when.ifPast("now")               // "next" (default) rolls forward; "now" clamps to now

weekday accepts the short form ("mon""sun") or the full name ("monday""sunday").

// Next Monday at 9am, in the user's timezone
await ctx.sleepUntil(ctx.when.next("monday").at("09:00"), { label: "monday-9am" });

// Tomorrow morning, in a fixed timezone instead of the user's
await ctx.sleepUntil(ctx.when.tz("America/New_York").tomorrow().at("08:00"));

// Five days out, clamped into business hours
const at = ctx.when.in(days(5)).window("09:00", "17:00").at("14:00");
await ctx.sleepUntil(at, { label: "day-5-followup" });

Timezone resolution

ctx.when is bound to the user's timezone automatically — you rarely pass .tz(). It resolves the first valid candidate in this order (invalid IANA strings are skipped, not thrown):

  1. an explicit .tz() override
  2. PostHog person properties — $timezone, then $geoip_time_zone
  3. the contact's stored timezone (cached from PostHog)
  4. the contact's properties.timezone
  5. the client's defaults.timezone (set on createHogsendClient)
  6. "UTC" — the final fallback

Send windows (quiet hours)

If a default send window is configured on the client (or you set one with .window(start, end)), resolved instants are clamped into the window: a time landing outside it snaps forward to the next open slot. Windows are interpreted in the bound timezone and handle DST correctly — "09:00" is always 9am wall-clock — and an overnight window like .window("22:00", "06:00") wraps midnight. Clamping only applies to instants you schedule through ctx.when; an immediate sendEmail() is never delayed.

ctx.waitForEvent()

Pause the journey until this user emits a specific event — or a timeout elapses, whichever comes first. Where ctx.sleep() waits for a fixed amount of time, ctx.waitForEvent() waits for behavior: "after signup, wait up to 7 days for them to activate, otherwise send a nudge." Like ctx.sleep(), it's a durable wait backed by Hatchet — the worker can restart and the journey resumes the moment the event arrives.

waitForEvent(opts: {
  event: string;
  timeout: DurationObject;
  label?: string;
}): Promise<{ timedOut: boolean }>

While waiting, the journey state is "waiting"; it flips back to "active" on resume. The result tells you which side won: timedOut: false means the event fired, timedOut: true means the timeout elapsed first.

The wait is forward-looking — only events emitted after the wait begins count. Use ctx.history.hasEvent() to check whether something already happened. timeout is required and capped at the journey's execution limit (720h / 30 days).

If the journey exits (or is cancelled) while waiting, the run is aborted cleanly — no post-wait email fires. After a long wait, still re-check ctx.guard.isSubscribed() before sending, since an unsubscribe doesn't exit the journey.

// After signup, wait up to 7 days for activation. If it never comes, nudge.
run: async (user, ctx) => {
  const { timedOut } = await ctx.waitForEvent({
    event: Events.FEATURE_USED,
    timeout: days(7),
    label: "await-activation",
  });

  if (!timedOut) return; // they activated on their own — nothing to do

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

  await sendEmail({
    to: user.email,
    userId: user.id,
    journeyStateId: user.stateId,
    template: Templates.ACTIVATION_NUDGE,
    subject: "Still haven't tried the key feature?",
    journeyName: user.journeyName,
  });
};

This is the reactive complement to ctx.sleep() — combine them freely: sleep for a delay, then wait for the behavior you actually care about. Because the wait is scoped to the enrolled user and resumes through the same ingestion pipeline, any event your product (or a webhook source) emits can wake a journey.

ctx.checkpoint()

Update the currentNodeId in the journey state without sleeping. Useful for tracking progress through a journey.

checkpoint(label: string): Promise<void>
await ctx.checkpoint("branch:paid-path");
// ... continue execution

ctx.trigger()

Fire an event from within a journey. The event goes through the full ingestion pipeline, which means it can trigger other journeys, update contact records, and evaluate exit conditions.

trigger(opts: {
  event: string;
  userId: string;
  userEmail?: string;
  properties?: Record<string, unknown>;
}): Promise<void>
await ctx.trigger({
  event: Events.USER_SUPPRESSED,
  userId: user.id,
  properties: {
    reason: "dormancy_sequence_completed",
    suppressedAt: new Date().toISOString(),
  },
});

Fanning events out to PostHog (and other tools)

The journey context no longer has ctx.identify or ctx.posthog.capture — those PostHog-specific shims were removed. To send the lifecycle event stream to PostHog, Segment, Slack, a CRM, or a warehouse, configure an outbound destination: the catalog (contact.*, email.*, journey.completed, bucket.*) is fanned out durably on the webhook spine. For a custom journey signal you want elsewhere, fire it with ctx.trigger() (it joins the internal pipeline) and capture it where you detect it via your app's PostHog SDK.

ctx.guard

Mid-journey guard checks.

ctx.guard.isSubscribed()

Check if the user is still subscribed to emails. Returns false if the user has globally unsubscribed. Worth calling after a long sleep, before sending again.

const subscribed = await ctx.guard.isSubscribed();
if (!subscribed) return; // exit journey early

ctx.history

Query historical data to make decisions mid-journey.

ctx.history.hasEvent()

Check whether a specific event exists for a user, optionally within a time window.

hasEvent(opts: {
  userId: string;
  event: string;
  within?: DurationObject;
}): Promise<{ found: boolean; count: number }>
// Has the user used a feature at all?
const { found } = await ctx.history.hasEvent({
  userId: user.id,
  event: Events.FEATURE_USED,
});

// Has the user used a feature in the last 2 days?
const { found, count } = await ctx.history.hasEvent({
  userId: user.id,
  event: Events.FEATURE_USED,
  within: days(2),
});

ctx.history.journey()

Check whether a user has previously entered or completed a specific journey.

journey(opts: {
  userId: string;
  journeyId: string;
}): Promise<{
  completed: boolean;
  lastCompletedAt: string | null;
  entryCount: number;
}>
const { completed, entryCount } = await ctx.history.journey({
  userId: user.id,
  journeyId: "activation-welcome",
});

if (!completed) {
  // user never finished onboarding
}

ctx.history.email()

Check whether a specific email template has been sent to an address.

email(opts: {
  email: string;
  template: string;
}): Promise<{
  sent: boolean;
  lastSentAt: string | null;
  count: number;
}>
const { sent, count } = await ctx.history.email({
  email: user.email,
  template: Templates.ACTIVATION_WELCOME,
});

if (sent) {
  // skip duplicate send
}

Duration helpers

Hogsend provides three duration helper functions. They are re-exported from @hogsend/engine (and live in @hogsend/core), so a single import line covers both your journeys and the helpers. They return a DurationObject used by ctx.sleep(), entryPeriod, suppress, and ctx.history.hasEvent().

import { days, hours, minutes } from "@hogsend/engine";

days(3)      // { hours: 72 }
hours(12)    // { hours: 12 }
minutes(30)  // { minutes: 30 }

The DurationObject type:

interface DurationObject {
  readonly hours?: number;
  readonly minutes?: number;
  readonly seconds?: number;
}

Use these everywhere instead of magic strings or raw numbers:

suppress: hours(12),
entryPeriod: days(7),
await ctx.sleep({ duration: days(2) });
await ctx.history.hasEvent({ userId, event, within: days(3) });

Constants

Define event names and template keys as typed constants instead of magic strings. This gives you autocomplete, typo protection, and a single source of truth. These constants are yours — they live in your app at src/journeys/constants/ (the scaffold ships a starter index.ts). Add to them as you build.

// src/journeys/constants/index.ts
export const Events = {
  USER_CREATED: "user.created",
  USER_DELETED: "user.deleted",
  FEATURE_USED: "feature.used",
  TRIAL_STARTED: "trial.started",
  PAYMENT_FAILED: "payment.failed",
  PAYMENT_SUCCEEDED: "payment.succeeded",
  SUBSCRIPTION_CANCELLED: "subscription.cancelled",
  // ... more events your product emits
} as const;

export type EventName = (typeof Events)[keyof typeof Events];

export const Templates = {
  ACTIVATION_WELCOME: "activation/welcome",
  ACTIVATION_ADVANCED: "activation/advanced",
  ACTIVATION_NUDGE: "activation/nudge",
  CHURN_PAYMENT_FAILED: "churn-payment-failed",
  // ... more template keys (must exist in the email registry)
} as const;

export type TemplateName = (typeof Templates)[keyof typeof Templates];

Import both in your journey files:

import { Events, Templates } from "./constants/index.js";

Template keys must resolve to a template in the email registry — see the Email guide.

Enrollment guards

Before a journey's run function executes, Hogsend checks a series of guards in order. If any guard fails, the journey returns { status: "skipped", reason } without creating state.

OrderGuardReason on skip
1meta.enabled is true"journey_disabled"
2No admin override disabling this journey (journeyConfigs)"journey_disabled_by_admin"
3trigger.where conditions pass (if defined)"trigger_conditions_not_met"
4entryLimit allows entry"already_entered_once" or "period_not_elapsed"
5User has not globally unsubscribed"user_unsubscribed"
6No active/waiting run exists for this user + journey"already_active"

These guards are automatic -- you don't need to implement them in your run function.

Journey state lifecycle

Each journey run creates a row in the journeyStates table that tracks its progress:

start -> active -> waiting (sleep / waitForEvent) -> active (on resume) -> completed
                                                                        -> failed (on error)
                                                                        -> exited (exitOn / cancel)
  • active -- the run function is executing.
  • waiting -- paused inside a ctx.sleep(), ctx.sleepUntil(), or ctx.waitForEvent() call.
  • completed -- the run function returned successfully. A journey:completed event is fired.
  • failed -- the run function threw an error. A journey:failed event is fired and the error message is stored.
  • exited -- an exitOn event matched (or the run was cancelled). Any in-flight wait is cancelled and no further steps run.

The currentNodeId field (updated by ctx.checkpoint(), ctx.sleep(), and ctx.waitForEvent() labels) shows where the user currently is in the journey.

Enabling journeys at runtime

Which of your registered journeys actually load into the worker is controlled by the ENABLED_JOURNEYS environment variable. The engine indexes journeys with its JourneyRegistry and filters on this value:

# Enable specific journeys by ID
ENABLED_JOURNEYS=activation-welcome,churn-prevention

# Enable all journeys (default)
ENABLED_JOURNEYS=*

createHogsendClient({ journeys }) and createWorker({ container, journeys }) both honor this filter (or an explicit enabledJourneys option). You pass the same journeys array to both — see below.

Registering a journey

Registration is entirely in your own files — there is no shared engine index to edit. The flow is: define the journey, add it to your journeys array, and that array is what you pass to the engine factories.

1. Add constants

Add any new event names and template keys to src/journeys/constants/index.ts.

2. Create the journey file

// src/journeys/conversion-abandoned-checkout.ts
import { days, hours } from "@hogsend/core";
import { defineJourney, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "./constants/index.js";

export const conversionAbandonedCheckout = defineJourney({
  meta: {
    id: "conversion-abandoned-checkout",
    name: "Conversion — Abandoned Checkout",
    enabled: true,
    trigger: { event: Events.CHECKOUT_ABANDONED },
    entryLimit: "once_per_period",
    entryPeriod: days(7),
    suppress: hours(4),
    exitOn: [
      { event: Events.CHECKOUT_COMPLETED },
      { event: Events.USER_DELETED },
    ],
  },

  run: async (user, ctx) => {
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.CONVERSION_WINBACK_OFFER,
      subject: "You left something behind",
      journeyName: user.journeyName,
    });

    await ctx.sleep({ duration: days(1), label: "day-1-followup" });

    const { found } = await ctx.history.hasEvent({
      userId: user.id,
      event: Events.CHECKOUT_COMPLETED,
      within: days(1),
    });

    if (!found) {
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.CONVERSION_WINBACK_OFFER,
        subject: "Still interested? Here's 10% off",
        journeyName: user.journeyName,
        props: { discountPercent: 10 },
      });
    }
  },
});

3. Add it to your journeys array

Import it and add it to the exported journeys array in your src/journeys/index.ts:

// src/journeys/index.ts
import type { DefinedJourney } from "@hogsend/engine";
import { conversionAbandonedCheckout } from "./conversion-abandoned-checkout.js";
import { welcome } from "./welcome.js";

export const journeys: DefinedJourney[] = [
  welcome,
  conversionAbandonedCheckout,
];

4. (Already wired) the array flows into the engine

Your thin entry files pass the same journeys array into both factories — you did this once when the app was scaffolded and don't touch it per-journey:

// src/index.ts (HTTP)
import { createApp, createHogsendClient } from "@hogsend/engine";
import { journeys } from "./journeys/index.js";
import { webhookSources } from "./webhook-sources/index.js";

const container = createHogsendClient({ journeys });
const app = createApp(container, { webhookSources });
// src/worker.ts (task execution)
import { createHogsendClient, createWorker } from "@hogsend/engine";
import { journeys } from "./journeys/index.js";

const container = createHogsendClient({ journeys });
const worker = createWorker({ container, journeys });
await worker.start();

Once a journey is in the array (and enabled via ENABLED_JOURNEYS), it automatically receives matching events from Hatchet and appears in the registry. No engine code changes, ever.

Full example: churn prevention

Here is a complete journey that handles payment failure recovery with escalating urgency:

import { days, hours } from "@hogsend/core";
import { defineJourney, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "./constants/index.js";

export const churnPrevention = defineJourney({
  meta: {
    id: "churn-prevention",
    name: "Churn — Payment Recovery & Prevention",
    enabled: true,
    trigger: { event: Events.PAYMENT_FAILED },
    entryLimit: "once_per_period",
    entryPeriod: days(7),
    suppress: hours(4),
    exitOn: [
      { event: Events.PAYMENT_SUCCEEDED },
      { event: Events.SUBSCRIPTION_CANCELLED },
      { event: Events.USER_DELETED },
    ],
  },

  run: async (user, ctx) => {
    // Immediate: let them know
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.CHURN_PAYMENT_FAILED,
      subject: "Your payment didn't go through",
      journeyName: user.journeyName,
    });

    await ctx.sleep({ duration: days(1), label: "first-retry" });

    // Day 1: check if they fixed it
    const { found: hasRetried } = await ctx.history.hasEvent({
      userId: user.id,
      event: Events.PAYMENT_SUCCEEDED,
      within: days(1),
    });
    if (hasRetried) return;

    // Day 1: gentle reminder
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.CHURN_PAYMENT_FAILED,
      subject: "Reminder: please update your payment method",
      journeyName: user.journeyName,
      props: { gracePeriodDays: 2 },
    });

    await ctx.sleep({ duration: days(2), label: "final-notice" });

    // Day 3: final warning
    const { found: hasResolved } = await ctx.history.hasEvent({
      userId: user.id,
      event: Events.PAYMENT_SUCCEEDED,
      within: days(3),
    });
    if (!hasResolved) {
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.CHURN_PAYMENT_FAILED,
        subject: "Final notice: your account will be downgraded tomorrow",
        journeyName: user.journeyName,
        props: { gracePeriodDays: 1 },
      });
    }
  },
});

Key patterns to notice:

  • entryLimit: "once_per_period" with entryPeriod: days(7) prevents spamming users whose payments keep failing.
  • exitOn includes PAYMENT_SUCCEEDED so the journey stops immediately when the user fixes their payment, even mid-sleep.
  • Early returns with if (hasRetried) return; let you exit the journey when the goal is already met.
  • ctx.history.hasEvent() with within checks recent activity instead of all-time history.
  • props on sendEmail pass dynamic data to email templates.
  • journeyStateId: user.stateId on every send links clicks/opens back to this run.

On this page