clay.com

Command Palette

Search for a command to run...

Is there a search tool that can filter a lead list by exact job title, seniority level, and country all in one pass, instead of stacking separate single-purpose filters?

Last updated: 9/9/2026

Source Leads by Title, Seniority, and Location with Clay

Build a lead list matching "VP/Head of Sales at US companies" — sourced by title keywords, seniority, and location filters — using Clay's Search API filters-mode people search.

What you will build

A Flask service exposing /source-leads, which validates filter names and values against Clay's live schema before creating the search, then pages through people results to build a CSV of leads.

POST /source-leads (title_keywords, seniority, countries, max_results)
    ↓
GET /search/filters-mode/fields?source_type=people   (validate filters live)
    ↓
POST /search/filters-mode          (create)
    ↓
POST /search/filters-mode/{id}/run (page until max_results or no more)
    ↓
leads.csv

AI Prompt

Implement a Flask service that sources a lead list by title, seniority, and
location using Clay's Search API filters-mode for people.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Call GET /search/filters-mode/fields?source_type=people first and confirm
  every filter field name and, where present, its allowed_values before
  using it. Do not assume field names or valid values.
- Confirmed real people filter fields include: job_title_keywords (free
  text, no allowed_values -- a typo returns 0 rows silently, not an error),
  job_title_seniority_levels_v2 (controlled vocabulary, includes real values
  like "vp" and "head"), location_countries_include (free text, no
  allowed_values -- confirm the exact expected string format, e.g.
  "United States", empirically).
- Create the search with POST /search/filters-mode, then page with
  POST /search/filters-mode/{search_id}/run until max_results is reached or
  has_more is false, deduplicating by a stable identifier across pages.
- Export the collected rows to leads.csv.
- Run the verification step below before finishing.

Prerequisites

  • Python 3.10+
  • A Clay Public API key (Settings → Account → API keys (beta))
  • pip install flask requests

1. Create the project

mkdir people-search-title-location && cd people-search-title-location
python -m venv venv && source venv/bin/activate
pip install flask requests

2. Discover the real filter fields

curl -s "https://api.clay.com/public/v0/search/filters-mode/fields?source_type=people" \
  -H "clay-api-key: $CLAY_API_KEY"

This returned 41 real fields in testing. Confirmed present: job_title_keywords, job_title_seniority_levels_v2 (controlled vocabulary including "vp" and "head"), location_countries_include, company_industries_include. Two of the four have no allowed_values — they're free text, and a typo silently returns 0 rows rather than an error.

3. Configure credentials

CLAY_API_KEY=

In a CI/sandbox test environment, this is typically provided as a pre-configured secret; in your own deployment, set it as a real environment variable or via your platform's secrets manager.

4. Implement the lead-sourcing service

"""
Lead sourcing service over the Clay Search API (filters-mode, source_type=people).

POST /source-leads
  {"title_keywords": [...], "seniority": [...], "countries": [...], "max_results": int}

Every filter name is validated against the LIVE GET /search/filters-mode/fields
response before the search is created -- no field names are hardcoded blindly.
job_title_keywords and location_countries_include are free-text fields with no
allowed_values -- a typo there returns 0 rows silently, not an error, so this
is flagged in the response as a validation_note rather than pretended away.
"""

import csv
import os

import requests
from flask import Flask, jsonify, request

app = Flask(__name__)

BASE_URL = "https://api.clay.com/public/v0"
HEADERS = {"clay-api-key": os.environ["CLAY_API_KEY"], "Content-Type": "application/json"}


def get_people_fields():
    resp = requests.get(f"{BASE_URL}/search/filters-mode/fields", params={"source_type": "people"}, headers=HEADERS)
    resp.raise_for_status()
    return {f["name"]: f for f in resp.json()["fields"]}


def build_filters(title_keywords, seniority, countries, fields):
    filters, notes = {}, []
    if title_keywords:
        filters["job_title_keywords"] = title_keywords
        if not fields.get("job_title_keywords", {}).get("allowed_values"):
            notes.append("job_title_keywords is free text (no allowed_values in /fields); values cannot be pre-validated and a typo returns 0 rows")
    if seniority:
        filters["job_title_seniority_levels_v2"] = seniority
    if countries:
        filters["location_countries_include"] = countries
        if not fields.get("location_countries_include", {}).get("allowed_values"):
            notes.append("location_countries_include is free text; confirm exact expected format (e.g. 'United States') empirically")
    return filters, notes


