Find and verify emails for HubSpot prospects using Apollo and Hunter with code
high complexityCost: $0
Prerequisites
Prerequisites
- Node.js 18+ or Python 3.9+
- HubSpot private app token with
crm.objects.contacts.readandcrm.objects.contacts.writescopes - Apollo API key with email finder credits
- Hunter.io API key with verification credits
- A scheduling environment: cron, GitHub Actions, or a cloud function
Step 1: Set up the project
# Test Apollo people match
curl -X POST "https://api.apollo.io/api/v1/people/match" \
-H "x-api-key: $APOLLO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"first_name": "Tim", "last_name": "Cook", "organization_name": "Apple"}'
# Test Hunter email verification
curl "https://api.hunter.io/v2/email-verifier?email=test@example.com&api_key=$HUNTER_API_KEY"Step 2: Find contacts missing emails in HubSpot
import requests
import os
import time
HUBSPOT_TOKEN = os.environ["HUBSPOT_TOKEN"]
APOLLO_API_KEY = os.environ["APOLLO_API_KEY"]
HUNTER_API_KEY = os.environ["HUNTER_API_KEY"]
HS_HEADERS = {"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"}
def get_contacts_without_email(limit=50):
all_contacts = []
after = 0
while len(all_contacts) < limit:
resp = requests.post(
"https://api.hubapi.com/crm/v3/objects/contacts/search",
headers=HS_HEADERS,
json={
"filterGroups": [{"filters": [
{"propertyName": "email", "operator": "NOT_HAS_PROPERTY"},
{"propertyName": "firstname", "operator": "HAS_PROPERTY"},
{"propertyName": "company", "operator": "HAS_PROPERTY"},
]}],
"properties": ["firstname", "lastname", "company", "domain"],
"limit": min(100, limit - len(all_contacts)),
"after": after
}
)
resp.raise_for_status()
data = resp.json()
all_contacts.extend(data["results"])
if data.get("paging", {}).get("next"):
after = data["paging"]["next"]["after"]
else:
break
return all_contactsStep 3: Find emails via Apollo
def find_email_apollo(first_name, last_name, company):
"""Find a person's email via Apollo People Match."""
resp = requests.post(
"https://api.apollo.io/api/v1/people/match",
headers={"x-api-key": APOLLO_API_KEY, "Content-Type": "application/json"},
json={
"first_name": first_name,
"last_name": last_name,
"organization_name": company
}
)
resp.raise_for_status()
person = resp.json().get("person")
if not person or not person.get("email"):
return None, None
return person["email"], person.get("email_status", "unknown")Step 4: Verify with Hunter
Skip verification if Apollo already marked the email as verified:
def verify_email_hunter(email):
"""Verify an email via Hunter. Returns 'deliverable', 'risky', 'undeliverable', or 'unknown'."""
resp = requests.get(
"https://api.hunter.io/v2/email-verifier",
params={"email": email, "api_key": HUNTER_API_KEY}
)
resp.raise_for_status()
return resp.json()["data"]["result"]
def find_and_verify(first_name, last_name, company):
"""Find email via Apollo, verify via Hunter if needed."""
email, apollo_status = find_email_apollo(first_name, last_name, company)
if not email:
return {"email": None, "status": "not_found", "source": "apollo"}
# Skip Hunter if Apollo already verified
if apollo_status == "verified":
return {"email": email, "status": "verified", "source": "apollo"}
# Verify with Hunter
hunter_result = verify_email_hunter(email)
return {
"email": email,
"status": hunter_result,
"source": "apollo+hunter"
}Apollo email_status values
Apollo returns verified (confirmed deliverable), guessed (pattern-matched), unavailable (no email found), or null. Only verified emails can safely skip Hunter verification.
Step 5: Process contacts and update HubSpot
def update_contact_email(contact_id, email, status, source):
"""Write email and verification status to HubSpot."""
properties = {"email_source": source, "email_verification_status": status}
if status == "deliverable" or status == "verified":
properties["email"] = email
resp = requests.patch(
f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}",
headers=HS_HEADERS,
json={"properties": properties}
)
resp.raise_for_status()
def main():
contacts = get_contacts_without_email(limit=50)
print(f"Found {len(contacts)} contacts without email\n")
stats = {"found": 0, "verified": 0, "risky": 0, "not_found": 0}
for contact in contacts:
props = contact["properties"]
name = f"{props.get('firstname', '')} {props.get('lastname', '')}".strip()
company = props.get("company", "")
result = find_and_verify(
props.get("firstname", ""),
props.get("lastname", ""),
company
)
if result["status"] in ("deliverable", "verified"):
update_contact_email(contact["id"], result["email"], result["status"], result["source"])
stats["verified"] += 1
print(f" {name} @ {company} -> {result['email']} ({result['source']})")
elif result["status"] == "risky":
update_contact_email(contact["id"], result["email"], "risky", result["source"])
stats["risky"] += 1
print(f" {name} @ {company} -> {result['email']} (RISKY)")
else:
stats["not_found"] += 1
print(f" {name} @ {company} -> not found")
time.sleep(0.5) # rate limit
print(f"\nDone. Verified: {stats['verified']}, Risky: {stats['risky']}, Not found: {stats['not_found']}")
if __name__ == "__main__":
main()Step 6: Schedule the script
# .github/workflows/find-emails.yml
name: Find and Verify Emails
on:
schedule:
- cron: '0 11 * * *' # 6 AM ET = 11 AM UTC
workflow_dispatch: {}
jobs:
find-emails:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install requests
- run: python find_emails.py
env:
HUBSPOT_TOKEN: ${{ secrets.HUBSPOT_TOKEN }}
APOLLO_API_KEY: ${{ secrets.APOLLO_API_KEY }}
HUNTER_API_KEY: ${{ secrets.HUNTER_API_KEY }}Rate limits
| API | Limit | Delay |
|---|---|---|
| Apollo People Match | 5 req/sec (Basic) | 500ms between calls |
| Hunter Email Verifier | 15 req/sec | No delay needed at 50/batch |
| HubSpot Search | 5 req/sec | 200ms between pages |
| HubSpot PATCH | 150 req/10 sec | No delay needed |
Cost
- Apollo: 1 credit per people match. Basic plan ($49/mo) = 900 credits.
- Hunter: 1 credit per verification. Starter plan ($49/mo) = 1,000 verifications. Free plan = 25/month.
- Credit savings: Apollo-verified emails skip Hunter entirely. Typically 30-40% of Apollo results are pre-verified, saving that many Hunter credits.
- Per 50 contacts: 50 Apollo credits + ~30-35 Hunter credits (after skipping verified ones).
Apollo credits are non-refundable
Apollo charges 1 credit even when no person is found. If your contact list has low-quality data (misspelled names, wrong companies), you'll burn credits on misses. Clean your data before running the script.
Next steps
- Add domain resolution — for contacts without a company domain, use Hunter's Domain Search (
GET /v2/domain-search?company=Acme+Inc) to find it first - Export results — write a CSV summary for review: name, company, email found, verification status, source
- Add bounce tracking — after emails are used in sequences, track bounces and feed them back to update verification status
Need help implementing this?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.