Hogsend is brand new.Chat to Doug
Recipes — Trial, billing & upgrades

Trial, billing & upgrades

Trial arcs, dunning, and upgrade nudges that stop the moment money arrives.

These recipes move a trial or free user to paid, and keep paying customers from lapsing. Every one stops the moment payment arrives or a card recovers, so nobody gets a nudge for something they have already done.

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

Trial conversion sequence

A day-1 value email, a usage branch mid-trial, a bucket-timed T-3 push — gone the instant they pay.

The mid-trial branch is ctx.history.hasEvent at decision time — a count of the feature.used events your product already emits, not an exported segment.

Full write-up
src/journeys/trial-onboarding.ts
export const trialOnboarding = defineJourney({
  meta: {
    id: "trial-onboarding",
    name: "Conversion — trial onboarding",
    enabled: true,
    trigger: { event: Events.TRIAL_STARTED },
    entryLimit: "once",
    suppress: hours(12),
    exitOn: [
      { event: Events.SUBSCRIPTION_CREATED },
      { event: Events.USER_DELETED },
    ],
  },

  run: async (user, ctx) => {
    // Day 1 — one concrete outcome, not a feature tour.
    await ctx.sleep({ duration: days(1), label: "day-1" });
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.TRIAL_FIRST_VALUE,
      subject: "Get your first result today",
      journeyName: user.journeyName,
    });

    // Mid-trial — branch on what they actually did.
    await ctx.sleep({ duration: days(3), label: "mid-trial" });
    if (!(await ctx.guard.isSubscribed())) return;

    const usage = await ctx.history.hasEvent({
      userId: user.id,
      event: Events.FEATURE_USED,
      within: days(4),
    });

    if (usage.count >= 3) {
      // Engaged — sell the paid tier on what they already use.
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.TRIAL_UPGRADE_VALUE,
        subject: "You're getting value — here's what Pro adds",
        journeyName: user.journeyName,
        props: { usageCount: usage.count },
      });
    } else {
      // Cold — conversion needs activation first, not a pitch.
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.TRIAL_ACTIVATION_NUDGE,
        subject: "Three days in — the fastest path to a result",
        journeyName: user.journeyName,
      });
    }
  },
});

Failed payment dunning

Notify, wait through Stripe's retries, escalate twice, and stop the instant the invoice is paid.

waitForEvent paces the reminders; exitOn guarantees a recovered payment — or a cancellation — kills the run at any point, even mid-wait.

Full write-up
src/journeys/dunning.ts
export const dunning = defineJourney({
  meta: {
    id: "dunning",
    name: "Billing — failed payment dunning",
    enabled: true,
    trigger: { event: Events.INVOICE_PAYMENT_FAILED },
    // a card that keeps failing re-enters at most once a week
    entryLimit: "once_per_period",
    entryPeriod: days(7),
    suppress: hours(4),
    exitOn: [
      { event: Events.INVOICE_PAID },         // recovered — stop immediately
      { event: Events.SUBSCRIPTION_DELETED }, // cancelled — stop dunning
    ],
  },

  run: async (user, ctx) => {
    // Immediately: most failures are a stale card, not a churn decision.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.BILLING_PAYMENT_FAILED,
      subject: "Your payment didn't go through",
      journeyName: user.journeyName,
    });

    // Stripe retries on its own schedule — give the first retry three days.
    const firstRetry = await ctx.waitForEvent({
      event: Events.INVOICE_PAID,
      timeout: days(3),
      label: "await-first-retry",
    });
    if (!firstRetry.timedOut) return; // recovered — exitOn already handled it
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.BILLING_UPDATE_CARD,
      subject: "Action needed: update your payment method",
      journeyName: user.journeyName,
    });

    const secondRetry = await ctx.waitForEvent({
      event: Events.INVOICE_PAID,
      timeout: days(4),
      label: "await-second-retry",
    });
    if (!secondRetry.timedOut) return;
    if (!(await ctx.guard.isSubscribed())) return;

    // Day 7: final notice to the customer…
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.BILLING_FINAL_NOTICE,
      subject: "Final notice: your subscription will be paused",
      journeyName: user.journeyName,
    });

    // …and the failure leaves the email channel: flag it for a human.
    await ctx.trigger({
      event: Events.DUNNING_EXHAUSTED,
      userId: user.id,
      userEmail: user.email,
      properties: { stage: "final-notice", source: "dunning" },
    });
  },
});

