URL: /capabilities/find-customers/build-a-verified-list · Id: `page:/capabilities/find-customers/build-a-verified-list`

# Build a verified list

> One sentence in, 100 checked work emails out, each at the right company, with the quote that shows the fit.

One sentence in, 100 checked work emails out, each at the right company, with the quote that shows the fit.

```text title="Run it · Ask"
Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1,000 units, spend up to $4, and show me the plan first.
```

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

const result = await $.try.Audience.build({ goal: 'Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1,000 units, spend up to $4, and show me the plan first.', budget: { amount: 4, currency: 'USD' } })
```

**Plan.** (1) `Prospect.count` with the company filters, free, to size each query before a row is bought. (2) `Prospect.find` from the query that sizes best, then screen each company against your sentence. (3) `ContactPoint.findEmail`, cheapest route first, at most 3¢ a person. (4) `ContactPoint.verifyEmail`, free routes first. (5) Repeat until 50 pass. Estimate 45¢ of $4.00.

**Receipt.** 136 calls, 29¢ spent, 50 of 50 verified.

- **Kind:** Workflow
- **Price:** $1.38 per 100 emails
- **Your time:** 8 minutes
- **Approval:** Runs on its own
- **Deepens:** [`process:outbound`](https://startupbuilder.dev/workflows/processes/outbound)

## What you get
- **100 checked emails** · One sentence in, 100 checked work emails out, each at the right company, with the quote that shows the fit. · [`noun:audience`](https://startupbuilder.dev/business/nouns/audience)
- `audience` · The saved list, as an Audience you can enroll, sync to an ad account or refresh.
- `rows` · One row per person: company, domain, person, title, email and its check result, with the quote that shows fit.
- `funnel` · How many matched, fit, had a person, had an email at the right company, and checked valid: the counts that tell you which filter to loosen.
- `skipped` · Every row it dropped, by reason and count: no email found, wrong company, catch-all, invalid, already a contact.
- `cost` · What it spent each time it ran, and its cap.

**How long:** About 3 to 6 minutes; the email finds set the pace.

## Trigger
It runs when you call it. List refresh re-reads the Audience each month and on every bounce.

```ts
every.month(($) => $.Audience.refresh({ audience: 'audiences/audience_31' }))
```

## 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. Count the market | [`Organization.count`](/code/data/organizations/count) | How many companies match, free, before a row is bought | Free | Runs on its own |
| 2. Find the companies and ask each site the fit question | [`Organization.find`](/code/data/organizations/find) | Three companies for every email wanted, each with `fit` and a quote code finds verbatim on the page | About 60¢ | Runs on its own |
| 3. Find the people | [`Prospect.find`](/code/data/people/find) | One person per strong-fit company with a matching title; names and titles are free | Free | Runs on its own |
| 4. Find each work email | [`ContactPoint.findEmail`](/code/data/people/find-email) | An address for about 85 in 100, in fit order; a miss is free | About 54¢ | Runs on its own |
| 5. Drop wrong-company addresses and find again | [`ContactPoint.findEmail`](/code/data/people/find-email) | Any address whose domain is not the account’s domain dropped, and found again from the person’s current profile URL; about 1 in 12 | About 4.5¢ | Runs on its own |
| 6. Check each email | [`ContactPoint.verifyEmail`](/code/data/people/verify-email) | `valid`, `catch-all`, `invalid` or `unknown`; catch-all and unknown cost nothing | About 20¢ | Runs on its own |
| 7. Drop who you already know | `Collection.query` | The rows not already a contact, a customer, suppressed or on an open deal | Free | Runs on its own |
| 8. Save the Audience | [`Audience.create`](/code/apps/ads/create-audience) | One Audience of `size` rows, linked to this call | Free | Runs on its own |

Calls: `Organization.count`, then `Organization.find`, then `Prospect.find`, then `ContactPoint.findEmail`, then `ContactPoint.verifyEmail`, then `Collection.query`, then `Audience.create`.

About $1.38 per 100 checked emails, each time it runs, capped at $2.00.

## Example: give it a goal
> Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1,000 units, spend up to $4, and show me the plan first.

Run it above shows the plan it showed before it spent anything, and the receipt of every call.

## When something fails

| When | Then |
| --- | --- |
| A site cannot be read | The company reads `fit: unknown` and stays out of the list; it is counted under `skipped` as `site-unreadable`. |
| No email is found for a person | The row is skipped as `no-email-found`, and the miss costs nothing. |
| A find returns an address at another company | Code drops it and finds again from the person’s current profile URL on the account’s own domain. In the 50-lead goal, 4 first finds pointed at other companies (one at a county government domain), so the run reads 50 verified, 46 at the right company on the first find, 4 replaced. |
| The check says catch-all or unknown | It settles at $0 and stays out unless `accept` includes `catch-all`; a deep check (`ContactPoint.verifyEmail` with `depth: deep`) resolves it for about 0.4¢. |
| `size` cannot be reached under `maxCost` | It returns what it found, with a `max-cost` skip count and a `resume` link that carries on from the last company read. |

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-build-1-<step>-<item>`.

