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
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' } })
{
"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"
}
}
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.
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 | Changed fields only: headcount, location, status | About $1.00 | Runs on its own |
| 3. Re-read each person’s profile | Contact.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 | 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
Each failure, and what happens next
| 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 |
| Re-check every address, not only the old ones. | ContactPoint.verifyEmail |
| Write to the people who moved at their new company. | Contact.follow |
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: 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
| 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, |
| 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 |
|---|
| 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
| 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, |
| 5. Suppress hard bounces | code | Code | Code reads the result back from where it landed | measured | $4.invalid, |
| 6. Save the new version | code | Code | Code reads the result back from where it landed | measured | $2, |
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
| Kind | What it touches |
|---|---|
| Systems | CRM, Mailboxes |
| Nouns | Collection, Organization, Contact, Contact point, Audience |
| Events | Collection.queried, Contact.enriched, Contact.suppressed, Audience.updated |
Reference
Inputs, outputs, its Event and its errors
| 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 before it spends, and with the Data calls and Accounts groups (refusals).