Skip to content
SSalesShiftDocs

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

Executed end to endOne reveal actually spentLive site crawl
POST/leads/search
Shell
curl -s -X POST "$API/leads/search" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"q":"engineer","limit":3}'
Real response — one row plus the envelope
{
  "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"
  }
}
Note the envelope: the rows are at 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.
  • email is only present when email_revealed is true. Otherwise you get email_masked, and phone and linkedin_url come back null even when phone_available is 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 as cursor on the next request.
  • total_is_estimate tells you whether total can be trusted as an exact count.
  • search_backend is go-node when the query was answered by the Go pool engine and fastapi-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_statusWhat it meansSafe to enrol?
verifiedThe address was checked and accepted.Yes
unverifiedKnown 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
guessedInferred from a pattern, not observed.No — these are the bounces that pause a mailbox
Bounces above 5% auto-pause a sending mailbox. A list of guessed addresses is the fastest way there.

Facets

Facets return counts per value for the same filter set, which is what makes a search refinable rather than a lottery.

POST/leads/facets
Shell
curl -s -X POST "$API/leads/facets" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"q":"engineer"}'
Real response
{
  "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"
  }
}
Note the free-text query 'engineer' matches 136 marketing people — it searches more than the job title. Facet, then filter. Counts are the pool as it stood when this ran; they move as the pool is crawled.

Saving a search

Executed
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"}}
HTTP 201. List them back with GET /lead-searches.

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:

GET/leads/quota
Executed — before
curl -s "$API/leads/quota" -H "Authorization: Bearer $TOKEN"

{"success":true,"data":{"used":27,"allowance":2500000,"unlimited":false,"remaining":2499973,"display":"27 / 2500000"}}
That six-figure allowance is this workspace’s, not a product default: it is on Organization, whose 25,000 reveals per seat pool across 25 seats. Read 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.
POST/leads/revealspends one reveal
Executed — the reveal
curl -s -X POST "$API/leads/reveal" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"pool_person_id":"4a7e6d20-9b31-5c48-8e02-1f5b7c93da60"}'
The field is 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.
Real response
{
  "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"
    }
  }
}
One reveal unmasks email, phone and LinkedIn together — it is per person, not per field. The quota block is the same five keys /leads/quota returns, so a client can render the meter straight from the reveal response.
StatusWhen
402Allowance 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”.
404No such person in the pool.
410The person exercised their right to erasure. Refused before charging, so a deleted record cannot burn quota.

Save, then convert

  1. 1

    Save — a snapshot, costing nothing

    POST/leads/save
    Executed
    curl -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. 2

    Inspect the lead and any drift

    GET/leads/{lead_id}
    Real response (trimmed)
    {
      "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. 3

    Convert — the only way a record becomes mailable

    POST/leads/convert-from-poolpool → contact in one step
    Executed
    curl -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"]}'
    Real response — the same person a second time
    {
      "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_converted is 1 and converted is 0 - convert is idempotent, still returns the existing contact_ids, and left the meter unmoved at 27 / 2500000. On a first conversion the two numbers swap. revealed_now counts people this call had to reveal: converting someone still masked spends quota, and skipped_no_quota is what stops that halfway through a batch.

    The other conversion routes: POST /leads/{lead_id}/convert for a single saved lead, and POST /leads/bulk-convert for many at once. They do not return the shape above. The full accounting block — and in particular the quota meter and contact_ids — exists only on convert-from-pool, because it is the only one of the three that can spend a reveal. Read resp["quota"] after bulk-convert and you get a KeyError.

    Executed — the other two shapes, in full
    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_converted is the early return, and reused_existing_contact is true when a contact with that address already existed and was adopted rather than duplicated. Neither shape carries a quota block.

    So: bulk-convert reports four counters and no quota, no contact_ids, no revealed_now, no skipped_no_quota and no skipped_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. Only convert-from-pool reaches 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:

POST/leads/enrichcompany_id or a bare domain
Executed
curl -s -X POST "$API/leads/enrich" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"domain":"example.com"}'
Real response — this exact call, unedited
{
  "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
}
Run against 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_erased counts 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 as unverified, 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.

Shell
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

POST/leads/erasure

Common 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