WF-W1 · Nightly Lapse Detection and Band Assignment
Why this exists: lapsing is the absence of an event, so it has to be manufactured as one. This workflow is the only thing in the build that creates that event, and it must run against every contact, every night, without anybody asking it to.
Trigger: scheduled, nightly at 02:00.
Calculation, in order:
- Recalculate days_since_last_purchase from last_purchase_at.
- Recalculate avg_cycle_days as the median gap between purchases where purchase_count_lifetime is 3 or more. Use the median, not the mean. One unusual gap will otherwise push a regular customer into the wrong band for months.
- Where purchase_count_lifetime is 1 or 2, use expected_cycle_days for their primary_service instead. Set these deliberately with the client: a quarterly service, an annual check, a two-yearly replacement. Guessing one global number is the most common modeling error in win-back builds and it mislabels half the database.
- Assign lapse_band against the applicable cycle:
- Active: under 1.25 cycles.
- Cooling: 1.25 to 2 cycles. Route to the recall or reminder program, not to win-back. Most of this band is recoverable with a nudge and should never see a win-back message.
- Lapsed: 2 to 4 cycles. The win-back audience.
- Dormant: 4 to 8 cycles. Win-back eligible, but email only and with re-permission mandatory.
- Gone: beyond 8 cycles, or beyond 36 months regardless of cycle. Not contacted by this build at all. Consent this old is not consent, and the addresses are mostly dead.
- Cap every band at an absolute ceiling in months as well as in cycles, so a customer on a two-week cycle is not declared Gone after eight weeks.
Actions on a band change into Lapsed or Dormant:
- Stamp lapse_detected_at; set lapse_band; add lapse-detected; create a card at Lapse Detected; write winback_campaign_id.
- Exclusion checks: drop the record out of the build entirely if any apply: do_not_winback = true; any opt-out or do-not-contact tag; an open complaint, dispute or unpaid overdue invoice; an active subscription or contract (they have not lapsed, your data is wrong); deceased flag; a booking already in the future; already in another active campaign.
- Holdout draw: assign 10% at random to holdout_group = true, add winback-holdout, and exclude them from every send in this build. Draw once per winback_campaign_id and never redraw mid-campaign.
- Set value_tier from historic frequency and spend rank, used solely to route A-tier records to human calls in WF-W10.
- Continue to WF-W2.
Exit: band returns to Active on any purchase, which also closes the card and stops every pending wave.
WF-W2 · Hygiene and Re-Permission Before Any Send
Why this workflow is the most important one in the build: a reactivation campaign is, by definition, mail to people who have not engaged in a long time. That is precisely the traffic mailbox providers use to judge a sender. Blast an unvalidated two-year-old list and you can damage the sending reputation that your invoices, receipts and appointment confirmations depend on. Repairing that takes months. This workflow is not optional and it is not a nice-to-have.
Trigger: record enters Lapse Detected.
Actions, in order:
- Validate the email address. Write email_validation_status. Invalid → suppress from email permanently. Risky → hold out of the main batches and send, if at all, only in a small separate batch after the healthy sends have completed and the reputation picture is clear.
- Validate the phone number. Write phone_validation_status. Landline → no SMS, voice only. Invalid → suppress from SMS.
- Deduplicate. Merge duplicates before sending, not after. Two copies of one person getting the same win-back email is the most visible possible failure.
- Check consent age and provenance. If documented consent is older than 24 months, or its source cannot be identified, route to re-permission: do not simply send and hope.
- Re-permission send (one only): subject "Still want to hear from us?". Body: what they would receive, roughly how often, a confirm button, and a leave button that is equally prominent. No offer, no discount, no pitch. An incentive attached to a consent request contaminates the consent. Stamp repermission_requested_at.
- Wait 10 days. Confirmed → stamp repermission_confirmed_at, add repermission-confirmed, move to Wave Queued. No response → move to Dormant Archive and suppress from marketing. Silence is an answer and should be treated as one.
- Seed the send. Include monitoring addresses across the major mailbox providers in every batch so inbox placement is observed rather than assumed.
- Warm the subdomain. If the reactivation subdomain is new, ramp volume gradually over at least two weeks, starting with the most recently engaged slice of the audience and working outwards. The order matters as much as the volume: engaged-first sending is what builds the reputation that lets the cold tail land.
Hard stops: no record proceeds to any wave without a valid channel, a documented consent basis, and a clean deduplication check. Hygiene Hold is excluded from every bulk audience in the account, explicitly.
WF-W3 · Wave Scheduling and Throttle Control
Why: the difference between a reactivation campaign and a reputation incident is almost entirely pacing.
Trigger: record enters Wave Queued; scheduler runs each morning at 08:00.
Conditions and logic:
- Daily send cap per segment, set as a fraction of normal daily volume from that sending domain. A reactivation campaign should never be the largest thing the domain sends that day. Start small and increase only while complaint and bounce rates stay flat.
- Send order: most recently engaged first, least engaged last, always. This is the most important setting in the build.
- Circuit breaker: if hard bounces exceed a threshold, or spam complaints exceed a far lower one, pause the entire campaign automatically, notify the owner, and require a human to restart it. Build this before the first send, not after the first problem.
- Global frequency cap: no contact receives a win-back message within 5 days of any other marketing message from the account, from this build or any other.
- Quiet hours and day-of-week: send between 09:00 and 17:00 local, Tuesday to Thursday for email. Avoid Monday morning and Friday afternoon, and avoid public holidays: maintain a holiday calendar per location.
Actions:
- Build today's batch from Wave Queued within the cap and in the correct order.
- Dispatch, stamp winback_last_contact_at, increment winback_attempt_count, set winback_wave, move the stage.
- Record batch-level bounce, complaint, open and click rates against winback_campaign_id within 24 hours, and post the figures to the internal channel.
- Hold the rest of the queue if any metric breaches its threshold.
Exit: queue empty, or circuit breaker tripped.
WF-W4 · Wave 1: The Check-In, With No Offer
Trigger: dispatched by WF-W3.
Why there is no offer here: a discount in the first message trains the list to wait for discounts and hands margin to the people who would have returned for free. Wave 1 exists to find out who is still interested, cheaply.
Actions:
- Send email. Subject: "Still doing {{last_service_taken}}?". Specific, low-pressure, no exclamation marks and no "we miss you" in the subject line, which is the single most filtered phrase in reactivation mail.
- Body, four short paragraphs: name what they had and roughly when; one genuine thing that has changed since (a new service, longer hours, a second location, new equipment); one sentence saying there is no catch; one button to availability. Plain text weighted, light on images, one clear unsubscribe and one preference-center link.
- SMS variant: Cooling and A tier only, consent confirmed within 12 months: "Hi {{first_name}}, it's {{business_name}}, it's been a while since your {{last_service_taken}}. We've got {{slot_1}} and {{slot_2}} free this week if you'd like to come back in. Reply STOP to opt out."
- Set winback_wave = 1; add winback-wave-1; move to Wave 1 Sent.
- Wait 5 days. Branch on behavior:
- Clicked or replied → move to Re-Engaged, cancel all remaining waves, continue to WF-W7.
- Opened, did not click → continue to Wave 2. The address is live and the person is curious; this is the best segment in the campaign.
- No open → continue to Wave 2, but switch the subject line and the send time, and move them behind the engaged cohort in the queue order.
- Bounced or complained → suppress immediately, move to Suppressed, and exit the build.
Exit: engagement of any kind; suppression; Wave 2 dispatched.
WF-W5 · Wave 2: The Reason Ask
Why this is the wave that pays for the build: a reactivation campaign that produces returns gives you revenue once. One that produces reasons gives you a list of fixable causes of churn, and the reasons are usually not what the owner assumed. Ask before you discount.
Trigger: 5 days after Wave 1 with no click or reply.
Conditions: no suppression, no booking, winback_attempt_count under 3, frequency cap satisfied, circuit breaker clear.
Actions:
- Send email. Subject: "Can I ask you one question?" Body: three lines, then the nine reason buttons inline so the answer is a single tap from the inbox. No offer anywhere on the page.
- Write winback_reason on tap, not on submit, and stamp the time.
- Set winback_wave = 2; add winback-wave-2; move to Wave 2 Sent.
- On any answer: cancel every remaining wave, move to Re-Engaged or Lost: Reason Known, and branch:
- Moved away → check for another location. If one is in range, send its details once. Otherwise thank them honestly, set do_not_winback = true, and close the card. Never discount a geography problem.
- Price → the only branch where Wave 3's offer is genuinely the right instrument. Queue it immediately rather than waiting the full interval.
- Service issue → no offer, no automation. Create a task for a named owner, due in 4 hours, for a personal call. Send one short acknowledgement: "Thank you for telling me: that's not the standard we want. {{owner_first_name}} will call you personally; no sales pitch, I'd just like to understand what happened." An offer sent to someone with an unresolved complaint makes things worse.
- Went elsewhere → one short message, no discount, asking what the other provider does better, and leaving the door open. This reply is competitive intelligence you cannot buy.
- No longer need it → thank them, set do_not_winback = true, and stop. Do not try to create a need.
- Forgot about us → the best possible answer. No discount required. Send two concrete times and a booking link, and enroll them in the recall program so it does not happen again.
- Life circumstances → one gentle, open-ended message, suppress for 6 months, and no offer. Treat with care: this branch frequently involves illness or bereavement, and the standing instruction is to hand anything specific to a human and automate nothing.
- Never actually a fit → thank them, suppress, and feed it back to whoever owns lead qualification. These records are telling you about an acquisition problem, not a retention one.
Exit: reason captured; booking made; suppression; Wave 3 dispatched.
WF-W6 · Wave 3: The Offer, With an Expiry
Trigger: 7 days after Wave 2 with no response, or immediately on the Price branch of WF-W5.
Conditions: winback_attempt_count under 3; no suppression; offer approved and configured; contact not in holdout_group; winback_reason is not Service issue, Moved away, No longer need it, Life circumstances or Never actually a fit: those branches never receive this wave.
Actions:
- Generate winback_offer_code, unique per contact so redemption is attributable, and set winback_offer_expires_at: 14 days is the usual window. Shorter feels like pressure; longer gets forgotten.
- Send email. Subject names the thing, not the saving. Body: one paragraph, the offer stated plainly in the words legal and the owner approved, the expiry date visible as a date rather than a countdown, the terms on the same screen, one button.
- Set winback_wave = 3; add winback-wave-3; move to Wave 3 Sent.
- Wait 7 days. Condition: unredeemed and unexpired. Send one reminder stating the expiry date. One. There is no second reminder and no extension. An extended deadline teaches the list that deadlines are decorative.
- On expiry: expire the code server-side so it genuinely stops working, and move the record to Dormant Archive.
- On redemption: stamp reactivated_at, set reactivation_source = Wave 3, move to Won Back, continue to WF-W8.
Commercial guardrails to settle before this workflow is built: the nature and size of the offer are the client's decision and must be set against margin rather than against what feels compelling. The offer should be the last instrument used, never the first. Exclude anybody whose winback_reason indicates the problem is not price. Never offer returning customers better terms than loyal ones can get. It is the fastest way to teach your best customers to lapse deliberately. Confirm any promotional terms, expiry handling and advertising claims with the client's own counsel before launch.
WF-W7 · Re-Engagement Capture and Booking
Trigger: any click, reply, inbound call, chat message, or reason tap from a record in the Win-Back pipeline.
Actions, in order:
- Cancel every pending wave for this contact immediately. This is the step that gets missed, and missing it means someone who just replied receives the next scheduled blast anyway.
- Add winback-engaged; move to Re-Engaged; stamp the engagement time.
- Branch on value_tier. A tier → create a task for a named human to call within 2 hours and route the thread to them. B and C → hand to BO.
- BO's opener acknowledges the gap without making it awkward: "Good to hear from you, {{first_name}}. It's been a little while: are you after {{last_service_taken}} again, or something different this time?"
- Offer two specific times from the priority return calendar rather than a bare link. Named times convert far better than open availability on a reactivated contact.
- On booking: stamp referral-style attribution: that is, set reactivation_source to the wave that produced the engagement: add winback-booked, move to Booked, and run the hardened confirmation ladder, because a first booking after a long gap behaves like a new lead rather than a regular.
- On no booking after 2 conversational turns: hand to a human rather than persisting. BO's job ends where judgment begins.
Exit: booked; handed to a human; contact goes quiet for 14 days, in which case the card returns to Dormant Archive without further messaging.
WF-W8 · Won-Back Onboarding and Cycle Reset
Why it matters: reactivating a customer and then returning them to the same unmanaged state simply schedules the same loss again. The win-back is only finished when the mechanism that lost them is fixed.
Trigger: purchase or completed appointment on a record tagged winback-engaged or in Booked.
Actions:
- Stamp reactivated_at and last_purchase_at; set lapse_band = Active; add won-back; move to Won Back; recalculate avg_cycle_days including the long gap, flagged as an outlier so it does not distort the next band assignment.
- Clear winback_wave and winback_attempt_count for the next cycle, and close the card.
- Wait 1 day. Send a thank-you that does not sell: "Really glad to have you back, {{first_name}}. Anything you need, just reply to this. It comes straight to us."
- Wait 2 days. Run the satisfaction question. A won-back customer's second experience decides whether the reactivation was worth anything, and this is also the gate into the review and referral programs.
- At the point of service: queue the next appointment or reorder prompt before they leave. A booked next visit is the only reliable protection against a repeat lapse.
- Enroll them in the recall or reminder program for their primary_service, with the cadence set from their own avg_cycle_days.
- Create a task to review the lapse cause. Where winback_reason is populated, route it to whoever owns that process. Service issues to operations, price to the owner, forgetting to whoever owns recall. A reason that lands in a field and is never read is a reason wasted.
Exit: onboarding complete and recall enrollment confirmed.
WF-W9 · Sunset, Archive and Suppression
Why you need an ending: a reactivation program with no exit becomes permanent mail to people who will never buy, and permanently unengaged recipients are exactly what damages a sending domain. The discipline to stop is part of the build.
Trigger: all waves sent with no engagement; or any suppression event; or scheduled monthly sweep.
Actions:
- No engagement after Wave 3 and the single reminder → add winback-dormant, move to Dormant Archive, and remove the contact from all marketing audiences. Retain the record for reporting and for any future consented re-entry; retain nothing you cannot justify keeping.
- Hard bounce, spam complaint, STOP, or unsubscribe → suppress immediately across the relevant channel, add winback-suppressed, move to Suppressed, set do_not_winback = true. Make the suppression visible to email and SMS at once so one channel does not keep running.
- Re-entry rule: a dormant-archived contact may be considered for one future campaign no sooner than 12 months later, and only after fresh validation and re-permission. Enforce this with lapse_detected_at and winback_campaign_id, not with memory.
- Monthly sweep: flag any record that has sat in Hygiene Hold or Wave Queued for more than 30 days. These are usually a sign the throttle is set too low or a validation step is silently failing.
- Apply the retention policy: delete or anonymize archived records at the agreed interval automatically, rather than letting a decade of dead contacts accumulate.
Exit: terminal by design.
WF-W10 · Holdout Measurement, A-Tier Call Lists and Reporting
Why the holdout group exists: some lapsed customers come back on their own. Without a control group you will credit every one of those returns to the campaign, conclude it worked brilliantly, and never find out whether it did. The holdout is the only honest measurement in this build.
Trigger: scheduled weekly, plus a campaign-close report at 90 days.
Actions:
- Weekly A-tier call list. Build the list of A-tier records in Wave 1 Sent or later with no engagement, ordered by historic frequency, and assign to the human call blocks. Script: identify yourself, say it has been a while, ask whether they still need the service, offer two times, and, if nothing else, ask what changed. No pitch, no offer unless already approved for that record.
- Log every call outcome into winback_reason. Calls produce reasons at a far higher rate than any message, which makes this list valuable even when it produces no bookings.
- Weekly operations digest to the internal channel: records detected, held in hygiene, queued, sent per wave, bounce and complaint rates, engagement by wave, bookings, and the circuit-breaker status.
- 90-day campaign report to the owner: purchase rate in the contacted group versus the holdout_group; the difference is the campaign's actual effect. Then returns by wave, reason distribution, reactivation rate by value_tier and by primary_service, offer redemption rate, and, reported honestly, how many Wave 3 redemptions came from people whose reason was never price.
- Feed the reasons upstream. Produce a ranked list of lapse causes and route each to a named owner. If "forgot about us" dominates, the real fix is the recall program, and the next win-back campaign should be smaller because fewer people lapsed.
- Retire the campaign. Close winback_campaign_id, release the holdout group back into normal eligibility, and schedule the next run: quarterly for short-cycle businesses, twice a year for long-cycle ones. A continuous win-back campaign is just a mailing list with extra steps.
Exit: campaign closed and reported.