## Change it
| Ask | Compiles to |
| --- | --- |
| Count catch-all addresses too, in their own batch. | [`Audience.build`](/capabilities/find-customers/build-a-verified-list) |
| Only companies hiring a leasing coordinator this month. | [`Organization.find`](/code/data/organizations/find) |
| Keep this list fresh every month and drop anyone who bounces. | [`Audience.refresh`](/capabilities/find-customers/list-refresh) |

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

/** An address is at the right company only on the account's own domain. */
const sameDomain = (address: string, hostname: string) => address.split('@')[1]?.toLowerCase() === hostname.replace(/^www\./, '').toLowerCase()

export default workflow({
  name: 'audience-build',
  verb: 'Audience.build',
  input: { icp: 'string', question: 'string?', titles: 'string[]', size: 'integer', perCompany: 'integer', accept: "('valid' | 'catch-all')[]" },
  maxCost: 2,
  execute: async (ctx, { icp, question, titles, size = 100, perCompany = 1, accept = ['valid'] }) => {
    const market = await ctx.step('count', () => $.Organization.count({ where: { description: icp } }))           // free
    if (market.count < size * 3) return ctx.refuse('market-too-small', 'Widen the customer sentence, or lower size')
    const orgs = await ctx.step('companies', () => $.Organization.find({ where: { description: icp }, question, limit: size * 3, maxCost: 0.7 }))
    const fit = orgs.organizations.filter((o) => o.fit === 'strong' && o.evidence.quoteFound)                  // a declared rule
    const people = await ctx.step('people', () => $.Prospect.find({ organizations: fit.map((o) => o.hostname), titles, perCompany }))
    const checked = []
    for (const p of people.prospects) {                                                                        // fit order
      if (checked.filter((c) => accept.includes(c.status)).length >= size) break                               // stop at size
      let found = await ctx.step(`find:${p.$id}`, () => $.ContactPoint.findEmail({ prospect: p.$id, maxCost: 0.02 }))
      if (found.address && !sameDomain(found.address, p.organization.hostname))                                // wrong company
        found = await ctx.step(`refind:${p.$id}`, () => $.ContactPoint.findEmail({ prospect: p.$id, from: p.profileUrl, domain: p.organization.hostname, maxCost: 0.02 }))
      if (!found.address || !sameDomain(found.address, p.organization.hostname)) continue                      // skipped: wrong-company
      checked.push(await ctx.step(`verify:${p.$id}`, () => $.ContactPoint.verifyEmail({ address: found.address, maxCost: 0.005 })))
    }
    const reachable = checked.filter((c) => accept.includes(c.status))
    const fresh = await ctx.step('dedupe', () => $.Collection.query({ minus: ['contacts', 'customers', 'suppressions', 'deals'], rows: reachable, key: 'email' }))
    return ctx.step('save', () => $.Audience.create({ name: icp, members: fresh.rows.slice(0, size), from: ctx.action }))
  },
})
```

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

## Works with
**Plays that order it:** [Account ads](/plays/account-ads), [Benchmark rows](/plays/benchmark-rows), [Buying committee](/plays/buying-committee), [Cold email](/plays/cold-email), [Show up in person](/plays/field-presence), [LinkedIn outbound](/plays/linkedin-outbound), [Proof-of-work outreach](/plays/proof-of-work-outreach).

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.build` |
| Guide approval | `limits` |
| Goal | Find 50 verified heads of operations (or COO, VP Operations, Director of Property Operations) at US residential property management companies managing roughly 100 to 1,000 units, spend up to $4, 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 | `every.month(($) => $.Audience.refresh({ audience: 'audiences/audience_31' }))` |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | `leaseline-audience-build-1` |
| Lookups | A `find:` input asks unless exactly one record fits |

