Skip to content

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.

Kind
Workflow
Price
$2.30 per 500 rows
Your time
3 minutes
Approval
Runs on its own
Deepens
Outbound
Audience.refresh
You, to your agent

Refresh my outbound list and drop anyone who moved or bounced, spend up to $10, and show me the plan first.

curl -X POST "https://api.sb/leaseline.app/audiences/refresh?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"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"}}'
npx startup-builder do Audience.refresh --try --data '{"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"}}'
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.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' } })"
  }
}
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' } })
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"
  }
}
Try mode reads, stores nothing and spends nothingAPI referenceIts Nouns

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

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

StepVerbReturnsPriceApproval
1. Read the list’s rowsCollection.queryEach row with when it was last read and last checkedFreeRuns on its own
2. Re-read each company’s profileOrganization.enrichChanged fields only: headcount, location, statusAbout $1.00Runs on its own
3. Re-read each person’s profileContact.enrichCurrent title and employer; a new employer marks the row movedAbout $1.00Runs on its own
4. Re-check addresses older than 90 daysContactPoint.verifyEmailvalid, catch-all, invalid or unknown for about a third of the rowsAbout 30¢Runs on its own
5. Suppress hard bouncesContact.suppressEvery invalid address and every hard bounce on the Suppression listFreeRuns on its own
6. Save the new versionAudience.updateA new version of the Audience; moved rows go to Champion movedFreeRuns 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

Each failure, and what happens next
WhenThen
A company no longer resolvesIts rows are marked gone, kept on the old version and left out of the new one.
A person profile cannot be readThe row keeps its last reading and is read first next month.
In goal mode, rows were read in the last 30 daysThe 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 monthThe 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

AskCompiles to
Drop anyone who moved instead of following them.Audience.refresh
Re-check every address, not only the old ones.ContactPoint.verifyEmail
Write to the people who moved at their new company.Contact.follow
TypeScript
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 from these steps.

Works with

Plays that order it: Family opt-in, lead reactivation, Owned newsletter.

It serves Find your first customers, and runs in Hands-on. It deepens Outbound.

For agents

For agents: the guide, its approvals, retries and lookups
FactValue
GuideAudience.refresh
Guide approvallimits
GoalRefresh 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.
Triggeron.Message.bounced((message, $) => $.Audience.refresh({ audience: message.audience }))
RetryThree times with backoff, then the next route; the same idempotency key never charges, sends or posts twice
Idempotency keyleaseline-audience-refresh-1
LookupsA find: input asks unless exactly one record fits
Step
1. Read the list’s rows
2. Re-read each company’s profile
3. Re-read each person’s profile
4. Re-check addresses older than 90 days
5. Suppress hard bounces
6. Save the new version

The amounts live once, on the hub (approvals).

Who does each step, and how it is checked
StepFunctionDoerCheckerAssuranceInputs
1. Read the list’s rowscodeCodeCode checks each result against its declared typemeasured$0.audience
2. Re-read each company’s profilecodeCodeCode checks each result against its declared typemeasured$1.rows
3. Re-read each person’s profilecodeCodeCode checks each result against its declared typemeasured$1.rows
4. Re-check addresses older than 90 dayscodeCodeCode checks each result against its declared typemeasured$1.stale, $0.recheckAfter
5. Suppress hard bouncescodeCodeCode reads the result back from where it landedmeasured$4.invalid, bounces
6. Save the new versioncodeCodeCode reads the result back from where it landedmeasured$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

Under the hood: the Systems, Nouns and Events it touches
KindWhat it touches
SystemsCRM, Mailboxes
NounsCollection, Organization, Contact, Contact point, Audience
EventsCollection.queried, Contact.enriched, Contact.suppressed, Audience.updated

Reference

Inputs, outputs, its Event and its errors
NameTypeDefaultMeaning
audienceAudienceRequiredThe list to refresh.
recheckAfterstring'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.
maxCostnumber4.00The 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 before it spends, and with the Data calls and Accounts groups (refusals).

Contents