{
  "version": 1,
  "slug": "find-work-emails-python-api",
  "title": "How to Find Work Emails in Python Using a Waterfall Email Finder API",
  "excerpt": "Find work emails in Python with Datablist's Waterfall Email Finder API, then prepare verified results for an outreach campaign without losing unresolved contacts.",
  "cover": {
    "src": "/howto_images/find-work-emails-python-api/find-work-emails-python-api-cover.png",
    "optimized": "https://www.datablist.com/_next/image?url=%2Fhowto_images%2Ffind-work-emails-python-api%2Ffind-work-emails-python-api-cover.png&w=1200&q=75"
  },
  "url": "https://www.datablist.com/how-to/find-work-emails-python-api",
  "contentMarkdown": "\nTo find work emails in Python, send a person's name and company information, or a public LinkedIn profile URL, to Datablist's Waterfall Email Finder API with the Requests library. The API returns the work email it finds, the address's deliverability status, and its mail provider.\n\nThe complete example below looks up Satya Nadella at `microsoft.com` and Reid Hoffman at `greylock.com`. It waits for the background task and retrieves every page of results. I will then reuse those results to prepare a local list of verified contacts for campaign review.\n\n> 📌 **Summary For Those In a Rush**\n>\n> Use one Python function to submit known contacts to the Waterfall Email Finder API, wait for the task, and collect found work emails. Then prepare verified contacts for campaign review while keeping unresolved contacts in your source list.\n>\n> - **Input:** First and last name with a company domain, a full name with a company name, or a public LinkedIn profile URL.\n> - **Output:** A work email with `emailStatus` and `mxProvider`, followed by a local review list containing only contacts with an email.\n\n> 💡 **Prefer a no-code solution?** Use Datablist's [Waterfall Email Finder](/enrichments/email-finder) to find work emails from a collection without writing code.\n\n## Set up Python, Requests, and your API key {#set-up-python-requests-and-your-api-key}\nFor this Python example, I use the `requests` package. Create a file named `find_work_emails.py`, then install Requests:\n\n```shell\npython -m pip install requests\n```\n\nCreate a Personal API Key from your [Datablist API account page](https://app.datablist.com/account/api). REST API access requires the Growth plan. Export the key in the terminal where you will run the script:\n\n```shell\nexport DATABLIST_API_KEY=\"your-api-key\"\n```\n\nSuccessful Waterfall Email Finder results also use credits. The cost section below separates those credits from the subscription that provides API access.\n\n## Find work emails with one Python function {#find-work-emails-with-one-python-function}\nPaste the following complete script into `find_work_emails.py`:\n\n```python\nimport json\nimport os\nimport time\nimport uuid\n\nimport requests\n\n\ndef find_emails(people, api_key, settings):\n    \"\"\"Submit one email-finder task, wait, and return every result.\"\"\"\n    tasks_url = \"https://api.datablist.com/v1/enrichments/tasks\"\n    headers = {\"X-API-Key\": api_key}\n\n    unique_id = str(uuid.uuid4())\n    print(f\"Task uniqueId: {unique_id}\", flush=True)\n\n    payload = {\n        \"enrichmentId\": \"waterfall-email-finder\",\n        \"uniqueId\": unique_id,\n        \"settings\": settings,\n        \"inputs\": people,\n    }\n    response = requests.post(tasks_url, headers=headers, json=payload)\n    response.raise_for_status()\n    task = response.json()\n    task_id = task[\"@id\"]\n    task_url = f\"{tasks_url}/{task_id}\"\n\n    while task[\"status\"] not in (\"completed\", \"failed\", \"stopped\"):\n        time.sleep(3)\n        response = requests.get(task_url, headers=headers)\n        response.raise_for_status()\n        task = response.json()\n\n    if task[\"status\"] != \"completed\":\n        raise RuntimeError(\n            f\"Task {task_id} {task['status']}: {task.get('error')}\"\n        )\n\n    results = []\n    results_url = f\"{task_url}/results\"\n    params = {\"limit\": 100}\n\n    while True:\n        response = requests.get(results_url, headers=headers, params=params)\n        response.raise_for_status()\n        page = response.json()\n        results.extend(page[\"results\"])\n\n        next_cursor = page[\"nextCursor\"]\n        if next_cursor is None:\n            break\n        params[\"cursor\"] = next_cursor\n\n    return results\n\n\nif __name__ == \"__main__\":\n    api_key = os.environ.get(\"DATABLIST_API_KEY\")\n    if not api_key:\n        raise RuntimeError(\"Set DATABLIST_API_KEY before running this script.\")\n\n    settings = {\"sourceKind\": \"name_company\"}\n    people = [\n        {\n            \"inputId\": \"contact-satya-nadella\",\n            \"data\": {\n                \"firstName\": \"Satya\",\n                \"lastName\": \"Nadella\",\n                \"companyNameOrDomain\": \"microsoft.com\",\n            },\n        },\n        {\n            \"inputId\": \"contact-reid-hoffman\",\n            \"data\": {\n                \"firstName\": \"Reid\",\n                \"lastName\": \"Hoffman\",\n                \"companyNameOrDomain\": \"greylock.com\",\n            },\n        },\n    ]\n\n    lookup_results = find_emails(people, api_key, settings)\n    for result in lookup_results:\n        print(json.dumps(result, indent=2))\n```\n\nRun it from the same terminal:\n\n```shell\npython find_work_emails.py\n```\n\nYou can also [download the maintained Requests example](https://github.com/datablist/waterfall-email-finder-api/blob/main/python/sync_requests.py). Once the blocking version is clear, the repository has an [async HTTPX alternative](https://github.com/datablist/waterfall-email-finder-api/blob/main/python/async_httpx.py).\n\nThe function makes three kinds of calls in order.\n\n**First, it submits the task.** Each `inputId` is your stable contact ID. The nested `data` object contains the fields used for the lookup. The POST returns `202 Accepted` with `@id`, the task ID used by later calls. The code prints `uniqueId` before submission so you still have a recovery value if the response is lost.\n\n**Second, it polls the task.** Datablist processes the task in the background. This Requests client waits and checks the task every three seconds until it is `completed`, `failed`, or `stopped`. Requests blocks while it waits, but the API task itself remains asynchronous.\n\n**Third, it retrieves the results.** A task may have several result pages. The loop keeps fetching pages until `nextCursor` is `null`. Use `inputId`, rather than response order, to match each result to its source contact.\n\n## Choose the input route for your contact data {#choose-the-input-route-for-your-contact-data}\nOne task uses one input mode. Keep the same `find_emails()` function and replace the `settings` and `people` values with the route that matches your source data.\n\nChoose an `inputId` that is unique within the task and already belongs to the source contact when possible. A CRM record ID or another durable lead ID works well. Datablist returns that value with the result, so you can match records without comparing names or assuming the response follows the input order.\n\nWhen you have separate names and a company domain, use `sourceKind: \"name_company\"`. A known domain is the clearest company identifier:\n\n```python\nsettings = {\"sourceKind\": \"name_company\"}\npeople = [\n    {\n        \"inputId\": \"contact-satya-nadella\",\n        \"data\": {\n            \"firstName\": \"Satya\",\n            \"lastName\": \"Nadella\",\n            \"companyNameOrDomain\": \"microsoft.com\",\n        },\n    }\n]\nlookup_results = find_emails(people, api_key, settings)\n```\n\nWhen your source has one name field and a company name, enable `useFullName`:\n\n```python\nsettings = {\"sourceKind\": \"name_company\", \"useFullName\": True}\npeople = [\n    {\n        \"inputId\": \"contact-satya-nadella\",\n        \"data\": {\n            \"fullName\": \"Satya Nadella\",\n            \"companyNameOrDomain\": \"Microsoft\",\n        },\n    }\n]\nlookup_results = find_emails(people, api_key, settings)\n```\n\nWhen you have a public person profile URL, use the LinkedIn route:\n\n```python\nsettings = {\"sourceKind\": \"linkedin\"}\npeople = [\n    {\n        \"inputId\": \"contact-satya-nadella\",\n        \"data\": {\n            \"linkedInProfile\": \"https://www.linkedin.com/in/satyanadella/\"\n        },\n    }\n]\nlookup_results = find_emails(people, api_key, settings)\n```\n\nThis route accepts a public `linkedin.com/in/...` profile URL as person context for the email lookup. Sales Navigator profile URLs are not accepted.\n\nIf you need to choose provider keys and their order, read the [custom waterfall guide](/how-to/custom-waterfall-email-finder). The managed waterfall used here does not require provider-specific setup.\n\n## How the managed waterfall finds an email {#how-the-managed-waterfall-finds-an-email}\nFor a name-and-company request, the API uses a supplied domain directly. When you supply a company name, it first tries to resolve that name to a usable company domain. For a LinkedIn request, it cleans the public profile URL and turns the available person and company details into the context eligible email providers need.\n\nThe managed waterfall sends an unresolved contact to an eligible provider. If no usable email comes back, another eligible provider can try the same contact. The sequence stops for that contact as soon as it accepts a work email. The default response does not name the finder.\n\n### Why a waterfall helps international outreach {#why-a-waterfall-helps-international-outreach}\nWork email providers use different datasets, so their coverage varies across countries and markets. A provider may return no result for a contact in one region, while a different provider has the data needed to find that address.\n\nFor a sales or growth team, the waterfall gives each unresolved contact another opportunity to be found before it is left out of the outreach list. This can recover contacts that a single-provider workflow would miss, while keeping the technical process consistent for the team.\n\nDatablist manages the eligible provider sequence behind one API integration. You submit the contact once and receive one result format, instead of building and maintaining a separate integration for each provider and merging those responses yourself.\n\n![A work email lookup moves to another provider after no result and stops when an email is found](/howto_images/find-work-emails-python-api/illustrations/waterfall-email-lookup-sequence.png)\n\n## Read the complete results {#read-the-complete-results}\nThe example task returned two successful results. Email addresses are masked below.\n\n```json\n{\n  \"taskId\": \"2286103ce43748828e27ce71b9cc526b\",\n  \"results\": [\n    {\n      \"inputId\": \"contact-satya-nadella\",\n      \"status\": \"success\",\n      \"outputs\": {\n        \"gender\": null,\n        \"mxProvider\": \"microsoft\",\n        \"emailFound\": \"s*******@microsoft.com\",\n        \"emailStatus\": \"verified\"\n      },\n      \"creditsUsed\": \"25\",\n      \"error\": null\n    },\n    {\n      \"inputId\": \"contact-reid-hoffman\",\n      \"status\": \"success\",\n      \"outputs\": {\n        \"gender\": null,\n        \"mxProvider\": \"mimecast\",\n        \"emailFound\": \"r*******@greylock.com\",\n        \"emailStatus\": \"verified\"\n      },\n      \"creditsUsed\": \"25\",\n      \"error\": null\n    }\n  ],\n  \"nextCursor\": null\n}\n```\n\n`emailFound` is the returned work email. `emailStatus` describes its deliverability at lookup time, and `mxProvider` identifies the email hosting provider when available. Both records are `verified`, and each used 25 credits.\n\nThe campaign code below puts `verified` results in `campaign_contacts` and keeps other found addresses in `needs_verification`. The `creditsUsed` value is the authoritative charge for each result.\n\nThe default managed result does not contain `finderProvider`. That field is conditional output for a valid custom-key name-and-company workflow, so the script should not infer it from `mxProvider`.\n\n## Prepare found emails for campaign review {#prepare-found-emails-for-campaign-review}\nOnce the direct lookup works, append this block at the end of the script, after `lookup_results` has been returned. It prepares local contacts for campaign review from the lookup you already ran.\n\n```python\nresults_by_id = {result[\"inputId\"]: result for result in lookup_results}\n\ncampaign_contacts = []\nneeds_verification = []\n\nfor person in people:\n    result = results_by_id.get(person[\"inputId\"])\n    if result is None:\n        continue\n    if result[\"status\"] != \"success\":\n        continue\n    if not result[\"outputs\"].get(\"emailFound\"):\n        continue\n\n    contact = person.copy()\n    contact[\"result\"] = result\n\n    if result[\"outputs\"].get(\"emailStatus\") == \"verified\":\n        campaign_contacts.append(contact)\n    else:\n        needs_verification.append(contact)\n\nprint(json.dumps({\n    \"campaign_contacts\": campaign_contacts,\n    \"needs_verification\": needs_verification,\n}, indent=2))\n```\n\n`results_by_id` makes response order irrelevant. For Satya, the loop reads `contact-satya-nadella` from the original `people` list, finds the result with the same `inputId`, copies the source record, and attaches the complete API object under `result`. Because the address is present and its status is `verified`, the contact goes into `campaign_contacts`. Reid follows the same path even if his API result arrives first.\n\nThe three conditions keep the decision readable. The first skips a contact when no matching result was returned. The second skips row-level errors and other non-success states. The third requires an actual `emailFound` value. Attaching the complete `result` object preserves the status, outputs, credit use, and error field for later review.\n\nA found email with another deliverability state goes to `needs_verification`. An absent result, a row error, or a result without `emailFound` stays out of both prepared lists. The original `people` list remains available for correction or later research.\n\n## Estimate Waterfall Email Finder API costs {#estimate-waterfall-email-finder-api-costs}\nThe default managed waterfall uses **25 credits for each found work email**. An unsuccessful default lookup uses 0 credits. Read `creditsUsed` from each result instead of multiplying the total number of submitted contacts.\n\nA found email with an `unverified` status still uses 25 credits, even though the local campaign code places it in `needs_verification` instead of `campaign_contacts`.\n\nThe two found emails in this example used 50 credits. For planning:\n\n- 100 found work emails use 2,500 credits.\n- 1,000 found work emails use 25,000 credits.\n- A larger input list can use fewer credits than `contacts × 25` because no-result lookups are not charged.\n\nREST API access requires the Growth plan. At the current USD top-up rate, $20 buys 20,000 credits, which is $0.001 per credit. At that rate, 25,000 credits have a $25 credit value. The subscription is separate from that credit calculation. Check the [Datablist pricing page](/pricing) for current plan and package details.\n\n<div format=\"cost-calculator\"\n     data-heading=\"Estimate your API cost\"\n     data-unit-label=\"Found work emails\"\n     data-credits-per-unit=\"25\"\n     data-lower-rate-label=\"Managed waterfall result\"\n     data-initial-quantity=\"1000\"\n     data-max-quantity=\"100000\">\n<p>Each found work email uses 25 credits. Inputs with no result use 0 credits.</p>\n</div>\n\n## Fix common errors and recover a task {#fix-common-errors-and-recover-a-task}\n**The API key is missing.** The script raises `Set DATABLIST_API_KEY before running this script.` before making a request. Export the variable in the current terminal and run the file again.\n\n**The API returns an authentication error.** `raise_for_status()` reports the HTTP failure. Copy a current Personal API Key, confirm the `X-API-Key` header uses it, and rerun only after fixing the credential.\n\n**The account has insufficient credits.** Check the account balance. Add credits or wait for the plan's next credit allocation before submitting more work.\n\n**The input fields do not match the selected mode.** Use separate name fields or `fullName` with `name_company`, or use `linkedInProfile` with `linkedin`. Correct the contact and submit it with one consistent mode.\n\n**The task is `failed` or `stopped`.** The script raises a `RuntimeError` containing the task ID, terminal status, and available task error. Inspect that task and any available results before deciding which contacts still need a lookup.\n\n**The POST response was lost.** Copy the `uniqueId` printed before submission. List your tasks with the same key:\n\n```shell\ncurl \"https://api.datablist.com/v1/enrichments/tasks\" \\\n  -H \"X-API-Key: $DATABLIST_API_KEY\"\n```\n\nFind the task with the printed `uniqueId`, copy its `@id`, then continue with the task-status or results URL. This lets you recover the existing task instead of automatically submitting the contacts again. The [task API documentation](https://platform.datablist.com/docs/enrichments/tasks) covers the lifecycle and result endpoints.\n\n## Next steps {#next-steps}\nYou now have one blocking Python Requests function for the three supported input routes and a local filter that prepares found, verified emails for campaign review.\n\nReplace the two example contacts with a small slice of your researched contacts. Choose the input route that matches your data, run the lookup, and review `emailStatus` before using an address. For a broader list workflow, read [how to find emails at scale](/how-to/finding-emails-at-scale). The [maintained GitHub repository](https://github.com/datablist/waterfall-email-finder-api) provides the downloadable blocking and async Python examples.\n"
}