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.read and crm.objects.contacts.write scopes
  • 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_contacts

Step 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

APILimitDelay
Apollo People Match5 req/sec (Basic)500ms between calls
Hunter Email Verifier15 req/secNo delay needed at 50/batch
HubSpot Search5 req/sec200ms between pages
HubSpot PATCH150 req/10 secNo 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.