winback
One journey that mails a customer at the moment they go quiet, and resets every time they buy.
Journeys
Section titled “Journeys”| Journey | What it does |
|---|---|
winback |
Invites a customer back after 30 days without a purchase. |
Events it needs
Section titled “Events it needs”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 |
Copy it
Section titled “Copy it”cow init --blueprint winbackEvery 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.
The pattern
Section titled “The pattern”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.
Reach for it when
Section titled “Reach for it when”Winback and churn-prevention flows over any recurring signal: purchases, logins, sessions, uploads.
Make it yours
Section titled “Make it yours”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.
The journey
Section titled “The journey”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", 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, }, }); },});Its scenario
Section titled “Its scenario”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.
{ "user": { "id": "usr_1", }, "events": [], "expect": [ { "activity": "setTrait", "input": { "key": "purchased_recently", "value": true } }, { "activity": "unsetTrait", "input": { "key": "purchased_recently" } }, { "activity": "sendEmail", "input": { "template": "winback", "props": { "firstName": "Ada" } } } ]}cow test winback --scenario scenarios/winback.lapsed.json