Hogsend is brand new.Try it
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({ duration: 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 definitions, types, duration helpers, and effects from the environment-free @hogsend/engine/journeys entry point, and register them in an array that you own. App and worker bootstrap files use the main @hogsend/engine runtime entry. The engine never imports your journeys — you inject them into its 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, defineJourney, hours, sendEmail } from "@hogsend/engine/journeys";
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 a testable journey module needs — defineJourney, its types, sendEmail, and the duration helpers — imports from @hogsend/engine/journeys. This subpath does not install the production Hatchet task runtime, so @hogsend/testing can load and run the original function without production credentials. Reserve the main @hogsend/engine entry for app and worker bootstrap code. You never reach into engine internals with relative paths.

defineJourney()

Every journey is created with defineJourney() from @hogsend/engine/journeys. 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/journeys";

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

defineJourney() returns a DefinedJourney with meta, the original run function, and a production task. The Hatchet task is named journey-${meta.id}, wired to onEvents: [meta.trigger.event] with a 720h execution timeout and retries: 0; the authoring subpath leaves it dormant until the production runtime is loaded. 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[] | ((b) => PropertyCondition | PropertyCondition[]);
  };

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

  exitOn?: Array<{
    event: string;
    where?: PropertyCondition[] | ((b) => PropertyCondition | PropertyCondition[]);
  }>;

  category?: string;
  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.
categorystring?Email-preference category stamped on this journey's sendEmail sends, overriding the template's own category (exactly as the built-in journey default does). Boot-validated. Defaults to journey. See Email category.
suppressDurationObjectMinimum time between sends within this journey, enforced at send time. A send inside the gap is skipped (journey_suppressed) — no provider call, no email_sends row. Measured per recipient across all enrollments of the journey, so a re-enrollment inside the window is still gapped. days(0) disables it.

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.

Email category

By default, every email a journey sends via sendEmail() is stamped with the built-in journey category. Set meta.category to gate the whole journey's sends on a specific list instead — it overrides the template's own category at every send site, exactly as the journey default already does. This lets you route, say, a re-engagement series through your product-updates opt-in so a recipient who opted out never receives it.

meta: {
  id: "product-newsletter-drip",
  // ...
  category: "product-updates",   // gate every send on this opt-in list
}

category is boot-validated fail-closed against the email-list namespace, alongside every template's category:

  • A defined topic list, or a reserved built-in (transactional / journey) → OK.
  • An unknown category (typo, stale id) → throws at boot — an unknown category would silently default to opt-in and un-gate suppression.
  • A channel preference list (in_app, a connector channel) → throws — a channel gates a delivery transport, not an email topic.
  • A defined opt-in list excluded via ENABLED_LISTSthrows (excluding it would ship the opt-in email to people who never subscribed); an excluded opt-out list → warns.

Omit category and the journey keeps the journey default.

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. Journey effects such as email, SMS, feed items, and connector actions are standalone imports from @hogsend/engine/journeys, keeping the context focused on orchestration. Runtime-only services such as the raw getPostHog() escape hatch remain on the main @hogsend/engine entry and need a mock or adapter in zero-infrastructure tests. (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.window("09:00", "17:00").in(days(5)).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;
  lookback?: DurationObject;
}): Promise<{
  timedOut: boolean;
  properties?: Record<string, string | number | boolean | null>;
}>

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. When the event fired, properties carries the matched event's payload (best-effort, scalars only) — so a journey can branch on what the user answered, not just that they did. An in-email semantic link answer (an NPS score, a yes/no) arrives here directly:

const answer = await ctx.waitForEvent({
  event: Events.NPS_SUBMITTED,
  timeout: days(3),
});
if (!answer.timedOut && typeof answer.properties?.score === "number") {
  // branch on the score
}

The wait is forward-looking — only events emitted after the wait begins count. Use ctx.history.hasEvent() to check whether something already happened, or pass lookback: a short window of recent user_events checked before the durable wait is established. A hit resolves immediately, payload included. This closes the gap where an event lands between two back-to-back waits (or between a send and its wait) — without it, a first-answer-deduped event in that gap would be missed for good. Keep the window tight: just the gap it covers (e.g. hours(1) on the second of two waits). timeout is required and capped at the journey's execution limit (720h / 30 days).

One rule when reacting to an event: don't also list it in exitOn — an exit match mid-wait aborts the run before your post-wait branch executes. One event name, one role.

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.

Waiting on a group, not a person

