Hogsend is brand new.Chat to Doug
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.

4 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

A cron task that aggregates the week, skips empty digests, and survives its own retries.

onCrons replaces onEvents: the clock is the trigger. The aggregate only returns active users, and every send carries a per-user, per-week idempotency key.

Full write-up
src/workflows/weekly-digest.ts
// src/workflows/weekly-digest.ts
import { contacts, userEvents } from "@hogsend/db";
import { hatchet } from "@hogsend/engine";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { getContainer } from "../container.js";
import { Events, Templates } from "../journeys/constants/index.js";

const WINDOW_MS = 7 * 24 * 60 * 60 * 1000;

export const weeklyDigestTask = hatchet.task({
  name: "weekly-digest",
  // Mondays at 09:00 UTC — a clock is the trigger, not a user event.
  onCrons: ["0 9 * * 1"],
  retries: 1,
  executionTimeout: "30m",
  fn: async () => {
    const { db, emailService, logger } = getContainer();
    const since = new Date(Date.now() - WINDOW_MS);
    // One key per (user, weekly run): retries and re-runs can't double-send.
    const weekKey = new Date().toISOString().slice(0, 10);

    // One aggregate query — users with no qualifying events never appear,
    // so the empty digest is structurally impossible.
    const activity = await db
      .select({
        userId: userEvents.userId,
        reportsCreated: sql<number>`count(*) filter (where ${userEvents.event} = ${Events.REPORT_CREATED})`,
        reportsShared: sql<number>`count(*) filter (where ${userEvents.event} = ${Events.REPORT_SHARED})`,
      })
      .from(userEvents)
      .where(
        and(
          gte(userEvents.occurredAt, since),
          inArray(userEvents.event, [
            Events.REPORT_CREATED,
            Events.REPORT_SHARED,
          ]),
        ),
      )
      .groupBy(userEvents.userId);

    let sent = 0;
    let skipped = 0;

    for (const row of activity) {
      // Identity resolved server-side — userId on events is never an email.
      const contact = await db.query.contacts.findFirst({
        where: eq(contacts.externalId, row.userId),
      });
      if (!contact?.email) {
        skipped++;
        continue;
      }

      const result = await emailService.send({
        template: Templates.RETENTION_WEEKLY_DIGEST,
        to: contact.email,
        userId: row.userId,
        subject: "Your week in review",
        props: {
          reportsCreated: Number(row.reportsCreated),
          reportsShared: Number(row.reportsShared),
          weekOf: weekKey,
        },
        // NO skipPreferenceCheck — a digest is exactly the mail that
        // preferences exist to control.
        idempotencyKey: `digest:${row.userId}:${weekKey}`,
      });

      if (result.status === "sent") sent++;
      else skipped++;
    }

    logger.info("weekly-digest complete", { sent, skipped, weekKey });
    return { sent, skipped, week: weekKey };
  },
});

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 },
    });
  },
});

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