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