URL: /capabilities/find-customers/list-refresh · Id: `page:/capabilities/find-customers/list-refresh`

# List refresh

> Every month, each row on your list re-read: who moved jobs, which companies changed, and which addresses bounced, as a new version of the list.

Every month, each row on your list re-read: who moved jobs, which companies changed, and which addresses bounced, as a new version of the list.

```text title="Run it · Ask"
Refresh my outbound list and drop anyone who moved or bounced, spend up to $10, and show me the plan first.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Audience.refresh({ goal: 'Refresh my outbound list and drop anyone who moved or bounced, spend up to $10, and show me the plan first.', budget: { amount: 10, currency: 'USD' } })
```

```json title="Run it · Response"
{
  "mode": "try",
  "stored": false,
  "estimate": "About $2.30 per 500 rows",
  "sample": {
    "audience": "audiences/audience_31",
    "version": "4",
    "changed": "38",
    "moved": "11",
    "suppressed": "6",
    "rechecked": "171"
  }
}
```

- **Kind:** Workflow
- **Price:** $2.30 per 500 rows
- **Your time:** 3 minutes
- **Approval:** Runs on its own
- **Deepens:** [`process:outbound`](https://startupbuilder.dev/workflows/processes/outbound)

## What you get
- **500 rows** · Every month, each row on your list re-read: who moved jobs, which companies changed, and which addresses bounced, as a new version of the list. · [`noun:audience`](https://startupbuilder.dev/business/nouns/audience)
- `version` · The Audience’s new version; the old one stays, so a Play mid-sequence keeps its rows.
- `changed` · Rows whose title or company fields changed.
- `moved` · People who left the company, handed to Champion moved with their new employer.
- `suppressed` · Hard bounces, now on your Suppression list.
- `rechecked` · Addresses re-checked, with how many turned invalid.

**How long:** About 10 minutes.

## Trigger
It runs monthly, and at once on every hard bounce.

```ts
on.Message.bounced((message, $) => $.Audience.refresh({ audience: message.audience }))
```

## Steps
Your agent does these steps, one row per step; each step’s check is part of its row and its price.

| Step | Verb | Returns | Price | Approval |
| --- | --- | --- | --- | --- |
| 1. Read the list’s rows | `Collection.query` | Each row with when it was last read and last checked | Free | Runs on its own |
| 2. Re-read each company’s profile | [`Organization.enrich`](/code/data/organizations/enrich) | Changed fields only: headcount, location, status | About $1.00 | Runs on its own |
| 3. Re-read each person’s profile | [`Contact.enrich`](/code/data/people/enrich) | Current title and employer; a new employer marks the row `moved` | About $1.00 | Runs on its own |
| 4. Re-check addresses older than 90 days | [`ContactPoint.verifyEmail`](/code/data/people/verify-email) | `valid`, `catch-all`, `invalid` or `unknown` for about a third of the rows | About 30¢ | Runs on its own |
| 5. Suppress hard bounces | `Contact.suppress` | Every invalid address and every hard bounce on the Suppression list | Free | Runs on its own |
| 6. Save the new version | `Audience.update` | A new version of the Audience; moved rows go to Champion moved | Free | Runs on its own |

Calls: `Collection.query`, then `Organization.enrich`, then `Contact.enrich`, then `ContactPoint.verifyEmail`, then `Contact.suppress`, then `Audience.update`.

About $2.30 per 500 rows, each time it runs, capped at $4.00.

## Example: give it a goal
> Refresh my outbound list and drop anyone who moved or bounced, spend up to $10, and show me the plan first.

The plan it shows runs 6 steps, priced before it spends anything; Run it above shows the result it returns.

## When something fails

| When | Then |
| --- | --- |
| A company no longer resolves | Its rows are marked `gone`, kept on the old version and left out of the new one. |
| A person profile cannot be read | The row keeps its last reading and is read first next month. |
| In goal mode, rows were read in the last 30 days | The agent skips their company re-read and spends that on re-checking addresses, as a Decision: a bounce costs more than a stale title. |
| You call it twice in one month | The second call returns the month’s version: it is idempotent per Audience and month. |

Each step retries three times, then falls through to the next route; one item's failure lands in `skipped` with its reason, never failing the call. Before a step would cross `maxCost` it stops and returns what it has, with a `resume` link. Each step's idempotency key is the call's key plus the step and the item: `leaseline-audience-refresh-1-<step>-<item>`.

## Change it
| Ask | Compiles to |
| --- | --- |
| Drop anyone who moved instead of following them. | [`Audience.refresh`](/capabilities/find-customers/list-refresh) |
| Re-check every address, not only the old ones. | [`ContactPoint.verifyEmail`](/code/data/people/verify-email) |
| Write to the people who moved at their new company. | [`Contact.follow`](/capabilities/keep-and-grow/champion-moved) |

```ts
import { $, workflow } from 'startup-builder'

export default workflow({
  name: 'audience-refresh',
  verb: 'Audience.refresh',
  input: { audience: 'string', recheckAfter: 'string', moved: "'follow' | 'drop'" },
  trigger: { schedule: 'monthly' },
  maxCost: 4,
  execute: async (ctx, { audience, recheckAfter = '90d', moved = 'follow' }) => {
    const rows = await ctx.step('rows', () => $.Collection.query({ from: 'audience', id: audience, members: true }))
    const orgs = await ctx.step('orgs', () => $.Organization.enrich({ hostnames: rows.map((r) => r.domain), fields: ['profile'], changedOnly: true, maxCost: 1.1 }))
    const people = await ctx.step('people', () => $.Contact.enrich({ contacts: rows.map((r) => r.contact), fields: ['title', 'organization'], maxCost: 1.1 }))
    const stale = rows.filter((r) => olderThan(r.checkedAt, recheckAfter))                                  // a declared rule
    const checks = await ctx.step('recheck', () => $.ContactPoint.verifyEmail({ emails: stale.map((r) => r.email), maxCost: 0.4 }))
    await ctx.step('suppress', () => $.Contact.suppress({ contacts: bounced(checks) }))
    return ctx.step('version', () => $.Audience.update({ id: audience, members: merge(rows, orgs, people, checks, { moved }) }))
  },
})
```

Extend it with `extends: 'Audience.refresh'` and `base()`, or [compose your own](/code/compose) from these steps.

## Works with
**Plays that order it:** [Family opt-in](/plays/family-opt-in), [lead reactivation](/plays/lead-reactivation), [Owned newsletter](/plays/owned-newsletter).

It serves [Find your first customers](/capabilities/find-customers), and runs in [Hands-on](/strategy/hands-on). It deepens [`process:outbound`](https://startupbuilder.dev/workflows/processes/outbound).

## For agents

| Fact | Value |
| --- | --- |
| Guide | `Audience.refresh` |
| Guide approval | `limits` |
| Goal | Refresh my outbound list and drop anyone who moved or bounced, spend up to $10, and show me the plan first. It may change counts, the order of steps, soft filters and the route a read takes, and it stops early when the goal is met. It never relaxes your words, raises its own budget, answers its own approval, calls a Play or acts outside your standing approval. |
| Trigger | `on.Message.bounced((message, $) => $.Audience.refresh({ audience: message.audience }))` |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | `leaseline-audience-refresh-1` |
| Lookups | A `find:` input asks unless exactly one record fits |

| Step | Action class | Seat rule that would hold it |
| --- | --- | --- |
| 1. Read the list’s rows | None | None. |
| 2. Re-read each company’s profile | None | None. |
| 3. Re-read each person’s profile | None | None. |
| 4. Re-check addresses older than 90 days | None | None. |
| 5. Suppress hard bounces | None | None. |
| 6. Save the new version | None | None. |

The amounts live once, on the hub ([approvals](https://startupbuilder.dev/agents/approvals)).

| Step | Function | Doer | Checker | Assurance | Inputs |
| --- | --- | --- | --- | --- | --- |
| 1. Read the list’s rows | `code` | Code | Code checks each result against its declared type | `measured` | `$0.audience` |
| 2. Re-read each company’s profile | `code` | Code | Code checks each result against its declared type | `measured` | `$1.rows` |
| 3. Re-read each person’s profile | `code` | Code | Code checks each result against its declared type | `measured` | `$1.rows` |
| 4. Re-check addresses older than 90 days | `code` | Code | Code checks each result against its declared type | `measured` | `$1.stale, $0.recheckAfter` |
| 5. Suppress hard bounces | `code` | Code | Code reads the result back from where it landed | `measured` | `$4.invalid, bounces` |
| 6. Save the new version | `code` | Code | Code reads the result back from where it landed | `measured` | `$2, $3, $4, $5` |

The doer is never the checker, and the weakest assurance on the path is the whole call’s.

## Under the hood

| Kind | What it touches |
| --- | --- |
| Systems | [CRM](/code/apps/crm), [Mailboxes](/code/apps/mailboxes) |
| Nouns | [`noun:collection`](https://startupbuilder.dev/business/nouns/collection), [`noun:organization`](https://startupbuilder.dev/business/nouns/organization), [`noun:contact`](https://startupbuilder.dev/business/nouns/contact), `noun:contact-point`, [`noun:audience`](https://startupbuilder.dev/business/nouns/audience) |
| Events | `Collection.queried`, `Contact.enriched`, `Contact.suppressed`, `Audience.updated` |

## Reference

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `audience` | `Audience` | Required | The list to refresh. |
| `recheckAfter` | `string` | `'90d'` | Re-check an address once its last check is older than this. |
| `moved` | `'follow' \| 'drop'` | `'follow'` | A person who changed jobs is handed to Champion moved, or dropped. |
| `maxCost` | `number` | `4.00` | The most this call spends each time it runs, in USD. It stops before a step would cross it and returns what it has, with a `resume` link. |

Emits `Audience.refreshed`. Refuses with [`max-cost`](/code/refusals#max-cost) before it spends, and with the Data calls and Accounts groups ([refusals](/code/refusals)).