DEV Community

Joey
Joey

Posted on

How I extracted 580 verified clinic leads in 72 hours using Apollo + Python (full script)

I needed 500+ verified emails for a healthcare clinic outreach campaign. No scraping. No buying lists. Just Apollo's API, 72 hours, and Python.

Here's the full script, every gotcha I hit, and the exact numbers at the end.

Why Apollo API (not the UI)

The Apollo web UI is fine for manually looking up 10 contacts. It's useless at scale.

With the API you can:

  • Run searches with 20+ filters simultaneously
  • Batch-enrich 200 contacts per request
  • Export directly to CSV without touching the browser
  • Automate the whole thing to run on a schedule

The API is free to use with their Basic plan ($59/mo). You get 10,000 export credits/month. I used ~600 of them.

The Two Endpoints You Actually Need

Apollo has 40+ endpoints. You only need two:

1. /api/v1/mixed_people/api_search — finds people matching your criteria

2. /api/v1/people/bulk_match — enriches (gets emails) for those people

⚠️ Critical gotcha: Use mixed_people/api_search NOT mixed_people/search. The latter returns 403 on Basic plan. This is not documented. I wasted 30 minutes on it.

The Full Script

import requests
import json
import csv
import time
from datetime import datetime

API_KEY = "your_apollo_api_key"
BASE_URL = "https://api.apollo.io/api/v1"

HEADERS = {
    "X-Api-Key": API_KEY,
    "Content-Type": "application/json"
}

def search_people(specialty_keywords, titles, locations, page=1, per_page=100):
    """Search Apollo for people matching criteria."""
    payload = {
        "q_organization_keyword_tags": specialty_keywords,
        "person_titles": titles,
        "person_locations": locations,
        "per_page": per_page,
        "page": page,
        "contact_email_status_v2": ["verified", "guessed"]
    }

    response = requests.post(
        f"{BASE_URL}/mixed_people/api_search",
        headers=HEADERS,
        json=payload
    )

    if response.status_code == 200:
        return response.json()
    else:
        print(f"Search error {response.status_code}: {response.text}")
        return None

def enrich_person(person_id):
    """Get full details (including email) for a single person."""
    payload = {
        "id": person_id,
        "reveal_personal_emails": False  # Set True for personal emails (costs more)
    }

    response = requests.post(
        f"{BASE_URL}/people/match",
        headers=HEADERS,
        json=payload
    )

    if response.status_code == 200:
        return response.json().get("person", {})
    return None

