Guide
Leads and enrichment
The lead pool is shared across tenants; what is private is what your organisation has revealed, saved and converted. Those three verbs are different operations with different costs, and confusing them is the single most common source of “why can’t I email this person”.
This guide searches the pool, spends a real reveal from a real quota, crawls a company website, and converts a lead into a mailable contact. Every response below came from the running service, with the people in it replaced — see the note under “Search the pool”.
Search the pool
/leads/searchcurl -s -X POST "$API/leads/search" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"q":"engineer","limit":3}'{
"success": true,
"data": {
"items": [
{
"pool_id": "9c1d4e07-2a55-5b83-9f16-4d8e0b73ca21",
"full_name": "Dana Keller",
"title": "Marketing Director",
"seniority": "director",
"department": "marketing",
"email_masked": "d•••@northwind.example",
"email_status": "verified",
"has_email": true,
"phone_count": 1,
"location": "Edinburgh, GB",
"score": { "value": 62 },
"company": {
"name": "Northwind Systems",
"domain": "northwind.example",
"industry": "Computer Software",
"industries_more": 0,
"employee_count": 350,
"employee_range": "201-500",
"keywords": ["devops", "automated deployment"],
"keywords_more": 1
},
"email_revealed": true,
"email": "[email protected]",
"phone": "+44 20 7946 0102",
"linkedin_url": "https://www.linkedin.com/in/example-profile",
"phone_available": true,
"saved_lead_id": "6b2f8a14-77c3-4de9-b105-3ea9c5148d77"
}
],
"next_cursor": "NjI.36d9e92d-4545-5df1-9ac0-99f2a50aa6e7",
"result_type": "person",
"sort": { "field": "score", "desc": true },
"total": 260,
"total_display": "260",
"total_is_estimate": false,
"search_backend": "go-node"
}
}data.items, not at the top level. Search, facets, quota and reveal all answer {success, data}. The company block also carries industries_more, a count of industries beyond the one shown, alongside the equivalent keywords_more.emailis only present whenemail_revealedis true. Otherwise you getemail_masked, andphoneandlinkedin_urlcome backnulleven whenphone_availableis true. The raw value never leaves the server unless it has been paid for.saved_lead_idis non-null when your org already saved this person — useful for rendering “already in your list” without a second query.- Paginate with
next_cursor, not an offset. Pass it back ascursoron the next request. total_is_estimatetells you whethertotalcan be trusted as an exact count.search_backendisgo-nodewhen the query was answered by the Go pool engine andfastapi-ormwhen it fell through to the Python path. Both read the same database, so it is a performance detail, not a data one — but see the note below for the one case where the choice is not about performance at all.
Read email_status before you mail anyone
| email_status | What it means | Safe to enrol? |
|---|---|---|
verified | The address was checked and accepted. | Yes |
unverified | Known but not checked — including anything a site crawl found, because finding a string on a web page is not verification. | Only with a low volume and a close eye on bounces |
guessed | Inferred from a pattern, not observed. | No — these are the bounces that pause a mailbox |
Facets
Facets return counts per value for the same filter set, which is what makes a search refinable rather than a lottery.
/leads/facetscurl -s -X POST "$API/leads/facets" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"q":"engineer"}'{
"success": true,
"data": {
"seniority": [
{ "value": "director", "count": 134 }, { "value": "manager", "count": 47 },
{ "value": "founder", "count": 33 }, { "value": "c_suite", "count": 33 },
{ "value": "vp", "count": 13 }
],
"department": [
{ "value": "marketing", "count": 136 }, { "value": "sales", "count": 63 },
{ "value": "executive", "count": 33 }, { "value": "engineering", "count": 28 }
],
"country": [
{ "value": "GB", "count": 91 }, { "value": "AU", "count": 65 },
{ "value": "PH", "count": 56 }, { "value": "CA", "count": 24 },
{ "value": "NL", "count": 13 }, { "value": "SG", "count": 7 },
{ "value": "AE", "count": 4 }
],
"email_status": [
{ "value": "unverified", "count": 114 }, { "value": "verified", "count": 100 },
{ "value": "guessed", "count": 46 }
],
"industry": [
{ "value": "Computer Software", "count": 230 },
{ "value": "Computer & Network Security", "count": 30 }
],
"search_backend": "go-node"
}
}Saving a search
curl -s -X POST "$API/lead-searches" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"CMOs in software, AU + GB",
"filters":{"seniority":["c_suite"],"department":["marketing"],"country":["AU","GB"]}}'
{"success":true,"data":{"id":"4cca8e98-1493-484f-976f-9c51c720b58b",
"name":"CMOs in software, AU + GB"}}Reveal, and what it costs
Reveals are metered per organisation per calendar month, and the allowance is a real limit: the plan sets it. The free Self-Hosted tier includes 50 a month. The paid tiers carry a per-seat figure that pools across the workspace, so what you actually get is the plan’s quota multiplied by the seats you pay for. Past the allowance a reveal is refused with 402 rather than served.
A per-tenant override (ss_org_settings.lead_reveal_quota) can only tighten that number, never lift it. The ordering is deliberate: support sets an override to contain a workspace abusing the pool, and a plan upgrade must not quietly undo a limit somebody applied for a reason. Check the meter before you spend:
/leads/quotacurl -s "$API/leads/quota" -H "Authorization: Bearer $TOKEN"
{"success":true,"data":{"used":27,"allowance":2500000,"unlimited":false,"remaining":2499973,"display":"27 / 2500000"}}allowance rather than assuming a number. Branch on unlimited, not on the integers: every plan in the catalogue today is capped, but an uncapped allowance is reported as -1 with remaining at a large finite value, and code that compares those two directly reads it backwards./leads/revealspends one revealcurl -s -X POST "$API/leads/reveal" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"pool_person_id":"4a7e6d20-9b31-5c48-8e02-1f5b7c93da60"}'pool_person_id, singular. A plural pool_ids array returns 400 pool_person_id required— which is what happened on the first attempt while writing this.{
"success": true,
"data": {
"pool_id": "4a7e6d20-9b31-5c48-8e02-1f5b7c93da60",
"email": "[email protected]",
"phone": "+1 555 0147",
"linkedin_url": "https://www.linkedin.com/in/example-profile",
"quota": {
"used": 27, "allowance": 2500000, "unlimited": false,
"remaining": 2499973, "display": "27 / 2500000"
}
}
}| Status | When |
|---|---|
402 | Allowance exhausted. The detail is computed rather than fixed — “Reveal limit reached for this month ({used}/{allowance}).” — so on the free tier it reads “(50/50)”. It is written to be shown to a user unchanged, and it is a 402, not a 403: the caller has permission, the plan simply has nothing left. A client that collapses the two loses the difference between “upgrade” and “you may not”. |
404 | No such person in the pool. |
410 | The person exercised their right to erasure. Refused before charging, so a deleted record cannot burn quota. |
Save, then convert
- 1
Save — a snapshot, costing nothing
POST/leads/savecurl -s -X POST "$API/leads/save" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"pool_person_ids":["4a7e6d20-9b31-5c48-8e02-1f5b7c93da60"]}' {"success":true,"saved":1,"already_saved":0}A saved lead is frozen at save time. Title, company and score do not move underneath you when the pool is re-crawled, which is what makes a qualified list stay qualified.
- 2
Inspect the lead and any drift
GET/leads/{lead_id}{ "id": "6b2f8a14-77c3-4de9-b105-3ea9c5148d77", "pool_person_id": "9c1d4e07-2a55-5b83-9f16-4d8e0b73ca21", "full_name": "Dana Keller", "title": "Marketing Director", "email": "[email protected]", "email_status": "verified", "status": "converted", "erasure_pending": false, "converted_contact_id": "aebb8a37-9ed8-4fbb-9017-3a22e44a0892", "converted_at": "2026-08-05T19:06:59.127291+00:00", "pool": { "pool_id": "9c1d4e07-2a55-5b83-9f16-4d8e0b73ca21", "is_active": true, "email_revealed": true, "email_status": "verified", "title": "Marketing Director", "company_name": "Northwind Systems", "quality_score": 62, "last_verified_at": null }, "drift": [] }driftlists fields where your frozen snapshot now disagrees with the live pool row — someone changed job, say. Empty here. It is the signal to re-qualify before mailing an old list. - 3
Convert — the only way a record becomes mailable
POST/leads/convert-from-poolpool → contact in one stepcurl -s -X POST "$API/leads/convert-from-pool" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"pool_person_ids":["4a7e6d20-9b31-5c48-8e02-1f5b7c93da60"]}'{ "converted": 0, "revealed_now": 0, "already_converted": 1, "skipped_no_quota": 0, "skipped_no_email": 0, "skipped_erased": 0, "contact_ids": ["86df14c8-c914-4151-b6d6-a96819ed7b1c"], "quota": { "used": 27, "allowance": 2500000, "unlimited": false, "remaining": 2499973, "display": "27 / 2500000" }, "success": true }This is a re-run against a person already converted, which is why already_convertedis 1 andconvertedis 0 - convert is idempotent, still returns the existingcontact_ids, and left the meter unmoved at27 / 2500000. On a first conversion the two numbers swap.revealed_nowcounts people this call had to reveal: converting someone still masked spends quota, andskipped_no_quotais what stops that halfway through a batch.The other conversion routes:
POST /leads/{lead_id}/convertfor a single saved lead, andPOST /leads/bulk-convertfor many at once. They do not return the shape above. The full accounting block — and in particular thequotameter andcontact_ids— exists only onconvert-from-pool, because it is the only one of the three that can spend a reveal. Readresp["quota"]afterbulk-convertand you get aKeyError.POST /leads/bulk-convert -d '{"lead_ids":["7e1daee4-…"]}' {"success":true,"converted":0,"skipped_no_email":0,"already_converted":1} POST /leads/7e1daee4-…/convert -d '{}' {"success":true,"already_converted":true,"contact_id":"c9cf967b-…"}Both re-run on 2026-08-13 against a lead that was already converted, so neither wrote anything. The single-lead route has a second shape: on a lead it actually converts it answers {"success": true, "contact_id": "…", "reused_existing_contact": false}—already_convertedis the early return, andreused_existing_contactis true when a contact with that address already existed and was adopted rather than duplicated. Neither shape carries a quota block.So:
bulk-convertreports four counters and no quota, nocontact_ids, norevealed_now, noskipped_no_quotaand noskipped_erased— it operates on leads you already saved, whose emails are already revealed or already absent, so there is nothing left to meter. The single-lead route reports one contact and whether it reused one. Onlyconvert-from-poolreaches into the pool, and only it has a meter to report.
Enrichment
There are two different things called enrichment here.
The site crawler
Crawls a company’s own website and folds what it finds back into the pool. This was executed live against a real domain:
/leads/enrichcompany_id or a bare domaincurl -s -X POST "$API/leads/enrich" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"domain":"example.com"}'{
"success": true,
"company_id": "9f76e889-bfdb-4852-a5ca-499b3e20f322",
"company_created": true,
"crawled": 1,
"changed": ["company created", "keywords"],
"people_found": 0,
"people_added": 0,
"people_already_known": 0,
"people_skipped_erased": 0,
"elapsed_ms": 124,
"note": null
}example.com, which is a single static page, so this is close to the floor of what a crawl returns: one page fetched, a company row created, keywords filled and no people found. A real marketing site with a team page moves crawled and people_foundup. Note the envelope differs from search — enrich puts its counters at the top level beside success, rather than under data. This is the first run against that domain. Running it again returns company_created: false and an empty changed, because the row now exists and the crawler only fills gaps — which is the rule described below, visible in the response.The rules it enforces are worth knowing, because they are unusual:
- It only fills gaps.An existing description, keyword set or address is never overwritten by a crawl. A curated or verified value outranks a scrape, and a “refresh” that quietly replaced good data with worse data would be impossible to notice and impossible to undo.
- Erasure is checked before every insert. Without that, the next crawl would resurrect exactly the people you were asked to forget.
people_skipped_erasedcounts them. - Provenance is recorded— which URL, which fields, when. “Where did this come from” stays answerable.
- Nothing is invented. A name is derived from an address only when the local part is plausibly a name; role accounts (
info@,sales@) are dropped entirely; and every discovered address is recorded asunverified, because finding a string on a web page is not verification.
Contact enrichment on your own key
Separately, POST /contacts/enrich fills gaps on contacts you already have. It uses Hunter.io when your organisation has connected a Hunter key as an enrichment integration, and otherwise falls back to deterministic email-pattern inference.
curl -s -X POST "$API/contacts/enrich" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"contact_ids":["86df14c8-c914-4151-b6d6-a96819ed7b1c"]}'Erasure
/leads/erasureCommon problems
400 pool_person_id required on a reveal
The field is singular: {"pool_person_id": "…"}. Reveal takes one person per call. Batch operations live on /leads/save and /leads/convert-from-pool, which take pool_person_ids as an array.
A sequence enrolment skips everyone with skipped_no_email
Saved leads are not contacts. Nothing in the leads area is mailable until it has been converted, and a lead that was saved while masked has no address to convert. Reveal first — which back-fills the saved row — then convert, then enrol the resulting contact_ids.
Quota went down but you got nothing useful
Check email_status on the row before spending. Revealing a guessed address costs the same as revealing a verified one and gives you a hypothesis. Facet on email_status and filter to verified before you start revealing at volume.
A crawl reported people_found: 0 on a company that clearly lists staff
Only a handful of pages are fetched per run (crawled tells you how many), and role accounts are dropped by design. A team page rendered entirely by client-side JavaScript will also come back empty, because the crawler reads what the server returns.
A saved list stopped converting well
Fetch the leads and read drift. A saved lead is a frozen snapshot; people change jobs. A non-empty drift array is the signal to re-qualify. Also check pool.is_active — false means an erasure request removed that person.
Pagination repeats or skips rows
Use next_cursor rather than computing an offset. The pool is written to by crawls, so offsets shift underneath a paging loop while a cursor does not.
Next
Sequences
Enrol the contacts you just converted into a multi-step cadence.
Deliverability
Why a list of guessed addresses pauses a mailbox, and what to do about it.
Plans and self-hosting
Where the reveal allowance comes from, what a 402 means across the rest of the API, and what changes when the node is yours.
vxcli
The leads command group mirrors these routes, including a cost line before anything spends quota.
REST API reference
All 17 lead routes with parameters and error cases.