Pass group to scope the wait to any member of the enrolled user's group — "park until anyone at this company upgrades":

const gate = await ctx.waitForEvent({
  event: Events.PLAN_UPGRADED,
  timeout: days(2),
  group: "company",
});

The engine resolves which company once, replay-stably (explicit key → the trigger event's groups association → the user's sole live membership; anything ambiguous throws instead of guessing), and the result's actorUserId names which member's event resolved the wait. One event resumes every member's parked run — keep meta.suppress in mind if the post-wait step sends. Full model: the Groups guide.

ctx.digest()

Aggregate many trigger events over a fixed window into one execution. The first event enrolls the journey; every event of the same name that arrives while the window is open is absorbed by the active-enrollment guard — spawning no new run — and collected when the window flushes. A user firing an event 40 times in a week gets one email, not forty.

digest(opts: {
  window: DurationObject;    // aggregation window, measured from this call. Max 720h.
  event?: string;            // event to collect. Defaults to the journey's trigger event.
  where?: PropertyCondition[] | ((b) => PropertyCondition | PropertyCondition[]);
  maxEvents?: number;        // events returned AND recorded. Default 100, ceiling 500.
  lookback?: DurationObject; // widen the scan back to catch the enrolling event. Default 15m.
  label?: string;            // node id for this digest. Default `digest:<event>`.
}): Promise<{
  events: Array<{
    properties: Record<string, string | number | boolean | null> | null;
    occurredAt: string;      // ISO-8601, recorded — replay-stable
  }>;
  count: number;
  truncated: boolean;        // true when more than `maxEvents` matched
  flushedAt: string;         // ISO-8601 instant the window closed
}>

When event is (or defaults to) the journey's trigger event and where is omitted, the journey's trigger.where is applied automatically — the digest honors the same trigger contract that gates enrollment.

The "batch" recipe. ctx.digest() only collects and dedups the window; grouping is plain TypeScript. Use Object.groupBy() over digest.events to turn a flat window into a grouped email — there's no batch primitive, because you don't need one:

run: async (user, ctx) => {
  // One rolling week of activity → one execution.
  const digest = await ctx.digest({
    window: days(7),
    event: Events.FEATURE_USED,
    label: "weekly-activity",
  });

  // A 7-day window is a long wait, and unsubscribe doesn't exit the journey.
  if (!(await ctx.guard.isSubscribed())) return;

  // "Batch" = Object.groupBy over the digested events.
  const byFeature = Object.groupBy(
    digest.events,
    (e) => String(e.properties?.feature ?? "other"),
  );
  const stats = Object.entries(byFeature).map(([feature, events]) => ({
    label: feature,
    value: String(events?.length ?? 0),
  }));

  await sendEmail({
    to: user.email,
    userId: user.id,
    journeyStateId: user.stateId,
    template: Templates.RETENTION_WEEKLY_DIGEST,
    subject: "Your week in review",
    journeyName: user.journeyName,
    props: { periodLabel: "Last 7 days", stats },
  });
};

Entry limit picks the cadence. entryLimit: "unlimited" gives a rolling digest — each window re-enrolls from the next event after it flushes, so the user gets a fresh digest every active period. entryLimit: "once" digests exactly one window ever; events after the first enrollment are dropped, not digested. Pair a rolling digest with suppress: days(0) — the digest already collapses the sends, and a suppress ≥ the window would gap out each new window's email.

Replay-safe. The flush scan runs once and the result is recorded in the state row; a replay-from-top returns the verbatim-same set instead of rescanning — deterministic on any engine.

Never tier-gated. The window has no plan ceiling other than the journey execution limit (720h / 30 days). A digest window is a first-class primitive, not a paid add-on.

Straggler band. An event landing between the flush scan and the journey completing is absorbed by the enrollment guard but not included in that digest — it counts toward the next window instead. This is an accepted caveat, matching Novu's digest semantics.

ctx.throttle()

An advisory frequency check the journey branches on: "has this user already received limit emails this window? then skip the nudge." It counts the user's non-failed email_sends within the window by recipient email — the same count the client-level frequency cap enforces on — and passes while that count is below limit.

throttle(opts: {
  limit: number;      // pass while the windowed send count is < limit. >= 1.
  window: DurationObject;
  category?: string;  // count only this category. No exemptions — "transactional" counts.
  label?: string;     // disambiguates the recorded verdict site.
}): Promise<{
  allowed: boolean;
  count: number;      // sends in the window at first check
  remaining: number;
}>
const { allowed } = await ctx.throttle({ limit: 3, window: days(7) });
if (!allowed) return; // already got 3 this week — skip the nudge