| Step | Action class | Seat rule that would hold it |
| --- | --- | --- |
| 1. Count the market | None | None. |
| 2. Find the companies and ask each site the fit question | None | None. |
| 3. Find the people | None | None. |
| 4. Find each work email | None | None. |
| 5. Drop wrong-company addresses and find again | None | None. |
| 6. Check each email | None | None. |
| 7. Drop who you already know | None | None. |
| 8. Save the Audience | None | None. |

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

| Step | Function | Doer | Checker | Assurance | Inputs |
| --- | --- | --- | --- | --- | --- |
| 1. Count the market | `code` | Code | Code checks each result against its declared type | `measured` | `$0.icp` |
| 2. Find the companies and ask each site the fit question | `code` | Code | Code finds the quoted evidence on the page | `measured` | `$0.icp, $0.question` |
| 3. Find the people | `code` | Code | Code checks each result against its declared type | `measured` | `$2.organizations, $0.titles` |
| 4. Find each work email | `code` | Code | Code checks each result against its declared type | `measured` | `$3.prospects` |
| 5. Drop wrong-company addresses and find again | `code` | Code | Code compares each address’s domain with the account’s own domain, after redirects, and keeps only a match | `measured` | `$4.addresses, $2.organizations` |
| 6. Check each email | `code` | Code | Code checks each result against its declared type | `measured` | `$5.addresses` |
| 7. Drop who you already know | `code` | Code | Code checks each result against its declared type | `measured` | `$6.valid` |
| 8. Save the Audience | `code` | Code | Code reads the result back from where it landed | `measured` | `$7.rows, $0.size` |

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 | [Ads](/code/apps/ads) |
| Nouns | [`noun:organization`](https://startupbuilder.dev/business/nouns/organization), [`noun:prospect`](https://startupbuilder.dev/business/nouns/prospect), `noun:contact-point`, [`noun:collection`](https://startupbuilder.dev/business/nouns/collection), [`noun:audience`](https://startupbuilder.dev/business/nouns/audience) |
| Events | `Collection.queried`, `Audience.created` |

## Reference

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `icp` | `string` | Required | Your customer in one sentence: who they are, where, how big. Each phrase is a hard filter. |
| `question` | `string` | Required | Asked of each company’s own site; returns `fit` and the quote that answers it. Leave it out to keep every match. |
| `titles` | `string[]` | Required | The titles to reach at each company, in the buyer’s own words. |
| `size` | `integer` | `100` | Checked emails wanted. It stops when it has them. |
| `perCompany` | `integer` | `1` | People per company. Two reaches a second buyer when the first does not answer. |
| `accept` | `('valid' \| 'catch-all')[]` | `['valid']` | Which check results count. A catch-all address goes in its own batch only when you add it here. |
| `maxCost` | `number` | `2.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.built`. Refuses with [`max-cost`](/code/refusals#max-cost) before it spends, and with the Data calls and Accounts groups ([refusals](/code/refusals)).