Hogsend is brand new.Try it
Hogsend
Recipes — Retention & engagement

Retention & engagement

Win-backs, surveys, digests, and the sunset policy that protects deliverability.

Retention recipes bring quiet users back, ask for feedback, and keep your sending list healthy. The win-back and sunset flows stop sending to people who have truly gone, which protects your domain's reputation.

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

5 recipes

The recipes

Win-back and sunset

Try to win dormant users back, then turn sustained silence into a clean unsubscribe.

Two touches, one semantic answer, one preference write. exitOn vetoes the sunset for anyone who came back; the timeout makes silence an answer.

Full write-up
src/journeys/winback-and-sunset.ts
export const winbackAndSunset = defineJourney({
  meta: {
    id: "winback-and-sunset",
    name: "Retention — win-back and sunset",
    enabled: true,
    // The trigger is a bucket join — the engine computes dormancy, you don't.
    trigger: { event: dormant30d.entered },
    entryLimit: "once_per_period",
    entryPeriod: days(180),
    suppress: hours(24),
    // Returning leaves the bucket, which exits the run — even mid-sleep.
    exitOn: [{ event: dormant30d.left }],
  },

  run: async (user, ctx) => {
    // Touch 1 — the win-back attempt.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.RETENTION_WINBACK,
      subject: "What's changed since you've been away",
      journeyName: user.journeyName,
    });

    // A return during this week fires dormant30d.left and ends the run here.
    await ctx.sleep({ duration: days(7), label: "post-winback" });
    if (!(await ctx.guard.isSubscribed())) return;

    // Touch 2 — still silent: ask the question directly.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.RETENTION_REPERMISSION,
      subject: "Should we keep emailing you?",
      journeyName: user.journeyName,
    });

    const answer = await ctx.waitForEvent({
      event: Events.REPERMISSION_ANSWERED,
      timeout: days(10),
      lookback: minutes(30),
    });

    if (!answer.timedOut && answer.properties?.answer === "stay") {
      return; // explicit opt-in renewed — keep them, send nothing more
    }

    // "leave", any other answer, or ten days of silence: sunset via the
    // Admin API preference write. exitOn already vetoed anyone who returned.
    const res = await fetch(
      `${process.env.API_PUBLIC_URL}/v1/admin/contacts/${user.id}/preferences`,
      {
        method: "PUT",
        headers: {
          Authorization: `Bearer ${process.env.ADMIN_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ unsubscribedAll: true }),
      },
    );
    if (!res.ok) throw new Error(`Sunset write failed: ${res.status}`);
    await ctx.checkpoint("sunset-applied");
  },
});

NPS survey

Score bands answered inside the email, detractors flagged to a human, promoters asked for a referral.

entryLimit does the cadence, waitForEvent does the collection, an if on properties.band does the routing.

Full write-up
src/journeys/nps-survey.ts
export const npsSurvey = defineJourney({
  meta: {
    id: "nps-survey",
    name: "Feedback — NPS survey",
    enabled: true,
    // Any product activity makes them eligible; the entry limit does the
    // cadence — at most one survey per user per 90 days.
    trigger: { event: Events.APP_ACTIVE },
    entryLimit: "once_per_period",
    entryPeriod: days(90),
    suppress: hours(24),
    // No exitOn — and the awaited answer (nps.submitted) must NEVER be one.
  },

  run: async (user, ctx) => {
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.FEEDBACK_NPS_SURVEY,
      subject: "How likely are you to recommend us?",
      journeyName: user.journeyName,
    });

    // Answers are provisional clicks confirmed ~30s after the scanner-burst
    // window — timeouts are days, never minutes.
    const answer = await ctx.waitForEvent({
      event: Events.NPS_SUBMITTED,
      timeout: days(7),
      lookback: minutes(30),
    });
    if (answer.timedOut) return; // silence — next window is in 90 days

    const band = answer.properties?.band;

    if (band === "detractor") {
      // Scalars only — the alert task resolves email/name server-side.
      await ctx.trigger({
        event: Events.NPS_DETRACTOR_FLAGGED,
        userId: user.id,
        properties: {
          band: "detractor",
          sourceEvent: Events.NPS_SUBMITTED,
          sourceTemplate: Templates.FEEDBACK_NPS_SURVEY,
          answeredAt: new Date().toISOString(),
        },
      });
      return; // a human follows up — no automated reply to a low score
    }

    if (band === "promoter") {
      if (!(await ctx.guard.isSubscribed())) return;
      await sendEmail({
        to: user.email,
        userId: user.id,
        journeyStateId: user.stateId,
        template: Templates.FEEDBACK_REFERRAL_ASK,
        subject: "Glad it's working — know a team who'd want this?",
        journeyName: user.journeyName,
      });
    }
    // band === "passive" (7–8): the run ends with no follow-up.
  },
});

Weekly digest

One ctx.digest() call collapses a week of activity into one email — replay-safe, no idempotency bookkeeping.

ctx.digest collapses the week into one execution; Object.groupBy over digest.events is the whole 'batch' step. No aggregate SQL, no contact lookup, no weekKey.

Full write-up
src/journeys/weekly-digest.ts
// src/journeys/weekly-digest.ts
import { days, defineJourney, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "./constants/index.js";

export const weeklyDigest = defineJourney({
  meta: {
    id: "weekly-digest",
    name: "Retention — Weekly activity digest",
    enabled: true,
    // Any report activity opens a window; the rest of the week folds in.
    trigger: { event: Events.REPORT_CREATED },
    entryLimit: "unlimited", // rolling: a fresh window opens after each flush
    // ctx.digest already collapses the week into one send — a non-zero
    // suppress would gap out each new window's email against the previous.
    suppress: days(0),
    exitOn: [{ event: Events.USER_DELETED }],
  },

  run: async (user, ctx) => {
    // First report enrolls; every report.created in the next 7 days is
    // absorbed by the enrollment guard and returned here at flush. One
    // execution, one email — not one per report.
    const digest = await ctx.digest({ window: days(7), label: "weekly" });

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

    // The "batch" recipe: ctx.digest collects the window, grouping is plain
    // TypeScript over digest.events.
    const byProject = Object.groupBy(
      digest.events,
      (e) => String(e.properties?.projectId ?? "unknown"),
    );
    const projects = Object.entries(byProject).map(([projectId, events]) => ({
      projectId,
      count: 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: { totalReports: digest.count, projects },
    });
  },
});

Anniversary emails

One celebration per year, gated on recent activity, landing at 09:00 local time.

The dormancy gate runs before the sleep, so a churned contact's run ends immediately instead of parking until morning to send nothing.

Full write-up
src/journeys/signup-anniversary.ts
export const signupAnniversary = defineJourney({
  meta: {
    id: "signup-anniversary",
    name: "Retention — signup anniversary",
    enabled: true,
    trigger: { event: Events.ANNIVERSARY_REACHED },
    // the yearly cap — a duplicate trigger inside the period is skipped
    entryLimit: "once_per_period",
    entryPeriod: days(365),
    suppress: hours(24),
    exitOn: [{ event: Events.USER_DELETED }],
  },

  run: async (user, ctx) => {
    const years = Number(user.properties.years ?? 1);

    // A celebration email to someone who left a year ago reads as
    // automated noise. Dormant contacts belong in a win-back flow.
    const { found: active } = await ctx.history.hasEvent({
      userId: user.id,
      event: Events.APP_ACTIVE,
      within: days(90),
    });
    if (!active) return;

    // The producer fires at whatever hour the nightly job runs. Land
    // the send at 09:00 in the user's own timezone instead.
    await ctx.sleepUntil(ctx.when.nextLocal("09:00"));
    if (!(await ctx.guard.isSubscribed())) return;

    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.RETENTION_ANNIVERSARY,
      subject:
        years === 1
          ? "One year with us today"
          : `${years} years with us today`,
      journeyName: user.journeyName,
      props: { years },
    });
  },
});

Re-engage quiet Discord members

Read the derived last_seen on a contact, find members quiet for 30 days, and email a win-back.

The win-back is gated three ways: a linked email, a last_seen older than 30 days, and a still-subscribed contact — so it never fires for the unlinked, the active, or the unsubscribed.

Full write-up
src/journeys/re-engage-quiet-discord-members.ts
export const reEngageQuietDiscordMembers = defineJourney({
  meta: {
    id: "re-engage-quiet-discord-members",
    name: "Retention — re-engage quiet Discord members",
    enabled: true,
    // Re-evaluate on each presence ping; the entry guard rate-limits re-entry.
    // GUILD_PRESENCES is opt-in: swap in discord.message_sent to skip it.
    trigger: { event: Events.DISCORD_PRESENCE_ACTIVE },
    entryLimit: "once_per_period",
    entryPeriod: days(30),
    suppress: hours(12),
  },

  run: async (user, ctx) => {
    const db = getDb();
    const contact = await db.query.contacts.findFirst({
      where: eq(contacts.id, user.id),
    });

    // No linked email → nothing to send. last_seen is the DERIVED first-party
    // signal (max of observed Discord events), not Discord presence.
    if (!contact?.email) return;

    const meta = (contact.properties?.discord ?? {}) as {
      last_seen?: string;
      username?: string;
    };
    const lastSeen = meta.last_seen ? new Date(meta.last_seen) : null;
    if (!lastSeen) return;

    const quietDays = (Date.now() - lastSeen.getTime()) / 86_400_000;
    if (quietDays < 30) return; // still active enough — no win-back

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

    await sendEmail({
      to: contact.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.DISCORD_WINBACK,
      subject: `We've missed you in the server, ${meta.username ?? "friend"}`,
      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