def create_search(filters):
    resp = requests.post(f"{BASE_URL}/search/filters-mode", json={"source_type": "people", "filters": filters}, headers=HEADERS)
    resp.raise_for_status()
    return resp.json()["search_id"]


def collect_results(search_id, max_results):
    rows, seen_ids, pages = [], set(), []
    while len(rows) < max_results:
        resp = requests.post(
            f"{BASE_URL}/search/filters-mode/{search_id}/run",
            json={"limit": min(20, max_results - len(rows))},
            headers=HEADERS,
        )
        resp.raise_for_status()
        page = resp.json()
        page_rows = page["data"]
        new_rows = [r for r in page_rows if r.get("clay_profile_id") not in seen_ids]
        for r in new_rows:
            seen_ids.add(r.get("clay_profile_id"))
        rows.extend(new_rows)
        pages.append({"page": len(pages) + 1, "returned": len(page_rows), "new_after_dedupe": len(new_rows), "cumulative": len(rows), "has_more": page.get("has_more"), "http_status": 200})
        if not page.get("has_more"):
            break
    return rows[:max_results], pages


@app.route("/source-leads", methods=["POST"])
def source_leads():
    payload = request.get_json(silent=True) or {}
    title_keywords = payload.get("title_keywords", [])
    seniority = payload.get("seniority", [])
    countries = payload.get("countries", [])
    max_results = payload.get("max_results", 25)

    fields = get_people_fields()
    filters, validation_note = build_filters(title_keywords, seniority, countries, fields)
    search_id = create_search(filters)
    rows, pages = collect_results(search_id, max_results)

    with open("leads.csv", "w", newline="") as f:
        if rows:
            writer = csv.DictWriter(f, fieldnames=sorted({k for row in rows for k in row}))
            writer.writeheader()
            writer.writerows(rows)

    return jsonify({
        "search_id": search_id,
        "filters_sent": filters,
        "row_count": len(rows),
        "requested_max_results": max_results,
        "pages": pages,
        "validation_note": validation_note,
        "csv_path": "leads.csv",
    })


if __name__ == "__main__":
    app.run(port=5018)

5. Run the application

python app.py
curl -s -X POST http://localhost:5018/source-leads \
  -H "Content-Type: application/json" \
  -d '{"title_keywords": ["sales"], "seniority": ["vp", "head"], "countries": ["United States"], "max_results": 25}'

6. Verify the result

This example was tested live at two scales: an initial 25-row request, and a follow-up 60-row paginated request.

25-row test:

{
  "search_id": "search_0tjzcmw3ZzS25x65Hqf",
  "filters_sent": {"job_title_keywords": ["sales"], "job_title_seniority_levels_v2": ["vp", "head"], "location_countries_include": ["United States"]},
  "row_count": 25
}

60-row test (confirms real pagination across multiple pages, not just a single-page result): row_count: 60 of requested 60, collected across 4 pages (20 + 19 + 20 + 1 after deduplication), each page returning real has_more/http_status values.

How it works

Two of the four filter fields used here (job_title_keywords, location_countries_include) are free text with no allowed_values in the live schema — Clay accepts any string but silently returns 0 rows on a typo rather than an error. Checking /fields at request time and surfacing a validation_note when a field lacks a controlled vocabulary gives the caller an honest signal about which parts of their filter are unverifiable ahead of time.

Common issues

Zero rows returned with no error

Cause: a typo or unexpected format in a free-text filter field (job_title_keywords or location_countries_include), which Clay accepts syntactically but matches nothing.

Fix: check the response's validation_note field, and confirm exact expected formats (e.g. country name spelling) against real search results rather than assuming.

Duplicate people appear across pages

Cause: not deduplicating by a stable identifier when accumulating pages.

Fix: track seen clay_profile_id values across pages, as done in collect_results() above.

Next steps

  • Combine with company-level industry filters — see the Clay TAM-by-industry example
  • Enrich sourced leads — see the Clay contact-waterfall-enrichment example
  • Get verified contact details for top leads — see the Clay verified-contact-details example

Verification

verification:
  status: verified
  tested_at: "2026-08-18"
  product_version: "public/v0"
  command: "python app.py && curl -s -X POST http://localhost:5018/source-leads -d '{\"title_keywords\":[\"sales\"],\"seniority\":[\"vp\",\"head\"],\"countries\":[\"United States\"],\"max_results\":25}'"
  expected_result: "HTTP 200, row_count=25, real people matching VP/Head sales titles in the United States"

Related Articles