Website Contact Scraper | Emails, Phones & Social Links
Pricing
from $2.55 / 1,000 delivered contact page versions
Website Contact Scraper | Emails, Phones & Social Links
Extract published website emails, phones and social links with source URLs. Optional email-only rows before charging; n8n and Python CSV setup guides included. $3 per 1,000 delivered page versions; no start fee.
Pricing
from $2.55 / 1,000 delivered contact page versions
Rating
0.0
(0)
Developer
Signal Foundry
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
Turn a list of public domains or company contact-page URLs into a spreadsheet with source URLs. Each delivered row contains the emails, phone numbers and social profile links found on one page. Contacts are extracted from HTML; mailboxes are not verified.
Choose your setup: Apify Console, n8n to CSV, or Python to CSV.
Paste entries such as example.com, example.com/contact or https://example.com/contact. Domains without a scheme use HTTPS; there is no automatic fallback to HTTP. Duplicate forms of the same normalized URL are fetched once. Existing HTTPS inputs keep the same record identities for repeat-run history.
Start with your use case
| Use case | Preview the input | Copy setup to Apify |
|---|---|---|
| Research software agencies | Software agency public contact pages | Copy agency setup |
| Build a SaaS contact spreadsheet | SaaS public contact pages to CSV | Copy SaaS setup |
| Review a hosting provider's published contacts | Hosting provider public contact audit | Copy hosting setup |
These inputs use public pages as examples. Website content and access rules can change. Replace their URLs with the websites you want to research.
From example to CSV
- Choose Copy setup and sign in to Apify, then review the editable input. For a first run, keep one or two domains or URLs, set
maxPagesPerSiteto1andmaxItemsto10. LeaveincludeEmptyPagesandonlyNewoff. - Set maximum charge to $0.03 in the run options and start the Actor. The base price is $0.003 per delivered page version: ten rows cost $0.03, including platform usage. There is no start fee; eligible Apify plans have lower event prices. A spending cap does not guarantee ten results.
- When the run finishes, open its dataset and export CSV or JSON. In Console, select the columns below if you want a compact spreadsheet. Keep
pageUrlso each contact can be checked at its source.
| Output field | What it contains |
|---|---|
websiteUrl | The starting URL from your input |
pageUrl | The page where the contacts were found |
pageTitle | The page's HTML title, when present |
emails | An array of email addresses found in visible HTML text or mailto: links |
phones | An array of phone numbers found in tel: links or labeled text |
socialLinks | An array of supported social profile links |
One page can contain several contacts in the same row. By default, a row may contain only a phone number or a social link, with an empty emails array. Enable Only pages with an email (requireEmail: true) to omit those rows before delivery and result charging. Use JSON if you want to preserve these arrays directly.
For more sample inputs, try Python and Django contact pages or the project foundation watchlist.
Real result preview
These two page records came from an actual Actor run on 2026-10-02, using the project foundation watchlist with one page per website. Selected published role inboxes and the number of extracted social profile links are shown; other fields and contacts are omitted here.
| Source page | Selected role inboxes from emails | Entries in socialLinks |
|---|---|---|
| Python help | psf@python.org, webmaster@python.org | 6 |
| Django Software Foundation contact | No email extracted | 7 |
With the default requireEmail: false, both are billable page records: a page with only social links can still be delivered. With requireEmail: true, only the Python help row qualifies for delivery; the Django page has no extracted email. The example produced two rows, not one row per individual contact. Website content can change, and extracted email addresses are not mailbox-verified. Copy the preview's source-page setup to review the input before starting your own run.
Keep only page records containing email
Enable Only pages with an email in Input, or set requireEmail: true in an API call:
{"websiteUrls": ["https://www.djangoproject.com/contact/foundation/","www.python.org/about/help/"],"requireEmail": true,"maxPagesPerSite": 1,"maxItems": 1}
No-email pages do not enter the dataset, generate a result charge or consume maxItems. A social-only homepage can still lead to an email-bearing contact/about page when maxPagesPerSite allows it. requireEmail takes precedence over includeEmptyPages. The setting is off by default, so existing input and repeat-run identities stay compatible.
This filter checks whether an email was extracted; it does not validate mailbox existence, ownership or permission to send mail. A row remains one page version with an email array, not one paid result per address. If none of the examined pages contains an email, the dataset is empty. Pages behind JavaScript rendering, contact forms, access restrictions or your page limit may contain contacts this Actor does not see.
What you can do
- Maintain a business directory from public contact pages.
- Prepare a source-linked contact research spreadsheet.
- Refresh an existing company watchlist and skip unchanged contact versions.
Pricing
The base price is $0.003 per delivered page version ($3 per 1,000 rows), with plan discounts where shown in Apify. A row can include multiple contacts; the price is per delivered row. Identical contact sets repeated on several pages of the same website are emitted only once per run. Pages with no extracted contacts are omitted by default and produce no result charge. If you enable includeEmptyPages, delivered empty-page rows are charged too. When requireEmail: true, pages without an extracted email are omitted and have no result charge, even if includeEmptyPages is also true.
There is no start fee. Apify platform usage is included in this Actor's per-event price. Unchanged versions skipped with onlyNew are not charged. Set a maximum charge in Apify Console or use the API maxTotalChargeUsd parameter.
Quick start
Paste this input in the Actor and choose Start:
{"websiteUrls": ["www.python.org","https://www.djangoproject.com"],"maxPagesPerSite": 3,"includeEmptyPages": false,"maxItems": 10}
For repeated calls, enable onlyNew and reuse stateKey for the same feed. To replay records, change the key or use resetState. History retains the last 25,000 delivered fingerprints; older versions may be delivered again after eviction. Changing your filters with the same key does not clear history. onlyNew scans up to the source scan limit before skipping remembered versions, so repeated runs may read more source records than they deliver. maxItems caps delivered rows.
Export contacts to CSV with n8n
Use the importable workflow or manual recipe below with n8n's built-in nodes; it needs no community-node installation or Google Sheets permission. Connect Manual Trigger → HTTP Request → Code → Convert to File. Keep one trigger item and put the complete website list in one request body: one POST starts one paid Actor run.
Import the ready-made workflow
Download the n8n workflow JSON. In n8n, open the workflow menu, choose Import from URL, and paste that link. You can also save the JSON and choose Import from File.
The template contains four connected built-in nodes and is inactive. Select a private Header Auth credential on Extract contacts — max $0.03: header name Authorization, value Bearer YOUR_APIFY_TOKEN. Edit websiteUrls in that node's JSON body before executing. No token, credential ID or contact results are included in the shared file.
Each manual execution starts one new paid snapshot, with a $0.03 server-side cap and automatic retries disabled. Review the input and current pricing before running it. A lost response may still have started a run; check Apify Console before trying again. Empty results may produce no CSV file.
The JSON settings and embedded transformation were checked locally against the current node definitions; import and execution in a live n8n instance have not been verified. It uses HTTP Request 4.2, Code 2 and Convert to File 1.1. The manual recipe below is available if your instance needs different node settings.
1. Configure the HTTP Request
In a new HTTP Request node, choose Import cURL and paste this command. It intentionally contains no API token:
curl --request POST \--url 'https://api.apify.com/v2/acts/signalfoundryhq~website-contact-extractor/run-sync-get-dataset-items?maxTotalChargeUsd=0.03&memory=256&timeout=120' \--header 'Content-Type: application/json' \--data '{"websiteUrls":["www.python.org/about/help/","https://www.djangoproject.com/contact/foundation/"],"maxPagesPerSite":1,"maxItems":10,"includeEmptyPages":false,"requireEmail":true,"onlyNew":false,"stateKey":"n8n-csv-export"}'
After import, set the body to JSON → Using JSON, then paste the object below. n8n's cURL importer can import values as strings; this step preserves the required numbers and booleans.
{"websiteUrls": ["www.python.org/about/help/","https://www.djangoproject.com/contact/foundation/"],"maxPagesPerSite": 1,"maxItems": 10,"includeEmptyPages": false,"requireEmail": true,"onlyNew": false,"stateKey": "n8n-csv-export"}
For authentication, select Generic Credential Type → Header Auth. Store the header name Authorization and value Bearer YOUR_APIFY_TOKEN in a private n8n credential, replacing the placeholder with your own Apify token. Keep the token out of shared workflow JSON and request URLs.
Set Response Format to JSON, Timeout to 150000 milliseconds, and keep Include Response Headers and Status, Never Error, pagination and Retry On Fail off. Connect the Manual Trigger directly to this node; do not loop the POST once per website or incoming contact.
The query caps this run at $0.03, with a 120-second Actor timeout. At the current base price of $0.003 per delivered page version, ten rows cost $0.03 including platform usage; Store plan discounts can lower that price. requireEmail: true excludes no-email page rows before result charging. The cap does not guarantee ten rows, and the extractor does not verify mailboxes or find private executive emails.
2. Flatten contact arrays
Set the Code node to JavaScript → Run Once for All Items and paste:
// n8n Code node: JavaScript, Run Once for All Items.const fields = ['websiteUrl', 'pageUrl', 'pageTitle', 'emails', 'phones', 'socialLinks'];function spreadsheetCell(value) {const text = Array.isArray(value)? value.map(String).join('; '): value == null ? '' : String(value);const visible = text.replace(/^[\s\uFEFF]+/, '');return /^[=+\-@=+-@]/.test(visible) || /^[\t\r\n]/.test(text)? "'" + text: text;}return $input.all().map((item, index) => {const row = item.json;if (!row || typeof row.pageUrl !== 'string' || !Array.isArray(row.emails)) {throw new Error('Expected a contact dataset row. Check the HTTP response and keep full-response mode off.');}return {json: Object.fromEntries(fields.map(field => [field, spreadsheetCell(row[field])])),pairedItem: { item: index },};});
This keeps one row per delivered page version, joins each contact array into one semicolon-separated cell, and retains its source URLs. Formula-like cells receive a leading apostrophe, including phone numbers beginning with +; that prefix changes the exported string. Import untrusted spreadsheet columns as text and keep the original JSON when you need unmodified phone numbers or arrays.
3. Download the CSV
Set Convert to File to Convert to CSV, put the output file in data, use contacts.csv as the file name, and enable Header Row. Execute the manual workflow after checking the input and charge cap, then download the output file. An empty result array can stop the downstream nodes without producing a file; check the Actor run's dataset and logs before trying again.
Each execution starts a new snapshot run because this example uses onlyNew: false; repeating it can charge for the same records again. If n8n times out or loses the response, inspect recent runs in Apify Console and retrieve the existing dataset before repeating the POST. For jobs beyond the synchronous window, use the asynchronous run API and fetch the dataset when that same run finishes.
References: n8n HTTP Request and cURL import, private HTTP credentials, Code node, Convert to File.
Export contacts to CSV with Python
Download the Python client
Python client (.py) · Synthetic sample dataset (.json)
Save both linked files in one folder. Try the client without an API token, network request or new Actor run:
$python3 export_contacts.py --from-json sample_contacts.json --output contacts.csv
This writes two CSV rows from synthetic example.com page records. Add --require-email to write one email-bearing row from that same file. These are demonstration records, not collected leads. To scrape real websites, follow the paid-run instructions below. The complete client is also included below for copying.
Save the complete script below as export_contacts.py. It uses Python 3.9+ and the standard library. Put one public domain or contact-page URL per line in a UTF-8 file called sites.txt:
www.python.org/about/help/https://www.djangoproject.com/contact/foundation/
Set APIFY_TOKEN to your own Apify API token, then run:
$python3 export_contacts.py --websites sites.txt --output contacts.csv --max-charge 0.03 --max-items 10 --max-pages 1
This starts a new paid Actor run. The default cap is $0.03, with up to ten delivered page versions and one page examined per website. The base price is $0.003 per delivered page version; plan discounts can lower it. The cap and row limit do not guarantee ten results. Repeating this snapshot command starts another run and can charge for the same records again.
For an email-bearing-pages-only CSV, add --require-email:
$python3 export_contacts.py --websites sites.txt --require-email --output contacts-with-email.csv --max-charge 0.03 --max-items 10 --max-pages 1
For a new run, the flag sends requireEmail: true to the Actor, so no-email rows are removed before result charging, rather than hidden afterwards in your CSV. If your starting page has no email, increase --max-pages to examine linked contact/about pages; this does not guarantee a match.
If you already downloaded the dataset as JSON, convert that existing file without a new Actor run or API token:
$python3 export_contacts.py --from-json dataset.json --output contacts.csv
You can also add --require-email to this offline command. It filters the existing JSON locally and cannot change any charges from the original run.
The CSV preserves source-page URLs and stores each contact array in one semicolon-separated cell. It includes a UTF-8 BOM for common spreadsheet imports. Cells that could begin spreadsheet formulas receive a leading apostrophe, including phone numbers starting with +; that prefix is part of the exported data. Spreadsheet behavior varies, especially after saving and reopening a CSV. Import untrusted columns as text and retain the original JSON for automated processing. See OWASP's CSV injection guidance.
The HTTP client sends one POST with a server-side run charge limit and no automatic retries. If the connection fails, inspect the Actor's recent runs in Console before retrying; a broken response does not prove that no run started. This example uses a 120-second Actor timeout and a 150-second client timeout. For longer jobs, use the asynchronous run API and retrieve its dataset after completion.
#!/usr/bin/env python3"""Export Contact Scraper rows to a spreadsheet-oriented UTF-8 CSV."""import argparseimport csvfrom decimal import Decimal, InvalidOperationimport jsonimport osfrom pathlib import Pathimport socketimport sysfrom urllib.error import HTTPError, URLErrorfrom urllib.parse import urlencodefrom urllib.request import Request, urlopenACTOR = 'signalfoundryhq~website-contact-extractor'FIELDS = ('websiteUrl', 'pageUrl', 'pageTitle', 'emails', 'phones', 'socialLinks')def positive_usd(text):try:value = Decimal(text)except InvalidOperation:raise argparse.ArgumentTypeError('Use a positive USD amount.')if not value.is_finite() or value <= 0:raise argparse.ArgumentTypeError('Use a positive USD amount.')return valuedef run_actor(websites, token, max_charge, max_pages=1, max_items=10, *, require_email=False, build='latest'):if not 1 <= len(websites) <= 25:raise ValueError('Provide 1 to 25 non-empty websites or contact-page URLs.')if not 1 <= max_pages <= 10 or not 1 <= max_items <= 250:raise ValueError('Use 1–10 pages per site and 1–250 delivered rows.')if not token:raise ValueError('Set APIFY_TOKEN to your own Apify API token.')query = urlencode({'maxTotalChargeUsd': format(max_charge, 'f'), 'memory': 256, 'timeout': 120, 'build': build})payload = {'websiteUrls': websites, 'maxPagesPerSite': max_pages, 'maxItems': max_items,'includeEmptyPages': False, 'onlyNew': False, 'stateKey': 'csv-export','requireEmail': require_email}request = Request(f'https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items?{query}',data=json.dumps(payload).encode('utf-8'), method='POST',headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'})# Do not automatically retry a paid POST: an interrupted response can hide a live run.with urlopen(request, timeout=150) as response:rows = json.load(response)if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):raise ValueError('Expected a JSON array of dataset records; no CSV was written.')if require_email and any(not row.get('emails') for row in rows):raise ValueError('The run returned a row without email despite requireEmail. Check the run and selected build in Console before retrying; no CSV was written.')return rowsdef spreadsheet_cell(value):if value is None:text = ''elif isinstance(value, list):text = '; '.join(str(item) for item in value)else:text = str(value)# Quoting alone does not prevent spreadsheet formula interpretation.visible = text.lstrip(' \t\r\n\ufeff')if visible.startswith(('=', '+', '-', '@', '=', '+', '-', '@')) or text.startswith(('\t', '\r', '\n')):return "'" + textreturn textdef write_csv(rows, output):if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):raise ValueError('Expected an array of dataset records.')with Path(output).open('w', encoding='utf-8-sig', newline='') as handle:writer = csv.DictWriter(handle, fieldnames=FIELDS, quoting=csv.QUOTE_ALL)writer.writeheader()for row in rows:writer.writerow({key: spreadsheet_cell(row.get(key)) for key in FIELDS})def main(argv=None):parser = argparse.ArgumentParser(description=__doc__)source = parser.add_mutually_exclusive_group(required=True)source.add_argument('--websites', type=Path, help='UTF-8 text file, one domain or URL per line')source.add_argument('--from-json', type=Path, help='Convert an existing dataset JSON array without a new run')parser.add_argument('--output', type=Path, default=Path('contacts.csv'))parser.add_argument('--max-charge', type=positive_usd, default=Decimal('0.03'))parser.add_argument('--max-pages', type=int, default=1)parser.add_argument('--max-items', type=int, default=10)parser.add_argument('--require-email', action='store_true', help='Keep only pages containing an extracted email; applied before result charging for a new run')args = parser.parse_args(argv)try:if args.from_json:rows = json.loads(args.from_json.read_text(encoding='utf-8-sig'))if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):raise ValueError('Expected an array of dataset records.')if args.require_email:rows = [row for row in rows if row.get('emails')]else:sites = [line.strip() for line in args.websites.read_text(encoding='utf-8-sig').splitlines() if line.strip()]rows = run_actor(sites, os.environ.get('APIFY_TOKEN', '').strip(), args.max_charge,args.max_pages, args.max_items, require_email=args.require_email)write_csv(rows, args.output)except (HTTPError, URLError, TimeoutError, socket.timeout) as error:status = f' (HTTP {error.code})' if isinstance(error, HTTPError) else ''print(f'Request did not finish{status}. Check this Actor in Apify Console before retrying; a run may already exist. No automatic retry.', file=sys.stderr)return 1except (OSError, ValueError) as error:print(str(error), file=sys.stderr)return 1print(f'Wrote {len(rows)} page records to {args.output}')return 0if __name__ == '__main__':raise SystemExit(main())
API and export
POST your input to https://api.apify.com/v2/acts/signalfoundryhq~website-contact-extractor/runs?maxTotalChargeUsd=0.05 using an Apify token in the Authorization header. After the run succeeds, read defaultDatasetId from the run and retrieve /v2/datasets/{datasetId}/items. Apify also provides CSV and other exports in Console. OUTPUT contains delivered, duplicatesSkipped and eventChargeLimitReached. Never put tokens in public URLs.
For a compact CSV, request the dataset items with these parameters, using the same Authorization header:
GET https://api.apify.com/v2/datasets/{datasetId}/items?format=csv&clean=true&fields=websiteUrl,pageUrl,pageTitle,emails,phones,socialLinks
Every record includes signalId, recordFingerprint, source, collectedAt and a source page/API URL. Metadata changes create new fingerprints. No background schedule is created by this Actor.
If you get no rows or fewer rows than expected
- Start with the actual contact page. With
maxPagesPerSite: 1, only the starting page is fetched. Use its contact URL directly, or increase the page limit to follow same-origin contact/about/support links. - Check whether the page exposes contacts in HTML. Forms without a published address, contacts inserted by JavaScript and obfuscated addresses may produce no email results. The Actor does not guess addresses or bypass access controls.
- Check run status and logs. A robots exclusion, denied request or non-HTML starting page can fail the run. A successful run can still have an empty dataset when no contacts match.
- Check duplicate history. With
onlyNewenabled,OUTPUT.duplicatesSkippedshows remembered versions that were omitted. TurnonlyNewoff to export them again; delivered rows are charged normally. - Check the limits.
maxItems,maxPagesPerSiteand the maximum charge can stop delivery early.OUTPUT.eventChargeLimitReachedidentifies a result-charge limit. Repeated contact sets within a website are collapsed into one row.
Scope and limitations
Server-rendered HTML only. No browser rendering, login, email guessing, mailbox verification or obfuscation bypass. Contact/about/support links are followed only on the current origin. Default: 3 fetched pages per site; maximum: 25 sites × 10 pages, 250 delivered page rows. Robots exclusions are respected, and private/reserved addresses are rejected. Plain-text phone extraction can be imperfect; values retain the page URL for review. A contact record does not imply permission to contact someone.
Source documentation: Public website HTML. SignalFoundry is independent of the source platform.
Related tool
For product prices, SKUs and stock flags from public WooCommerce catalogs, use WooCommerce Products Scraper. It is a separate Actor priced at $0.0015 per delivered product version.
Support
Open an issue on this Actor with the run URL and a non-sensitive description of the problem. Do not include tokens, passwords or private data.