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_searchNOTmixed_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}")
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)