await sendEmail({ /* ... */ });

Advisory, not enforcement. ctx.throttle() is a branch you write; the client-level frequencyCap config (see the email guide) is the hard send-time backstop. The two can legitimately disagree across a long wait — the throttle verdict is frozen at first check, the cap re-counts at send. There is no reservation, either: two concurrent journeys each read the same count and can overshoot an advisory limit. Reach for frequencyCap when you need a ceiling that can't be crossed.

Replay-safe. The verdict is recorded once per site and replayed verbatim, so a replay-from-top branches identically — even though the run's own sends have since landed in the window. (A live re-count on replay would diverge: allowed → send → crash → replay re-counts → now blocked → different branch — the exact divergence the replay-safety model forbids.)

Counting things that aren't sends. ctx.throttle() counts email sends only. To rate-limit anything else — a push, a Slack message, a specific journey action — use the named-counter recipe: fire a marker with ctx.trigger() and count it with ctx.history.hasEvent().

const { count } = await ctx.history.hasEvent({
  userId: user.id,
  event: "nudge.sent",
  within: days(7),
});
if (count >= 3) return;

// ... do the thing, then record that it happened
await ctx.trigger({ event: "nudge.sent", userId: user.id });

Reserved context state

ctx.digest(), ctx.throttle(), ctx.once(), and ctx.variant() persist their recorded results under reserved keys in the journey's context bag — __digest__, __throttle__, __once__, and __variants__. Don't write those keys yourself — the engine also strips all four from incoming trigger-event properties before seeding a journey's context, so an event can never pre-fill them. Digest and throttle also reserve label prefixes on the per-run node-id set: digest: (the default digest:<event> node id) and throttle:. If you set your own label, keep it clear of those prefixes, and give each ctx.digest() / ctx.throttle() call a distinct label when a run has more than one — reusing a label throws a loud collision error rather than silently over-collapsing two records.

ctx.variant()

Deterministic experiment arm for THIS user — the replay-law-safe A/B primitive.

variant<const A extends readonly [string, ...string[]]>(
  key: string,
  arms: A,
): Promise<A[number]>;
const arm = await ctx.variant("welcome-subject", ["setup", "outcome"]);

await sendEmail({
  to: user.email,
  userId: user.id,
  journeyStateId: user.stateId,
  template: Templates.ACTIVATION_WELCOME,
  subject:
    arm === "outcome"
      ? "Welcome — your first journey live in 15 minutes"
      : "Welcome — let's get you set up",
});

Assignment is a pure sha256 bucket over (journeyId, key, userId) — no RNG, no clock. The same user gets the same arm on every evaluation. Equal split only (weights are not supported).

Two guarantees, stated separately:

  1. Within one enrollment, the assignment is RECORDED once (in the journey's state row, under the reserved __variants__ key) and the recorded value wins VERBATIM on any later call — including a replay after a deploy that changed arms. An arm no longer in the current array is returned as recorded (and warned once in the logs).
  2. A re-entry mints a new enrollment and re-derives the arm from the same deterministic hash — the same arm as long as the arms array is unchanged. Editing arms between entries may reassign re-entrants.

Keys are validated against /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/ — 1–64 chars, no : and no spaces.

ctx.variant issues ZERO durable Hatchet calls (positionally invisible in the journal, like ctx.throttle). A per-journey holdout diverts BEFORE run() executes, so variants split the treatment cohort only.

A variant-selected template needs NO ctx.once wrap and no idempotencyLabel of its own: the arm is deterministic + recorded, so a replay re-derives the identical send key. The pre-existing rule stands: if a LATER unconditional send can hit the SAME template as one of the arms under the same nearest wait label, give one of them a distinct idempotencyLabel — the engine throws the loud key-collision error otherwise.

In tests, seed an assignment with the harness option: createJourneyTest(journey, { user, variants: { "welcome-subject": "outcome" } }).

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 through the environment-free journey authoring entry point. They return a DurationObject used by ctx.sleep(), entryPeriod, suppress, and ctx.history.hasEvent().

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

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, defineJourney, hours, sendEmail } from "@hogsend/engine/journeys";
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/journeys";
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, defineJourney, hours, sendEmail } from "@hogsend/engine/journeys";
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.