Skip to content

winback

One journey that mails a customer at the moment they go quiet, and resets every time they buy.

Journey What it does
winback Invites a customer back after 30 days without a purchase.

These are names your own app sends through track, not names Cowliss defines. Rename them in the copied files to whatever your app already sends.

Event Named by
purchase_completed winback
Terminal window
cow init --blueprint winback

Every file above lands in your repo, yours to edit. In a repo that already has a cow.json, add it with cow add blueprint winback instead.

Silence timer with a send at the end of it. Every purchase inside the window resets the clock, so the email only fires for genuinely quiet customers, at exactly the moment they lapsed. Resetting it is api.restart() rather than a loop, so a customer who keeps buying never grows a journal.

Winback and churn-prevention flows over any recurring signal: purchases, logins, sessions, uploads.

Rename the purchase event, the flag trait, and the email. The 30-day window is configuration. As written each customer is won back once, ever, which is the safe default for a mail nobody asked for; enrollment: { cooldown: "180d" } lets the purchase that answers the email re-mark the customer and start the cycle again.

Winback: purchase_completed starts an execution and marks the profile as having bought recently; every further purchase inside 30 days restarts the execution, which resets the silence clock with a fresh, empty journal; after 30 days of silence the flag is unset and one winback email goes out.

purchase_completed is a name your own app sends through track, not one Cowliss defines: the only events Cowliss writes itself are the reserved system. ones. Nothing here runs until your app tracks a purchase.

marketing and no enrollment, so each customer is won back once, ever: the purchase that answers the email does not start a second execution. That is the safe default for a mail nobody asked for. To win the same customer back every time they lapse, say how long to leave between attempts: enrollment: { cooldown: "180d" }.

Blueprint: add it with cow add blueprint winback; rename the purchase event, the flag trait, and the email to your domain’s vocabulary.

import { defineJourney } from "@cowliss/cli/journeys";
/**
* Winback: `purchase_completed` starts an execution and marks the profile as
* having bought recently; every further purchase inside 30 days restarts the
* execution, which resets the silence clock with a fresh, empty journal;
* after 30 days of silence the flag is unset and one winback email goes out.
*
* `purchase_completed` is a name your own app sends through `track`, not one
* Cowliss defines: the only events Cowliss writes itself are the reserved
* `system.` ones. Nothing here runs until your app tracks a purchase.
*
* `marketing` and no `enrollment`, so each customer is won back once, ever:
* the purchase that answers the email does not start a second execution.
* That is the safe default for a mail nobody asked for. To win the same
* customer back every time they lapse, say how long to leave between
* attempts: `enrollment: { cooldown: "180d" }`.
*
* Blueprint: add it with `cow add blueprint winback`; rename the purchase event,
* the flag trait, and the email to your domain's vocabulary.
*/
export default defineJourney({
trigger: { event: "purchase_completed" },
purpose: "marketing",
from: "Acme <[email protected]>",
description: "Invites a customer back after 30 days without a purchase.",
tags: ["demo", "retention"],
run: async (_event, api) => {
await api.traits.set("purchased_recently", true);
const purchase = await api.waitForEvent("purchase_completed", {
timeout: "30d",
});
if (purchase) {
// Another purchase inside 30 days: restart to reset the silence timer
// with a fresh, empty journal. Looping here instead would journal one
// entry per purchase, so the best customer is the one whose execution
// eventually fails on the 1,000-call cap.
await api.restart({ event: purchase });
}
await api.traits.unset("purchased_recently");
const me = await api.profile.get();
await api.send.email({
template: "winback",
props: {
firstName:
typeof me.traits.firstName === "string"
? me.traits.firstName
: undefined,
},
});
},
});

cow test replays this on a virtual clock: the trigger fires at the start, events arrive at their offsets, and expect is the ordered list of capability calls the run must make, each input matched as a subset.

winback.lapsed.json
{
"user": {
"id": "usr_1",
"traits": { "email": "[email protected]", "firstName": "Ada" }
},
"events": [],
"expect": [
{
"activity": "setTrait",
"input": { "key": "purchased_recently", "value": true }
},
{ "activity": "unsetTrait", "input": { "key": "purchased_recently" } },
{
"activity": "sendEmail",
"input": { "template": "winback", "props": { "firstName": "Ada" } }
}
]
}
Terminal window
cow test winback --scenario scenarios/winback.lapsed.json