Skip to main content

Documentation Index

Fetch the complete documentation index at: https://loops.so/docs/llms.txt

Use this file to discover all available pages before exploring further.

Churn rarely happens suddenly. Users go quiet, skip a few sessions, stop getting value, and only then cancel. This recipe defines the segment that catches them early and a workflow that tries to pull them back.

What you need

These contact properties need to be updated via the Update contact endpoint or an integration when the user performs a key action. For extended context on this pattern, Sending a campaign to active users is worth reading.

Define the segment

Go to the Audience page, apply filters, then save the segment. A reasonable starting definition:
  • churnRisk Is true, AND
  • Subscribed Is true
Set churnRisk from your app as:
  • true when lastActive is between 14 and 60 days ago
  • false otherwise
This keeps long-inactive users out so you do not wake up dormant signups, while also excluding brand-new contacts who have not had a chance to be active yet.
Sending to users who have been inactive for months can hurt your sender reputation. That’s why this recipe caps the window at 60 days.
Because Loops filters do not support dynamic date expressions like “today minus 14 days,” compute this window in your app or a daily sync job and write churnRisk directly.
syncToLoops.ts
import { LoopsClient } from "loops";

type UserActivityRow = {
  email: string;
  userId: string;
  lastActiveAtMs: number | null;
};

const loops = new LoopsClient(process.env.LOOPS_API_KEY!);
const DAY_MS = 24 * 60 * 60 * 1000;
const CHURN_MIN_DAYS = 14;
const CHURN_MAX_DAYS = 60;

async function syncChurnRisk(users: UserActivityRow[]) {
  const now = Date.now();
  const minCutoff = now - CHURN_MIN_DAYS * DAY_MS;
  const maxCutoff = now - CHURN_MAX_DAYS * DAY_MS;

  for (const user of users) {
    const last = user.lastActiveAtMs;
    const churnRisk = last !== null && last <= minCutoff && last >= maxCutoff;

    await loops.updateContact({
      email: user.email,
      userId: user.userId,
      properties: {
        lastActive: last,
        churnRisk,
      },
    });
  }
}

Set up the re-engagement workflow

1

Add a lifecycle property

Add a boolean contact property in Loops called churnRisk. This should beupdated via the Update contact endpoint whenever activity changes, or in a daily sync job.
2

Trigger on Contact updated

In the workflow builder, pick Contact updated with churnRisk changing to true. See Workflow triggers.
3

Add an audience filter

Match the trigger so only currently-at-risk users continue: churnRisk Is true.Choose between One time or Every time for the trigger frequency, depending on whether you want contacts to re-enter the workflow in the future.Set the filter scope to All following nodes, so Loops will continue evaluating each contact against the filter as the workflow progresses.
4

Build the email sequence

Add email nodes to the workflow, with timer nodes in between.
  • Day 0: “We noticed you have been quiet. Here is the one thing worth coming back for.” Personal, useful, not promotional.
  • Timer 4 days
  • Send a customer story or missed-feature email.
  • Timer 6 more days
  • Send a direct ask with a link to cancel if they prefer.
If you use All following nodes contacts will be checked against the filter before they reach each node, and will exit the workflow as soon as churnRisk is false.

Why this works

  • The segment updates automatically as churnRisk changes
  • The filter in the workflow prevents messaging users who already came back
  • The 60-day upper bound protects deliverability by keeping long-dormant users out

Testing before you ship

  • Send a test to your own account with lastActive manually set 20 days back and churnRisk set to true
  • Use the experiments feature to A/B test subject lines if volume permits

Read more

Lifecycle emails overview

Filters and segments

Branching workflows

Sender reputation

Last modified on May 11, 2026