> ## 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.

# Recipe: Active 30-day users segment

> A reusable segment of contacts active in the last 30 days, ideal for warming a sender reputation and targeting engaged users with product updates.

The "active in the last 30 days" segment is a workhorse. It's the right audience for [product updates](/guides/product-updates), warming [sender reputation](/deliverability/sending-reputation), and testing new content without hitting less-engaged users.

This recipe sets up the segment, wires up the activity signal, and shows how to use it.

## What you need

* A `lastActive` (date) contact property, updated whenever a user takes a meaningful action
* An `isActive30d` (boolean) contact property that your app computes and writes (`true`/`false`)

## Writing the activity signal

Pick one or two actions that represent real usage, not every click. For most SaaS apps, logins and a core action (send, create, publish, message) are enough.

Using the [Update contact endpoint](/api-reference/update-contact):

<CodeGroup>
  ```js API theme={"dark"}
  await fetch("https://app.loops.so/api/v1/contacts/update", {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.LOOPS_API_KEY}`,
    },
    body: JSON.stringify({
      email: user.email,
      userId: user.id,
      lastActive: Date.now(),
      isActive30d: true,
    }),
  });
  ```

  ```ts JavaScript SDK theme={"dark"}
  import { LoopsClient } from "loops";

  const loops = new LoopsClient(process.env.LOOPS_API_KEY!);

  await loops.updateContact({
    email: user.email,
    userId: user.id,
    properties: {
      lastActive: Date.now(),
      isActive30d: true,
    },
  });
  ```
</CodeGroup>

The API accepts custom properties at the top level of the body. The [JavaScript SDK](/sdks/javascript) nests them under `properties`.

After a contact already exists in Loops with a `userId`, you can send later updates with only `userId`.

You can also sync this property via [Segment](/integrations/segment), [PostHog](/integrations/posthog), or [RudderStack](/integrations/rudderstack) if you already track these events.

<Note>
  `lastActive` needs to be a Date-type [contact property](/contacts/properties).
  Send a millisecond timestamp and Loops will parse it.
</Note>

Because Loops filters do not support dynamic date expressions like "today minus 30 days," compute the rolling window in your app and write `isActive30d` directly.

Common patterns:

* **On activity events**: set `lastActive` to now (or a date you save in your database) and `isActive30d` to `true`
* **Daily backfill job**: once per day, set `isActive30d = (lastActive >= now - 30 days)` for all relevant contacts

This keeps segment membership accurate without relying on dynamic date math in the Loops UI.

Example daily backfill (Node.js + JavaScript SDK):

```ts syncToLoops.ts theme={"dark"}
import { LoopsClient } from "loops";

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

const loops = new LoopsClient(process.env.LOOPS_API_KEY!);
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;

async function syncActive30d(users: UserActivityRow[]) {
  const cutoff = Date.now() - THIRTY_DAYS_MS;

  for (const user of users) {
    const isActive30d =
      user.lastActiveAtMs !== null && user.lastActiveAtMs >= cutoff;

    await loops.updateContact({
      email: user.email,
      userId: user.userId,
      properties: {
        // Keep this in sync with your DB source of truth.
        lastActive: user.lastActiveAtMs,
        isActive30d,
      },
    });
  }
}
```

## Build the segment

Go to the [Audience page](https://app.loops.so/audience) and in the filters add:

* `isActive30d` `Is true`
* `Subscribed` `Is true`

Click **Save segment** and name it "Active 30-day users".

Because [segments update automatically](/contacts/filters-segments#audience-segments), membership refreshes as `isActive30d` changes. You never need to rebuild it.

## Using the segment

### For product update campaigns

Send product updates to this segment instead of your full audience. Open rates will be substantially higher, which protects deliverability. See [Product updates](/guides/product-updates) for copy guidance.

### For reputation warming

Early on, or after migrating domains ([guide](/deliverability/migrating-domains)), this segment is the right audience for campaign #1. See [Your first onboarding emails](/guides/onboarding-emails#targeted-campaign) for the full warming flow.

### As an audience filter inside a workflow

Inside a [workflow](/workflows), apply the same filter on a send step. For example, a workflow triggered by **Event received** (such as a weekly feature-highlight event you fire from a cron job) can use this filter so the email only reaches engaged users. See the available [workflow triggers](/workflows/triggers).

## Variations

* **7-day active**: add `isActive7d` as a separate boolean your app computes and writes (`true`/`false`).
* **Inactive (inverse)**: use `isActive30d` `Is false` for re-engagement. See [Churn risk segment](/guides/churn-risk-segment).
* **Power users**: combine with a count property like `sessionCount30d` `Greater than` `10`.

## Read more

<CardGroup>
  <Card title="Filters and segments" icon="filter-list" href="/contacts/filters-segments" />

  <Card title="Contact properties" icon="list" href="/contacts/properties" />

  <Card title="Product update emails" icon="megaphone" href="/guides/product-updates" />

  <Card title="Sender reputation" icon="shield" href="/deliverability/sending-reputation" />
</CardGroup>
