# Contact enrichment

Enrichment is the least glamorous line item in a GTM stack and the one that quietly decides whether everything downstream works. A CRM row with a name and a company domain is not actionable. The same row with a verified work email, a title, a role history and firmographics is a segment, a routing rule and a personalization input at once.

The problem is rarely the data. It is that enrichment is sold as a seat-based subscription with an annual commitment, priced for a sales team of thirty, when what you actually need is a function you can call from a webhook a few thousand times a month.

## The pipeline

```
contact.find      →  work email from a name and a domain
contact.verify    →  is it deliverable, or a catch-all
person.enrich     →  title, role history, location, about
company.enrich    →  industry, headcount, revenue, funding, location
```

Call the ones you need. A form-fill handler usually wants all four; a re-verification cron wants only the second.

## If you need to find the people first

Use `people.search` with the company's website domain and the job titles you need. Pair `companies` with `company_domains` when supplying both, and review the returned employer, role and quality flags before enrichment. The LinkedIn route checks employer identity and can return no match when the evidence is insufficient; the database route may already supply an email. Look up only missing emails, using the matched employer's domain and the person's actual name. Follow the [people-search guide](/docs/people-search) for examples and recovery steps.

## Step 1: name plus domain in, email out

```bash
curl -X POST https://api.jell.run/v1/run \
  -H "Authorization: Bearer $JELL_API_KEY" \
  -H "Idempotency-Key: enrich-crm-4821" \
  -d '{
    "capability": "contact.find",
    "input": {"first_name": "Alex", "last_name": "Rivera", "company_domain": "acme.com"},
    "routing": {"strategy": "best_value", "max_cost": "0.05"}
  }'
```

The response carries the email, a status, whether employment was verified, the MX provider, and the person and company objects the provider matched on. That last part matters: it tells you whether the provider found *your* Alex Rivera or a different one.

Six providers back this capability. On `auto` routing a miss falls through to the next one, up to three attempts, and only the provider that delivers is charged. Every attempt is listed in the response, so a low hit rate is diagnosable rather than mysterious.

**Billing note that changes the unit economics:** an unmatched lookup is not billed. The reservation is released in full. Enrichment coverage is never 100%, and paying only for hits is the difference between a predictable cost per enriched row and a bill that scales with your misses.

## Step 2: verify, always

```bash
curl -X POST https://api.jell.run/v1/run \
  -H "Authorization: Bearer $JELL_API_KEY" \
  -d '{"capability": "contact.verify", "input": {"email": "alex.rivera@acme.com"}}'
```

Three outcomes, and the third is the one people get wrong:

- `valid`: the mail server accepted the address. Safe to send.
- `invalid`: the server rejected it. Never send. Mark the row dead.
- `unknown`: the server would not say, usually because the domain is `catch_all` and accepts every address including nonsense.

Treating `unknown` as `invalid` throws away real contacts, and treating it as `valid` puts unverifiable addresses in a warm-up sequence. Keep it as its own state and route it to a lower-volume sender.

Emails decay at roughly 2 to 3% a month as people change jobs. A quarterly re-verification pass over the active list costs a fraction of a cent per row and prevents the slow bounce-rate creep that gets a domain throttled.

## Step 3: the person behind the address

`person.enrich` takes a public LinkedIn profile URL, or an email, and returns the full professional profile: headline, about, location, and the role history:

```bash
curl -X POST https://api.jell.run/v1/run \
  -H "Authorization: Bearer $JELL_API_KEY" \
  -d '{"capability": "person.enrich",
       "input": {"profile_url": "https://www.linkedin.com/in/alexrivera"}}'
```

Role history is the field most enrichment integrations ignore and the one that pays. Tenure in the current role tells you whether someone is still changing things or has settled. A person eight months into a new VP role is buying; the same person at four years is not.

Need a phone number as well, `contact.phone` takes the work email or the profile URL and returns a mobile. It costs materially more than an email lookup, so gate it behind a qualification score rather than running it on the whole list.

## Step 4: the company around them

```bash
curl -X POST https://api.jell.run/v1/run \
  -H "Authorization: Bearer $JELL_API_KEY" \
  -d '{"capability": "company.enrich", "input": {"domain": "acme.com"}}'
```