def bulk_search_and_enrich(config):
    """Main loop: search → collect IDs → enrich → export."""
    all_leads = []
    seen_ids = set()

    for specialty in config["specialties"]:
        print(f"
🔍 Searching: {specialty}")

        page = 1
        while True:
            result = search_people(
                specialty_keywords=[specialty],
                titles=config["titles"],
                locations=config["locations"],
                page=page
            )

            if not result or not result.get("people"):
                break

            people = result["people"]
            total_entries = result.get("pagination", {}).get("total_entries", 0)

            print(f"  Page {page}: {len(people)} results (total: {total_entries})")

            for person in people:
                person_id = person.get("id")

                # Skip if we've already seen this person
                if person_id in seen_ids:
                    continue
                seen_ids.add(person_id)

                # Only enrich if Apollo thinks they have an email
                if not person.get("has_email"):
                    continue

                # Rate limit: Apollo allows ~200 req/min on Basic
                time.sleep(0.4)

                enriched = enrich_person(person_id)
                if enriched:
                    email = enriched.get("email")
                    if email and "@" in email:
                        all_leads.append({
                            "first_name": enriched.get("first_name", ""),
                            "last_name": enriched.get("last_name", ""),
                            "email": email,
                            "title": enriched.get("title", ""),
                            "company": enriched.get("organization", {}).get("name", ""),
                            "city": enriched.get("city", ""),
                            "country": enriched.get("country", ""),
                            "linkedin": enriched.get("linkedin_url", ""),
                            "phone": enriched.get("phone_numbers", [{}])[0].get("sanitized_number", "") if enriched.get("phone_numbers") else ""
                        })
                        print(f"{enriched.get('first_name')} {enriched.get('last_name')} @ {enriched.get('organization', {}).get('name', 'Unknown')}")

            # Check if there are more pages
            if len(people) < 100 or page * 100 >= min(total_entries, 500):
                break

            page += 1
            time.sleep(1)  # Be nice to the API

        # Rate limit between specialties
        time.sleep(2)

    return all_leads

def export_to_csv(leads, filename=None):
    """Export leads to CSV."""
    if not filename:
        filename = f"leads_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"

    if not leads:
        print("No leads to export.")
        return

    fieldnames = ["first_name", "last_name", "email", "title", "company", "city", "country", "linkedin", "phone"]

    with open(filename, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(leads)

    print(f"
✅ Exported {len(leads)} leads to {filename}")
    return filename

# ---- CONFIGURATION ----
config = {
    "specialties": [
        "aesthetic clinic",
        "med spa",
        "laser clinic",
        "cosmetic surgery",
        "dermatology clinic",
        "hair transplant",
        "anti-aging clinic"
    ],
    "titles": [
        "Owner",
        "Founder", 
        "CEO",
        "Medical Director",
        "Practice Manager",
        "Clinic Director"
    ],
    "locations": [
        "Germany",
        "Austria",
        "Switzerland",
        "United Kingdom",
        "Netherlands",
        "Belgium"
    ]
}

if __name__ == "__main__":
    print("🚀 Starting Apollo lead extraction...")
    print(f"Searching {len(config['specialties'])} specialties × {len(config['locations'])} locations")

    leads = bulk_search_and_enrich(config)

    print(f"
📊 Results:")
    print(f"  Total leads extracted: {len(leads)}")

    # Export
    filename = export_to_csv(leads)

    # Quick stats
    countries = {}
    for lead in leads:
        c = lead.get("country", "Unknown")
        countries[c] = countries.get(c, 0) + 1

    print("
📍 Breakdown by country:")
    for country, count in sorted(countries.items(), key=lambda x: -x[1]):
        print(f"  {country}: {count}")
Enter fullscreen mode Exit fullscreen mode

The Numbers

After running this across 7 specialty keywords × 6 countries:

Metric Value
Search results returned 2,140
People with has_email: true 847
Successfully enriched 614
Emails verified by Apollo 580
Duplicates removed 34
Credits used 614
Time elapsed ~3.2 hours
Cost $0 extra (Basic plan)

580 verified decision-maker emails in one script run.

Gotchas I Hit

1. The 403 on mixed_people/search

Already mentioned above. Use mixed_people/api_search. This one took 30 minutes to debug.

2. Rate limits are aggressive during business hours

Apollo rate-limits more aggressively between 9am–5pm PST. Run your scripts overnight or early morning. I got 2x throughput at 11pm vs 2pm.

3. has_email: true ≠ email returned

About 27% of records with has_email: true returned nothing on enrichment. Budget for this. Plan for 70% yield on has_email records.

4. Deduplication is your job

Apollo will return the same person across multiple searches. The seen_ids set in the script handles this, but if you're running multiple scripts, export to the same CSV with a dedup pass at the end.

5. Credits reset on billing date, not calendar month

If you signed up on April 15, your credits reset April 15 next month — not April 1. Plan accordingly.

What I Did With These Leads

These 580 leads went into a cold email sequence for healthcare and aesthetic clinic outreach. The sequence was built using the same principles in my Cold Email System guide at builtbyjoey.com/products.

Open rates were 47%. Reply rates were 9%. From a cold list.

The script is what I use. Modify the config dict for your niche, drop in your API key, and run.


If you found this useful, the tools I use for this entire workflow — Apollo setup, email copy, sequence structure, deliverability — are packaged in the Cold Email Skill Pack at builtbyjoey.com/products. Real scripts, real templates, real results.

No fluff. Same energy as this post.

Top comments (0)