Usage limit upgrade

Nudge at 80%, escalate only at the wall, and stop the instant they upgrade.

waitForEvent is the branch (did they hit the wall?); its payload names the blocked metric, and exitOn guarantees an upgrade at any point ends the run.

Full write-up
src/journeys/usage-limit-upgrade.ts
export const usageLimitUpgrade = defineJourney({
  meta: {
    id: "usage-limit-upgrade",
    name: "Conversion — usage limit upgrade",
    enabled: true,
    trigger: {
      event: Events.USAGE_THRESHOLD_REACHED,
      // below 80% is not pressure — those events never enter the journey
      where: (b) => b.prop("usage_pct").gte(80),
    },
    entryLimit: "once_per_period",
    entryPeriod: days(30), // one nudge sequence per billing cycle
    suppress: hours(24),
    // usage.limit_hit is deliberately NOT here — the journey reacts to it.
    exitOn: [{ event: Events.SUBSCRIPTION_UPGRADED }],
  },

  run: async (user, ctx) => {
    // The trigger event's scalar properties ride in on user.properties.
    const usagePct = Number(user.properties.usage_pct ?? 80);
    const metric = String(user.properties.metric ?? "usage");

    // First touch — headroom is still optional, sell it as such.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.USAGE_APPROACHING_LIMIT,
      subject: `You've used ${usagePct}% of your plan`,
      journeyName: user.journeyName,
      props: { usagePct, metric },
    });

    // Second touch only if they actually hit the wall.
    const wall = await ctx.waitForEvent({
      event: Events.USAGE_LIMIT_HIT,
      timeout: days(14),
      label: "await-limit-hit",
    });
    if (wall.timedOut) return; // never hit 100% — one nudge was enough

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

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.USAGE_LIMIT_HIT,
      subject: "You've hit your plan limit",
      journeyName: user.journeyName,
      props: {
        metric: String(wall.properties?.metric ?? metric),
        blockedCount: Number(wall.properties?.blocked_count ?? 0),
      },
    });
  },
});

Cancellation save

Ask why with three one-tap links, branch on the answer, and stop the moment they reactivate.

waitForEvent returns the answer's payload, so the branch is a plain if on properties.reason — and the awaited event stays out of exitOn so the branch actually runs.

Full write-up
src/journeys/cancellation-save.ts
export const cancellationSave = defineJourney({
  meta: {
    id: "cancellation-save",
    name: "Conversion — cancellation save",
    enabled: true,
    trigger: { event: Events.SUBSCRIPTION_CANCEL_REQUESTED },
    entryLimit: "once_per_period",
    entryPeriod: days(90), // one save attempt per quarter
    suppress: hours(12),
    // The awaited answer event is deliberately NOT here — one event, one role.
    exitOn: [{ event: Events.SUBSCRIPTION_REACTIVATED }],
  },

  run: async (user, ctx) => {
    // Ask why. Three semantic links share one answer slot — first answer wins.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.CANCEL_REASON_SURVEY,
      subject: "Before you go — one question",
      journeyName: user.journeyName,
    });

    const answer = await ctx.waitForEvent({
      event: Events.CANCEL_REASON_GIVEN,
      timeout: days(3),
      lookback: minutes(30), // covers an answer landing in the send→wait gap
      label: "await-reason",
    });
    if (answer.timedOut) return; // no answer — let the cancellation stand

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

    const reason = answer.properties?.reason;

    if (reason === "price") {
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.CANCEL_DISCOUNT_OFFER,
        subject: "Stay for 30% less",
        journeyName: user.journeyName,
      });
      return;
    }

    if (reason === "missing_feature") {
      // Route a human in — scalars only; the alert task resolves identity
      // server-side from the contacts row, never from event properties.
      await ctx.trigger({
        event: Events.CANCEL_SAVE_ESCALATED,
        userId: user.id,
        userEmail: user.email,
        properties: { reason: "missing_feature", source: "cancel-survey" },
      });
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.CANCEL_ROADMAP,
        subject: "What's coming — and a person to talk to",
        journeyName: user.journeyName,
      });
      return;
    }

    // "not_using" — a pause keeps the account where a refund doesn't.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.CANCEL_PAUSE_OFFER,
      subject: "Pause instead — keep your data and your rate",
      journeyName: user.journeyName,
    });
  },
});

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