Abandoned cart recovery
Wait for the purchase, remind twice, and stop the instant the order lands.
waitForEvent is the branch (did they buy yet?); exitOn is the guarantee (a purchase at any point kills the run, even mid-sleep).
Full write-upexport const abandonedCart = defineJourney({
meta: {
id: "abandoned-cart",
name: "E-commerce — abandoned cart",
enabled: true,
trigger: {
event: Events.CHECKOUT_STARTED,
// carts under $25 never enter the journey at all
where: (b) => b.prop("cart_value").gte(25),
},
entryLimit: "once_per_period",
entryPeriod: days(7),
suppress: hours(12),
exitOn: [{ event: Events.CHECKOUT_COMPLETED }],
},
run: async (user, ctx) => {
// Give the purchase four hours to complete on its own.
const first = await ctx.waitForEvent({
event: Events.CHECKOUT_COMPLETED,
timeout: hours(4),
});
if (!first.timedOut) return; // they bought — nothing to recover
if (!(await ctx.guard.isSubscribed())) return;
await sendEmail({
to: user.email,
userId: user.id,
journeyStateId: user.stateId,
template: Templates.ECOMMERCE_CART_REMINDER,
subject: "You left items in your cart",
journeyName: user.journeyName,
props: {
cartId: String(user.properties.cart_id ?? ""),
cartValue: Number(user.properties.cart_value ?? 0),
},
});
// One more day. exitOn still covers a purchase mid-wait.
const second = await ctx.waitForEvent({
event: Events.CHECKOUT_COMPLETED,
timeout: days(1),
});
if (!second.timedOut) return;
// Land the last call at 09:30 in the shopper's own timezone.
await ctx.sleepUntil(ctx.when.nextLocal("09:30"));
if (!(await ctx.guard.isSubscribed())) return;
await sendEmail({
to: user.email,
userId: user.id,
journeyStateId: user.stateId,
template: Templates.ECOMMERCE_CART_LAST_CALL,
subject: "Your cart is about to expire",
journeyName: user.journeyName,
props: {
cartId: String(user.properties.cart_id ?? ""),
cartValue: Number(user.properties.cart_value ?? 0),
},
});
},
});