Industry, employee range and count, founded year, location, revenue, funding, specialties and competitors. This is the segmentation layer: the fields your routing rules and your pricing tier read.

Cache it. Firmographics move slowly, and re-fetching the same domain for every contact at that company is the most common way to overspend on enrichment. One call per domain per quarter, one call per person.

## Wiring it into a real system

The shape that works is a queue, not a batch:

1. A row arrives (form fill, CRM webhook, list import) with at minimum a name and a domain.
2. Check your own cache first. Company data by domain, contact data by person.
3. Call `contact.find`, then `contact.verify` on the result.
4. Enrich the person and the company only for rows that passed verification.
5. Write back the fields plus the `run_id` of each call.

Storing the `run_id` is worth the column. It turns "where did this email come from and what did it cost" into a lookup against `/v1/runs`, which lists every attempt, the provider, the latency and the charge.

Set `Idempotency-Key` to your own row id. A retried webhook then costs nothing rather than double-enriching.

## Where it breaks

- **Coverage varies by region and company size.** Hit rates are highest for mid-market and enterprise in North America and Western Europe, lowest for small companies and for regions where professional profiles are less public.
- **Generic inboxes are not people.** `info@`, `hello@` and `sales@` will verify as valid and are useless for personalization. Filter them before they reach a sequence.
- **Personal email addresses are a different risk profile.** Work addresses at a company domain are the safe default for B2B outreach in most jurisdictions.
- **Re-enrichment is not free.** Set a TTL per field type instead of refreshing everything on a schedule.

## Run it as an agent

```bash
jell run -c contact.find -i '{"first_name":"Alex","last_name":"Rivera","company_domain":"acme.com"}'
jell run -c contact.verify -i '{"email":"alex.rivera@acme.com"}'
jell run -c company.enrich -i '{"domain":"acme.com"}'
```

`/v1/inspect` returns the exact price and the billing conditions before any of these run, so an agent can quote the cost of enriching a list before it starts spending.

## FAQ

### How often should enrichment be refreshed?

Set a TTL per field type rather than refreshing whole rows. Emails decay 2 to 3% a month as people change jobs, so quarterly re-verification on the active list is the right cadence. Firmographics move slowly and a yearly refresh is usually enough. Role history changes when someone moves, which the verification pass will surface first. Refreshing everything on one schedule is the most common way to overspend.

### What is the difference between contact.find and person.enrich?

`contact.find` answers one question: what is this person's work email, given a name and a company domain. `person.enrich` starts from a profile URL or an email and returns the professional profile: headline, about, location and role history. They are different jobs. Most pipelines call the first to reach someone and the second to have something to say.

### Should I enrich everyone or only qualified rows?

Enrich email and company data broadly, since both are cheap and both feed segmentation. Gate phone lookups behind a qualification score, because `contact.phone` costs roughly an order of magnitude more per row. The pattern that works is a cheap pass over everything, a scoring step, then an expensive pass over the top tier only.

### Why store the run_id?

Because it turns provenance into a lookup. `/v1/runs` returns the provider, every attempt, the latency and the exact charge for that call. Without it, "where did this email come from and what did it cost" is unanswerable six months later, which matters both for cost analysis and for any data-subject request you have to respond to.

### Does an unmatched lookup cost anything?

No. The reservation is placed before the provider call and released in full when nothing is found. That is the `unbilled_nomatch` outcome and it appears in the run's `attempts` array, so a low hit rate on a segment is visible rather than just expensive.

### Is there one API that gives me email finder, email verification and company data in a single call?

One API and one key, three calls: `contact.find` for the email, `contact.verify` for deliverability and `company.enrich` for the firmographics. They are separate capabilities on purpose, because each is routed across different providers and priced on its own outcome (a miss on the finder is free, a verification is not). An agent chains them in one step; the invoice is one balance either way.

### How can my AI agent look up a person's work email from their name and company?

Call `contact.find` with the first name, last name and company domain. Routing tries up to six providers in order and stops at the first hit, and an unmatched lookup is not billed. Through the MCP server or the SKILL.md the agent gets the call as a native tool with the price quoted first, and should run `contact.verify` on the result before the address touches a sender.
