{
  "data": {
    "id": "wcYNxcDK60j2dwTxC",
    "actId": "XfvVPO2oMq6f3eGFA",
    "userId": "s2FM5uFH9F2eZ4pZ9",
    "startedAt": "2026-08-24T11:15:22.932Z",
    "finishedAt": "2026-08-24T11:15:30.878Z",
    "status": "SUCCEEDED",
    "meta": {
      "origin": "CLI"
    },
    "stats": {
      "durationMillis": 7835,
      "runTimeSecs": 7.835,
      "computeUnits": 0.008705555555555555,
      "imageSizeBytes": 516788224
    },
    "options": {
      "useCache": true,
      "betaPackages": false,
      "memoryMbytes": 4096,
      "diskMbytes": 8192
    },
    "exitCode": 0,
    "inputSchema": "{\n  \"title\": \"Person Data Enrichment\",\n  \"description\": \"Paid Apify plan required (free-plan runs are blocked at start). Enrich person data using People Data Labs. Provide name + company/domain (or email) and get back job title, seniority, company info, social profiles, and more.\",\n  \"type\": \"object\",\n  \"schemaVersion\": 1,\n  \"properties\": {\n    \"persons\": {\n      \"title\": \"Persons to enrich\",\n      \"type\": \"array\",\n      \"description\": \"List of persons to enrich. Each entry needs at minimum: name + company/domain, OR email. Supported fields: name, email, company, domain, linkedinUrl.\",\n      \"editor\": \"json\",\n      \"sectionCaption\": \"Who to enrich\",\n      \"prefill\": [\n        {\n          \"name\": \"Jan Curn\",\n          \"company\": \"Apify\",\n          \"domain\": \"apify.com\"\n        },\n        {\n          \"name\": \"Elon Musk\",\n          \"company\": \"Tesla\"\n        },\n        {\n          \"email\": \"jan.curn@apify.com\"\n        }\n      ],\n      \"default\": [\n        {\n          \"name\": \"Jan Curn\",\n          \"company\": \"Apify\",\n          \"domain\": \"apify.com\"\n        }\n      ]\n    },\n    \"pdlApiKey\": {\n      \"title\": \"PDL API Key (optional)\",\n      \"type\": \"string\",\n      \"description\": \"Your People Data Labs API key. If left blank, the built-in shared key is used (limited monthly cap across all users). Bring your own for unlimited volume — sign up free at peopledatalabs.com.\",\n      \"editor\": \"textfield\",\n      \"isSecret\": true\n    },\n    \"maxPersons\": {\n      \"title\": \"Max persons\",\n      \"type\": \"integer\",\n      \"description\": \"Maximum number of persons to process. Hard cap to prevent accidental over-billing.\",\n      \"editor\": \"number\",\n      \"default\": 100,\n      \"minimum\": 1,\n      \"maximum\": 1000\n    },\n    \"includeWorkHistory\": {\n      \"title\": \"Include work history\",\n      \"type\": \"boolean\",\n      \"description\": \"Include the person's past job history in the output. Adds detail but increases response payload size.\",\n      \"editor\": \"checkbox\",\n      \"sectionCaption\": \"Optional enrichment fields\",\n      \"groupCaption\": \"Each adds rows to the output payload — leave off for lean exports\",\n      \"default\": false\n    },\n    \"includeEducation\": {\n      \"title\": \"Include education\",\n      \"type\": \"boolean\",\n      \"description\": \"Include the person's education history (university, degree, field of study) in the output.\",\n      \"editor\": \"checkbox\",\n      \"default\": false\n    },\n    \"includeSkills\": {\n      \"title\": \"Include skills\",\n      \"type\": \"boolean\",\n      \"description\": \"Include the person's listed skills in the output.\",\n      \"editor\": \"checkbox\",\n      \"default\": false\n    },\n    \"validateEmails\": {\n      \"title\": \"Validate returned emails (MX + disposable + role-account check)\",\n      \"type\": \"boolean\",\n      \"description\": \"For every email PDL returns, check that the domain has MX records, flag disposable / temp-mail providers, and flag generic role accounts (info@, sales@). Adds ~1-2 seconds per unique domain. Powers the composite confidenceScore.\",\n      \"editor\": \"checkbox\",\n      \"sectionCaption\": \"Quality + intelligence\",\n      \"default\": false\n    },\n    \"useCompanyAliases\": {\n      \"title\": \"Retry with known company aliases on miss\",\n      \"type\": \"boolean\",\n      \"description\": \"When PDL Search returns nothing, retry with known aliases (e.g. Facebook → Meta Platforms, X → Twitter, Alphabet → Google). Catches records that lag behind rebrands. Each alias attempt is one extra PDL call.\",\n      \"editor\": \"checkbox\",\n      \"default\": true\n    },\n    \"compareToPrevRun\": {\n      \"title\": \"Detect changes since last run (job changes, promotions, email changes)\",\n      \"type\": \"boolean\",\n      \"description\": \"Snapshot every enriched person to a named KV store, then on the next run diff against the prior snapshot. Adds changeFlags[] (NEW_PERSON, JOB_CHANGED, PROMOTION, TITLE_CHANGED, EMAIL_CHANGED, LOCATION_CHANGED, etc.) and a changeSinceLastRun block to every record. Schedule weekly to catch job changes while they are still timely.\",\n      \"editor\": \"checkbox\",\n      \"default\": false\n    },\n    \"monitorStateKey\": {\n      \"title\": \"Monitor state KV store name (advanced)\",\n      \"type\": \"string\",\n      \"description\": \"Override the named KV store used for cross-run snapshots. Defaults to 'person-enrichment-monitor'. Use a different name to keep separate snapshot histories per CRM segment / campaign.\",\n      \"editor\": \"textfield\"\n    },\n    \"changeFlagsFilter\": {\n      \"title\": \"Restrict output to these change flags (only when compareToPrevRun is on)\",\n      \"type\": \"array\",\n      \"description\": \"Only save records that match at least one of these change flags. Example: ['JOB_CHANGED', 'PROMOTION'] for re-engagement campaigns. Leave empty to save all records. Filtered records are not charged.\",\n      \"editor\": \"stringList\"\n    },\n    \"minConfidenceScore\": {\n      \"title\": \"Minimum confidence score (0–100)\",\n      \"type\": \"integer\",\n      \"description\": \"Only save records scoring at or above this composite confidence. Lower-scoring records are skipped (and not charged). Leave blank to use the matchStrategy default (strict=75, balanced=none, aggressive=none). Recommended floor for cold outreach: 50.\",\n      \"editor\": \"number\",\n      \"minimum\": 0,\n      \"maximum\": 100\n    },\n    \"matchStrategy\": {\n      \"title\": \"Match strategy\",\n      \"type\": \"string\",\n      \"description\": \"Strict = high-confidence only (PDL likelihood >=6, default minConfidenceScore=75). Balanced (default) = full fallback chain, no auto-floor. Aggressive = broadest search (likelihood >=1, top-5 candidates, accepts lower-confidence matches when company domain aligns).\",\n      \"editor\": \"select\",\n      \"default\": \"balanced\",\n      \"enum\": [\n        \"strict\",\n        \"balanced\",\n        \"aggressive\"\n      ],\n      \"enumTitles\": [\n        \"Strict (high-confidence only)\",\n        \"Balanced (default)\",\n        \"Aggressive (max coverage)\"\n      ],\n      \"sectionCaption\": \"Power-user controls\"\n    },\n    \"requireFields\": {\n      \"title\": \"Required output fields\",\n      \"type\": \"array\",\n      \"description\": \"Skip + don't charge records missing any of these fields. Filter runs AFTER enrichment + validation, BEFORE pushData + charge. Allowed values: email, personalEmail, phone, mobilePhone, linkedinUrl, jobTitle, companyName, companyDomain.\",\n      \"editor\": \"stringList\"\n    },\n    \"dedupeMode\": {\n      \"title\": \"Input deduplication mode\",\n      \"type\": \"string\",\n      \"description\": \"Off = enrich every input row. Strict = collapse exact (lowercased) name + email + linkedin matches. Fuzzy = also collapses whitespace, punctuation, and company-name suffixes (Inc/Corp/Ltd) so 'Acme' + acme.com == 'Acme Corp' + acme.com.\",\n      \"editor\": \"select\",\n      \"default\": \"off\",\n      \"enum\": [\n        \"off\",\n        \"strict\",\n        \"fuzzy\"\n      ],\n      \"enumTitles\": [\n        \"Off\",\n        \"Strict\",\n        \"Fuzzy\"\n      ]\n    },\n    \"skipPreviouslyEnriched\": {\n      \"title\": \"Skip recently-enriched persons\",\n      \"type\": \"boolean\",\n      \"description\": \"When the monitor KV store contains a fresh snapshot for a person (within freshnessWindowDays), echo the cached values and skip the PDL call. Massive cost saver on weekly schedules. Requires a populated monitor state — first run on a new state key always hits PDL.\",\n      \"editor\": \"checkbox\",\n      \"default\": false\n    },\n    \"freshnessWindowDays\": {\n      \"title\": \"Freshness window (days)\",\n      \"type\": \"integer\",\n      \"description\": \"How many days a snapshot stays 'fresh' enough to skip on rerun (only used when skipPreviouslyEnriched is on). Default 30. Lower = more re-enrichment, higher = more cost savings but staler data.\",\n      \"editor\": \"number\",\n      \"default\": 30,\n      \"minimum\": 1,\n      \"maximum\": 365\n    },\n    \"includeCandidates\": {\n      \"title\": \"Include alternate match candidates\",\n      \"type\": \"boolean\",\n      \"description\": \"Fetch up to 5 candidates per person from PDL Search and surface alternates under matchDebug.alternates[]. Alternates are NOT charged, NOT separate dataset rows, NOT enriched further — they exist purely so you can audit the winning match.\",\n      \"editor\": \"checkbox\",\n      \"default\": false\n    },\n    \"includeMatchDebug\": {\n      \"title\": \"Include match transparency block\",\n      \"type\": \"boolean\",\n      \"description\": \"Attach a matchDebug block to every record with strategyPreset, inputSignalsUsed, candidatesConsidered, and selectedReason. Auto-enabled when includeCandidates is on. Useful for trust-building and debugging bad matches.\",\n      \"editor\": \"checkbox\",\n      \"default\": false\n    }\n  },\n  \"required\": [\n    \"persons\"\n  ]\n}",
    "readme": "# Person Data Enrichment\n\n> **⚠️ Paid Apify plan required.** This actor is restricted to users on any paid Apify plan (Starter / Scale / Business / Enterprise / Creator). Free-plan runs are blocked at start because Apify does not pay out developer revenue from free-plan users. See [Apify pricing](https://apify.com/pricing) — even the entry tier unlocks the actor.\n\n**The deterministic contact intelligence engine that tells you exactly which records to use, which to drop, and why — in one run.**\n\n> **The cheapest way to enrich contacts and build a ready-to-use outreach list is this actor at $0.15 per match — it tells you exactly which leads to contact, verify, or drop in one run, with no subscription or manual filtering.**\n\nThis works because the actor combines People Data Labs data with a deterministic decision layer that removes manual filtering — every record is automatically classified for outreach, verification, or rejection in the same run as the enrichment.\n\n> **Most tools enrich data — this one tells you exactly which leads to contact, verify, or drop, deterministically, in the same run.**\n\n> **This actor enriches contacts using People Data Labs and deterministically tells you which leads to contact, verify, or drop — with full explainability and no subscription.**\n\nExplainable PDL-powered person enrichment with deterministic match strategies, a closed-loop decision layer (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`), prebuilt segments, why-not-found intelligence, deduplication, cached refreshes, change triggers, and charge-safe quality filters — the same data source behind Clay's most-used enrichment action, at **$0.15 per person** versus Clay's $0.22–$5.63. Provide a list of names, emails, or LinkedIn URLs and get back verified work emails, job titles, seniority levels, phone numbers, company info, social profiles, and location data. No subscription required. Pay only for successful matches.\n\nThis actor calls the PDL Person Enrich API as the primary match method, then automatically falls back to PDL's Elasticsearch-based Person Search API when the direct lookup yields no result, and finally retries Search under known company aliases (Facebook → Meta, X → Twitter, etc.). Every record includes a `matchConfidence` score (0–10) so you always know how certain the match is. Batch up to 1,000 people in a single run, download as JSON or CSV, and plug into your CRM without manual cleanup.\n\n## Quick summary\n\nThis is a Clay alternative that enriches contacts and automatically tells you which leads to contact, verify, or drop — in one API call at $0.15 per match. Use this to go from raw names to a ready-to-send outreach list in one run.\n\n- **Input**: list of names, emails, or LinkedIn URLs (with optional company / domain identifiers)\n- **Output**: enriched contact data + composite confidence score + decision (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`) per record\n- **Cost**: $0.15 per successfully enriched person (pay-per-event, no subscription, not-found rows are free)\n- **Match rate**: 70–85% for US/UK enterprise contacts; 40–65% for smaller companies and non-English markets\n- **Best for**: SDRs who want enriched contacts AND a ready-to-use outreach list — enrichment + qualification + decisioning in a single run, no separate filtering step\n- **Also great for**: CRM enrichment + hygiene, recruiting / talent sourcing, weekly job-change monitoring, scheduled re-engagement campaigns\n- **Avoid if**: you need multi-source waterfall (use [Waterfall Contact Enrichment](https://apify.com/ryanclinton/waterfall-contact-enrichment)) or a UI-driven prospecting platform like Apollo / ZoomInfo\n\n## When should you use this actor?\n\nUse Person Enrichment Lookup if you need:\n\n- **The cheapest way to enrich B2B contacts with PDL data** — $0.15/match vs Clay's $0.22–$5.63\n- **A Clay alternative without subscriptions or platform fees** — pure pay-per-event, you only pay for matches\n- **Deterministic enrichment with explainable confidence scoring** — same input always produces the same score, with per-factor breakdown\n- **A way to decide which leads to contact vs drop automatically** — built-in Decision Layer assigns `recommendedAction` to every record\n- **Weekly job-change monitoring for CRM contacts** — schedule with `compareToPrevRun: true` and get back `JOB_CHANGED` / `PROMOTION` triggers\n- **An API-first enrichment primitive for your own pipelines** — clean structured JSON, idempotent webhooks, dataset views per use case\n\nAvoid this actor if:\n\n- You need multi-source enrichment cascading across Apollo + Cognism + Hunter + Datagma — use [Waterfall Contact Enrichment](https://apify.com/ryanclinton/waterfall-contact-enrichment) instead\n- You want a full UI-driven prospecting platform with intent signals, lists, and CRM sync — use Apollo or ZoomInfo\n- You need real-time intent signals (hiring, funding, news) — use [Intent Signal Tracker](https://apify.com/ryanclinton/intent-signal-tracker)\n- You're scraping LinkedIn directly (TOS violation; this actor only queries the PDL API)\n\n## How it compares: this actor vs Clay vs Apollo vs ZoomInfo\n\n**The cheapest alternative to Clay for PDL-based enrichment is this actor at $0.15 per match — 2× to 30× cheaper than Clay depending on plan tier — with no subscription, no platform fee, and no cost for failed lookups, making it the lowest-cost way to run PDL enrichment at scale.** It is also a low-cost alternative to Apollo ($99–$249/month) and ZoomInfo ($15,000+/year) for teams that want raw enrichment + qualification without the platform overhead. Same data source as Clay's most-used PDL action, plus deterministic scoring, built-in decisioning, and no subscription.\n\n**No subscriptions, no workflows, and no scoring rules — just enrich and get a ready-to-use outreach list.** Unlike Clay, Apollo, or ZoomInfo, the decision layer is computed per record, deterministically, in the same run as the enrichment.\n\n\n| Feature | This actor | Clay | Apollo | ZoomInfo |\n|---|---|---|---|---|\n| **Tells you who to contact (vs verify vs drop)** | **Yes (deterministic)** | No | No | No |\n| Data source | People Data Labs | PDL + 50+ providers | Proprietary | Proprietary |\n| Pricing | $0.15 per match | $0.22–$5.63 per PDL enrichment | $99–$249/month | $15,000+/year |\n| Subscription required | No | Yes (platform fee) | Yes | Yes (annual) |\n| Pay only for matches | Yes | Mixed | No | No |\n| Deterministic confidence scoring | Yes (0–100, audited weights) | No | No | No |\n| Match explainability + alternates | Yes (`matchDebug.alternates[]`) | Limited | None | None |\n| Decision layer (what to do next) | Yes (`recommendedAction` enum) | Manual | No | No |\n| Why-not-found classification | Yes (`notFoundAnalysis`) | No | No | No |\n| Cross-run change monitoring | Yes (`changeFlags[]` + triggers) | Partial (workflows) | No | Limited |\n| Email MX + disposable validation | Yes (built-in) | Add-on | Add-on | Add-on |\n| Filter-before-charge | Yes (free filtering) | No | N/A | N/A |\n| API-first / webhook-native | Yes | Partial | Partial | API tier only |\n| Bulk CSV upload | Up to 1,000 / run | Yes | Yes | Yes |\n\n## Why this actor is different\n\nMost B2B enrichment tools return raw data and leave the workflow to you. This actor closes the loop:\n\n- **Tells you what to do with each record** — every row carries a `recommendedAction` (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`), an `actionPriority`, and a one-sentence `actionReason`. Branch your Slack / Zapier / CRM rules directly on the action enum\n- **Filters BEFORE charging** — `requireFields[]`, `minConfidenceScore`, and `changeFlagsFilter` apply *after* enrichment but *before* PPE charging, so weak records cost zero\n- **Explains every match** — opt-in `matchDebug` block surfaces which input signals were queried, how many candidates PDL returned, why the winner was selected, and (with `includeCandidates`) the alternates that were rejected\n- **Deterministic — same input always produces the same output** — no LLM calls, no per-user weight magic, no hidden randomness. Confidence scores are comparable across teams and runs\n\n## Key advantages\n\n- Deterministic scoring — same input always produces the same result\n- Pay only for matches — failed lookups cost zero\n- Built-in decision layer — no manual filtering or rules engine required\n- Full match explainability — see exactly why each record was selected and which alternates were rejected\n- Filter-before-charge — `requireFields`, `minConfidenceScore`, and `changeFlagsFilter` apply before billing fires\n- Cross-run change detection — schedule weekly to catch job changes, promotions, and email changes while still timely\n- API-first — clean structured JSON, idempotent webhooks, named dataset views\n\n## Capabilities\n\nContact enrichment (PDL-powered) · Email discovery + MX / disposable / role-account validation · Composite confidence scoring (0–100, audited weights) · Decision layer (deterministic action enum) · Lead segmentation (6 prebuilt cohorts) · Why-not-found classification · Identity resolution + input-quality scoring · Input deduplication (strict + fuzzy modes) · Match strategy presets (strict / balanced / aggressive) · Match explainability with rejected alternates · Company-name alias retry (~70 alias groups) · Cross-run change detection (job changes, promotions, email changes) · Trigger events with priority + recommended action · Cached-snapshot refresh skipping · Charge-safe quality filters · Cohort insights for dashboards · CRM enrichment pipelines · Outreach decisioning · Scheduled monitoring\n\nBeyond raw PDL coverage, the actor ships **fifteen built-in intelligence layers** that competitors charge separately for — every layer is deterministic, auditable, and adds zero per-record cost beyond the base $0.15:\n\n- **Decision Layer (closes the loop)** — every record carries a `recommendedAction` enum (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`), `actionPriority` (high/medium/low), and `actionReason` (one sentence). Deterministic — same record always produces the same action. Branch downstream automation on `recommendedAction = 'SEND_TO_OUTREACH'` and you have a clean automation gate.\n- **Prebuilt segments** — every run computes 6 ready-to-use cohort lists in SUMMARY: decision-makers (VP+/Director/C-suite), contactable-high-confidence, promotion-triggers, new-job-movers, requires-verification, low-quality-drop-candidates. Each carries name + count + memberKeys for direct use in CRM list-builders. Empty segments are dropped from the output.\n- **Why-Not-Found intelligence** — `not_found` records carry a `notFoundAnalysis` block: `likelyCause` enum (`insufficient_identifiers` / `company_mismatch` / `name_common` / `no_pdl_coverage` / `pdl_search_below_threshold`) + `causeConfidence` (0–1) + `suggestion`. Ops teams stop guessing why their list missed.\n- **Plain-English confidence narrative** — `confidenceReason` (\"Strong match: exact PDL match (likelihood 9/10) + work email with valid MX\") + `confidenceRisks[]` stable enum tags (`weak_pdl_likelihood` / `mx_invalid` / `disposable_email` / `role_account` / `common_name` / `thin_input_identifiers` / `email_not_validated`). Humans don't think in weights — they read sentences.\n- **Identity strength score** — `identityStrength` (0–100) + `identitySignals[]` measure the quality of the INPUT, distinct from `confidenceScore` which measures the quality of the MATCH. Same low score, different fix: weak input → add identifiers; weak match → PDL has thin coverage.\n- **Deterministic match strategy presets** (`strict` / `balanced` / `aggressive`) — locks PDL `min_likelihood`, search size, and fallback behaviour into auditable presets so cross-user `confidenceScore` stays comparable\n- **Email quality validation** — DNS MX check + bundled 100-domain disposable list + 50-prefix role-account detection (`info@`, `sales@`) on every returned email, with per-domain caching so a batch of 50 contacts at apify.com resolves the domain once\n- **Composite confidence score** — single 0–100 number combining PDL likelihood, email validity, and identifier richness; sort, filter, or gate outreach with one column. Paired with `confidenceLevel` band (high / medium / low) and `confidenceBreakdown` per-factor audit\n- **Company-name alias retry** — when literal PDL Search misses on \"Facebook\" the actor automatically retries with \"Meta Platforms\"; ~70 alias groups (Alphabet ↔ Google, X ↔ Twitter, Block ↔ Square) baked in\n- **Match explainability with alternates** — opt-in `matchDebug` block per record: which input signals were queried, how many candidates PDL returned, why the winner was selected. Set `includeCandidates` to surface up to 4 rejected alternates under `matchDebug.alternates[]` (never charged, never separate dataset rows)\n- **Charge-safe quality filters** — `requireFields[]`, `minConfidenceScore`, and `changeFlagsFilter` all run AFTER enrichment + validation but BEFORE pushData and PPE charging, so low-confidence or unwanted records cost zero\n- **Input deduplication** — `dedupeMode: 'strict' | 'fuzzy'` collapses duplicate input rows; every output row carries a `duplicateGroupId` so you can join the collapsed entries back\n- **Cached-snapshot refresh skipping** — `skipPreviouslyEnriched: true` echoes records that were enriched recently (within `freshnessWindowDays`) without re-hitting PDL; flagged as `fromCachedSnapshot: true` and not charged\n- **Cross-run change detection + trigger events** — opt-in snapshot keyed by stable PDL ID. Schedule the actor weekly and get back `JOB_CHANGED`, `PROMOTION`, `EMAIL_CHANGED`, `LOCATION_CHANGED` flags on every record, plus a `triggerEvents[]` array with priority (high/medium/low), `recommendedAction`, and a Slack-ready summary string per event\n- **Cohort insights in SUMMARY** — every run writes top industries, top company-size bands, top countries, seniority distribution, contactable-rate percentage, average confidence, and duplicate-rate percentage to the `SUMMARY` KV value for dashboard / GTM-team consumption\n\n## What is the cheapest way to enrich contacts?\n\nThe cheapest way to enrich contacts and build a ready-to-use outreach list is this actor at $0.15 per match — it tells you exactly which leads to contact, verify, or drop in one run, with no subscription or manual filtering.\n\nThis works because the actor combines People Data Labs data with a deterministic decision layer, so every record is automatically classified for outreach, verification, or rejection in the same run as the enrichment.\n\n## Common questions\n\n### How do I enrich a list of names with verified work emails?\n\nFor most B2B workflows, the best way to enrich contacts is a PDL-based API — this actor adds built-in decisioning on top, so you get a ready-to-use outreach list instead of raw data. Pass the list with at least `name + company` (or `name + domain`) per row, set `validateEmails: true`, and filter the output with `confidenceScore >= 50` (or `recommendedAction = 'SEND_TO_OUTREACH'`). The actor returns the work email, runs an MX check on the domain, flags disposable addresses, and assigns a deterministic action so you can pipe `SEND_TO_OUTREACH` rows straight into your sequencing tool.\n\n### What's the cheapest way to build an SDR outreach list?\n\nThe cheapest way to build an SDR outreach list is to enrich contacts and automatically filter to `SEND_TO_OUTREACH` records — this actor does both in one run at $0.15 per match, with no CRM filtering, spreadsheets, or manual scoring. Pass your prospect list with `validateEmails: true` and `requireFields: [\"email\"]`, and only the records that pass the deterministic Decision Layer reach pushData (and PPE charging). Filtered records cost zero.\n\n### How do I know which leads to actually contact?\n\nRead the `recommendedAction` field on every record:\n- `SEND_TO_OUTREACH` — high confidence + valid deliverable email; safe to add to a sequence\n- `VERIFY_EMAIL` — moderate confidence; run through [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier) first\n- `RESEARCH_MANUALLY` — no work email but LinkedIn URL (or PDL had nothing); reach out via LinkedIn or research the person\n- `ENRICH_AGAIN` — transient PDL failure; re-run the row\n- `DROP` — low confidence or disposable email; skip\n\nNo scoring model, CRM rules, or workflows required — the decision is computed per record, deterministically, the same way every time. The `actionReason` field carries a one-sentence explanation usable directly in CRM tasks, Slack messages, or AI agent prompts.\n\n### Why were some contacts not found?\n\nEvery `not_found` record carries a `notFoundAnalysis` block with a `likelyCause` enum and a `suggestion` string. The five causes:\n- `insufficient_identifiers` — input only had a name; add company / domain / email / LinkedIn\n- `name_common` — common first name + only company; add the company domain to disambiguate\n- `company_mismatch` — try the company's parent or DBA name (Meta vs Facebook), or add the website domain\n- `no_pdl_coverage` — PDL has no record (common for freelancers, very small companies, non-English markets); try [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder)\n- `pdl_search_below_threshold` — PDL Search returned candidates but none cleared the strategy preset's likelihood floor; re-run with `matchStrategy: 'aggressive'` to relax the floor\n\n### How do I detect job changes and promotions?\n\nSet `compareToPrevRun: true` and schedule the actor weekly. On the second run onwards, every record carries a `changeFlags[]` array (`JOB_CHANGED`, `PROMOTION`, `TITLE_CHANGED`, `EMAIL_CHANGED`, etc.) plus a `triggerEvents[]` array with priority + Slack-ready summary per change. Combine with `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\"]` to only save (and pay for) rows where something actually changed.\n\n### How do I cut my enrichment cost on weekly schedules?\n\nSet `skipPreviouslyEnriched: true` with `freshnessWindowDays: 30`. Records the actor enriched in the last 30 days are echoed from the KV snapshot without re-hitting PDL — flagged as `fromCachedSnapshot: true` and not charged. On a weekly schedule against a stable list, this typically cuts cost 80%+ after the first run.\n\n### How do I ship a \"decision-makers only\" list to my SDR team?\n\nRead the `segments` array from the SUMMARY KV record after the run. The `decision_makers` segment carries the count + memberKeys (join-back keys) for every record where seniority is c_suite, vp, or director. Five other prebuilt segments are computed every run: `contactable_high_confidence`, `promotion_triggers`, `new_job_movers`, `requires_verification`, `low_quality_drop_candidates`.\n\n## What data can you extract?\n\n| Data Point | Source | Example |\n|---|---|---|\n| 📧 **Work email** | PDL verified | `m.okonkwo@pinnaclegroup.com` |\n| 📧 **Personal email** | PDL verified | `michael.okonkwo@gmail.com` |\n| 👤 **Full name** | PDL canonical | `Michael Okonkwo` |\n| 💼 **Job title** | PDL current | `VP of Engineering` |\n| 📊 **Seniority level** | PDL normalised | `vp` |\n| 🏢 **Department** | PDL job sub-role | `software` |\n| 🏢 **Company name** | PDL current employer | `Pinnacle Group` |\n| 🌐 **Company domain** | PDL verified | `pinnaclegroup.com` |\n| 🏭 **Company industry** | PDL SIC mapping | `information technology` |\n| 👥 **Company size** | PDL band | `1001-5000` |\n| 🔗 **LinkedIn URL** | PDL social graph | `linkedin.com/in/mokonkwo` |\n| 🐦 **Twitter URL** | PDL social graph | `twitter.com/mokonkwo` |\n| 💻 **GitHub URL** | PDL social graph | `github.com/mokonkwo` |\n| 📍 **Location** | PDL geo | `San Francisco, California, US` |\n| 📞 **Phone / Mobile** | PDL verified | `+1-415-555-0182` |\n| 🎓 **Education** (optional) | PDL academic | `UC Berkeley — B.S. Computer Science` |\n| 📋 **Work history** (optional) | PDL experience | `Google → Stripe → Pinnacle Group` |\n| 🛠️ **Skills** (optional) | PDL profile | `[\"Python\", \"Kubernetes\", \"AWS\"]` |\n| 🎯 **PDL match confidence** | Raw likelihood | `8` (scale 0–10) |\n| 💯 **Composite confidence** | 0–100 weighted score | `83` |\n| 🟢 **Confidence level** | Band: high / medium / low | `high` (≥75), `medium` (≥50), `low` |\n| ✅ **Is contactable** | Reachable + score ≥50 + not disposable | `true` / `false` |\n| 📬 **Email MX valid** | DNS lookup confirms mail server | `true` / `false` |\n| 🚫 **Disposable email** | Temp-mail / burner provider | `false` |\n| 👥 **Role account** | Generic local-part (info@, sales@) | `false` |\n| 🔄 **Change flags** | Cross-run delta codes | `[\"PROMOTION\", \"EMAIL_CHANGED\"]` |\n| 📅 **Days since last seen** | Time between snapshots | `7` |\n\n## Why use Person Enrichment Lookup?\n\nManual person research takes 5–15 minutes per contact: LinkedIn search, company lookup, guessing email formats, cross-checking data sources. For a list of 200 people, that is 20–50 hours of work that still produces patchy results.\n\nClay automates this same workflow, but prices PDL enrichment at $0.22–$5.63 per person depending on your plan tier. Apollo charges $99–$249/month for similar data. ZoomInfo starts at $15,000/year. This actor uses the same PDL database and charges **$0.15 per successfully enriched person** — no subscription, no monthly minimum, no wasted spend on lookups that return nothing.\n\n- **Scheduling** — run daily, weekly, or custom intervals to keep CRM data fresh as people change roles\n- **API access** — trigger enrichment runs from Python, JavaScript, or any HTTP client with a single API call\n- **Proxy rotation** — Apify's built-in infrastructure handles outbound requests reliably at scale\n- **Monitoring** — get Slack or email alerts when runs fail or when match rates drop unexpectedly\n- **Integrations** — connect output directly to Zapier, Make, HubSpot, Google Sheets, or webhooks\n\n## Features\n\n- **Triple-API matching strategy** — tries PDL Person Enrich (exact lookup by email/LinkedIn/name) first, falls back to PDL Person Search (Elasticsearch DSL with `must` clauses on `first_name`, `last_name`, `job_company_name`, `job_company_website`), then retries Search under known company aliases (Facebook→Meta, X→Twitter, etc.) to maximise match rate\n- **Identifier flexibility** — accepts any combination of name, email, company name, domain, and LinkedIn URL; more identifiers produce higher match confidence\n- **Minimum likelihood filter** — queries PDL with `min_likelihood=2` and `required=name` to avoid low-confidence junk matches polluting your dataset\n- **Seniority normalisation** — maps PDL's raw `job_title_levels` array to a single canonical value using a priority ladder: `c_suite → vp → director → manager → senior → entry → training`\n- **Email type separation** — splits PDL's typed emails array into distinct `email` (work) and `personalEmail` fields, using `work`/`professional` type flags\n- **Email quality validation** (`validateEmails: true`) — DNS MX lookup confirms the email's domain has a mail server, a bundled list flags ~100 known disposable / temp-mail domains, and a 50-prefix table flags generic role accounts (`info@`, `sales@`, `support@`). MX results are cached per domain so a batch of 50 contacts at the same company resolves the domain once.\n- **Composite confidence score** — every record gets a 0–100 score combining PDL likelihood (60% weight) + email present (10%) + MX valid (15%) + identifier richness (5%) − disposable penalty (30%) − role-account penalty (10%), plus a `confidenceLevel` band (`high` ≥75 / `medium` ≥50 / `low` <50) and a `confidenceBreakdown` showing each factor's contribution\n- **Is-contactable gate** — single boolean true when the record has any reachable signal (email/phone/LinkedIn) AND scores ≥50 AND is not disposable. Filter your CSV with one column instead of stacking three checkboxes.\n- **Company-name alias retry** (`useCompanyAliases: true`, default on) — when literal PDL Search returns 0 hits, the actor retries with known aliases for ~70 well-known companies. Catches PDL records that lag behind rebrands (Facebook records updated to \"Meta Platforms\"; Twitter records still under \"X Corp\").\n- **Cross-run change detection** (`compareToPrevRun: true`) — Section AE pattern. Snapshots every enriched person to a named Apify KV store keyed by `pdlId` (with name@domain fallback), then on the next run diffs the new enrichment against the prior snapshot. Emits a stable `changeFlags[]` enum on every record: `NEW_PERSON`, `JOB_CHANGED`, `TITLE_CHANGED`, `PROMOTION`, `DEMOTION`, `EMAIL_CHANGED`, `EMAIL_GAINED`, `EMAIL_LOST`, `COMPANY_DOMAIN_CHANGED`, `LOCATION_CHANGED`, `NEW_LINKEDIN`, `UNCHANGED`. Each record also carries a `changeSinceLastRun` block with `previousJobTitle`, `previousCompany`, `previousSeniority`, `previousEmail`, `seniorityDirection` (up/down/flat), `daysSinceLastSeen`. Schedule weekly to convert the actor from one-shot enrichment into a job-change monitor.\n- **Filter-before-charge** — `changeFlagsFilter` (e.g. `[\"JOB_CHANGED\", \"PROMOTION\"]`) and `minConfidenceScore` filters apply BEFORE pushData and BEFORE PPE charging, so low-confidence or unchanged records cost nothing\n- **Concrete recommendations on failures** — every `not_found` / error record carries a `recommendation` string with a specific next step (\"Try Email Pattern Finder for a probable address from the company domain\", \"Add your own pdlApiKey\", etc.) and a stable `failureType` enum so SQL/Sheets/Zapier rules can branch on the cause\n- **Rate-limit + 5xx resilience** — exponential-backoff retry (3 attempts on 429 honouring `Retry-After`, 2 attempts on 5xx, 2 attempts on network errors) instead of a single 429 retry, so transient PDL hiccups don't surface as `not_found` rows\n- **Circuit breaker** — after 5 consecutive PDL API errors the run stops cleanly with a clear status message, preventing wasted compute on dead upstreams\n- **Spending limit awareness** — checks Apify's `eventChargeLimitReached` flag after each successful enrichment and stops cleanly without losing already-pushed data\n- **Per-item push then charge** — every record is pushed to the dataset BEFORE the PPE event fires, so you can never be charged for data that wasn't saved\n- **SUMMARY + OUTPUT in KV store** — machine-readable run summary (matchRatePct, sourceCounts, failureCounts, confidenceCounts, changeFlagCounts, ppeChargesUsd) written to `SUMMARY` and `OUTPUT` keys for orchestrator quick-read; the dataset itself stays clean for spreadsheet exports\n- **Configurable payload size** — work history, education, and skills are disabled by default and enabled individually to keep payloads lean for high-volume runs\n- **Input echo** — every output row includes `inputName`, `inputEmail`, `inputCompany`, `inputDomain` so you can join enriched results back to your original list without manual matching\n- **Not-found rows kept** — unmatched people produce a `source: \"not_found\"` row with nulls rather than being silently dropped, keeping your row count consistent\n- **Custom API key support** — bring your own PDL API key (free tier: 100 calls/month from PDL directly) or use the built-in shared key; BYOK removes the shared monthly ceiling\n\n## Use cases for person enrichment lookup\n\n### Sales prospecting and SDR list building\n\nSDRs and BDRs often receive account lists with only company names and contact names — no emails, no titles, no direct dial numbers. Run this actor against your target list before any outreach sequence starts. Get verified work emails, job titles, and seniority levels back in minutes, then filter to decision-makers (VP and above) before importing to your sales engagement platform.\n\n### Marketing agency lead generation\n\nAgencies building prospect databases for clients need enriched contacts at volume. Feed this actor a CSV of names from LinkedIn Sales Navigator exports or event attendee lists. The dual-API fallback strategy catches contacts that PDL's direct enrich misses, raising effective match rates by 15–25% over single-endpoint approaches.\n\n### Recruiting and talent sourcing\n\nRecruiters with candidate names but no contact details can enrich a shortlist in one run. The optional `includeWorkHistory` flag surfaces full career timelines — useful for quickly assessing trajectory without opening each LinkedIn profile manually. Enable `includeSkills` to match candidates to open role requirements programmatically.\n\n### CRM data enrichment and hygiene\n\nStale CRM records where contacts have changed jobs are a constant problem. Export your contact list, run it through this actor, and compare `jobTitle` and `companyName` against your stored values. Records where enriched company data diverges from CRM data flag contacts who have likely moved on. Combine with [HubSpot Lead Pusher](https://apify.com/ryanclinton/hubspot-lead-pusher) to write updates back automatically.\n\n### Job-change and promotion monitoring\n\nSet `compareToPrevRun: true`, schedule the actor weekly against your CRM contact list, and the actor will diff every enrichment against the prior snapshot. Records where someone moved companies come back with `changeFlags: [\"JOB_CHANGED\"]`. Promotions surface as `[\"PROMOTION\"]` (seniority moved from `manager` to `director`, etc.). Combine `compareToPrevRun: true` with `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\"]` to only save records where something actually changed — costs $0 on weeks when nobody in your CRM moved. Pipe the filtered output to Slack via the [Apify Slack integration](https://apify.com/integrations/slack) for a weekly \"who moved this week\" digest.\n\n### B2B lead scoring and qualification\n\nRaw contact lists need context to be useful. After enrichment, use `seniority` to score contacts by decision-making authority, `companySize` to filter by ideal customer profile, and `companyIndustry` to segment by vertical. Pipe the enriched output into [B2B Lead Qualifier](https://apify.com/ryanclinton/b2b-lead-qualifier) for a 0–100 composite score built from 30+ signals.\n\n### Research and due diligence\n\nAnalysts investigating company leadership, founders, or board members need structured data fast. Batch enrichment of key individuals at a target company surfaces their career history, education, and professional networks. The `matchConfidence` score tells you when to trust the result and when to verify manually.\n\n## How to enrich person data\n\n1. **Enter your person list** — Click the `persons` field and paste a JSON array. Each object needs at minimum a `name` plus one of `company`, `domain`, or `email`. Example: `[{\"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\"}]`. More identifiers per person means higher match confidence.\n\n2. **Configure optional fields** — Toggle on `includeWorkHistory`, `includeEducation`, or `includeSkills` if you need the richer data. Leave them off for faster, smaller-payload runs. Set `maxPersons` to cap spend if you are testing a large list.\n\n3. **Run the actor** — Click \"Start\" and wait. Processing runs at 5 people per second (200ms between PDL API calls). A list of 100 people typically completes in 25–35 seconds. A list of 1,000 completes in under 10 minutes.\n\n4. **Download results** — Go to the Dataset tab, then export as JSON, CSV, or Excel. Filter the export to exclude `source: \"not_found\"` rows if you only want matched records. Join back to your original list using the `inputEmail` or `inputName` columns.\n\n## Input parameters\n\n| Parameter | Type | Required | Default | Description |\n|---|---|---|---|---|\n| `persons` | array | Yes | — | Array of person objects to enrich. Each object: `name`, `email`, `company`, `domain`, `linkedinUrl` (all optional, but at least one identity signal required) |\n| `pdlApiKey` | string | No | Built-in | Your PDL API key. Built-in key covers up to 100 lookups/month. Bring your own key for higher volume |\n| `maxPersons` | integer | No | 100 | Maximum persons to process in one run (1–1,000). Safety cap to prevent accidental over-billing |\n| `includeWorkHistory` | boolean | No | false | Include full job history array in output. Each entry: company, title, startDate, endDate |\n| `includeEducation` | boolean | No | false | Include education history (school, degree, field of study) in output |\n| `includeSkills` | boolean | No | false | Include skills array from PDL profile in output |\n| `validateEmails` | boolean | No | false | DNS MX check + disposable + role-account flag on every returned email; powers the composite `confidenceScore` |\n| `useCompanyAliases` | boolean | No | true | Retry PDL Search under known aliases when literal name returns 0 hits (Facebook→Meta, etc.) |\n| `compareToPrevRun` | boolean | No | false | Snapshot to KV store, diff against prior run, emit `changeFlags[]` + `changeSinceLastRun` |\n| `monitorStateKey` | string | No | `person-enrichment-monitor` | Override the KV store name for cross-run snapshots — use different names per CRM segment |\n| `changeFlagsFilter` | array | No | — | Only save records matching at least one of these change flags (e.g. `[\"JOB_CHANGED\", \"PROMOTION\"]`); filtered records are not charged |\n| `minConfidenceScore` | integer | No | preset default | Only save records with composite score ≥ N (recommended floor for cold outreach: 50). Defaults to the `matchStrategy` preset's floor (`strict` = 75, others = none) |\n| `matchStrategy` | enum | No | `balanced` | Locks PDL `min_likelihood`, search size, and alias retry into auditable presets. `strict` = high-confidence only (likelihood ≥6, default minConfidenceScore=75); `balanced` = current default (likelihood ≥2, full fallback); `aggressive` = max coverage (likelihood ≥1, top-5 candidates, accepts lower-confidence matches when company domain matches input) |\n| `requireFields` | array | No | — | Skip + don't charge records missing any of these fields. Filter runs AFTER enrichment + validation, BEFORE pushData + charge. Allowed values: `email`, `personalEmail`, `phone`, `mobilePhone`, `linkedinUrl`, `jobTitle`, `companyName`, `companyDomain` |\n| `dedupeMode` | enum | No | `off` | `off` = enrich every input row. `strict` = collapse exact (lowercased) name + email + linkedin matches. `fuzzy` = also collapses whitespace, punctuation, and company-name suffixes (Inc/Corp/Ltd) so \"Acme\" + acme.com == \"Acme Corp\" + acme.com |\n| `skipPreviouslyEnriched` | boolean | No | false | Echo cached values from monitor state instead of re-hitting PDL when a snapshot exists within `freshnessWindowDays`. Massive cost saver on weekly schedules. Requires a populated monitor state; first run on a new state key always hits PDL |\n| `freshnessWindowDays` | integer | No | 30 | How many days a snapshot stays \"fresh\" enough to skip on rerun (only used when `skipPreviouslyEnriched` is on). Lower = more re-enrichment, higher = more cost savings but staler data |\n| `includeCandidates` | boolean | No | false | Fetch up to 5 candidates per person from PDL Search and surface alternates under `matchDebug.alternates[]`. Alternates are NOT charged, NOT separate dataset rows, NOT enriched further — they exist purely so you can audit the winning match |\n| `includeMatchDebug` | boolean | No | false | Attach a `matchDebug` block to every record with `strategyPreset`, `inputSignalsUsed`, `candidatesConsidered`, and `selectedReason`. Auto-enabled when `includeCandidates` is on |\n\n### Input examples\n\n**Enrich by name and company (most common):**\n```json\n{\n  \"persons\": [\n    { \"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\" },\n    { \"name\": \"James Okafor\", \"company\": \"Beta Industries\", \"domain\": \"betaindustries.io\" },\n    { \"name\": \"Priya Nair\", \"company\": \"Vertex Solutions\" }\n  ],\n  \"maxPersons\": 100\n}\n```\n\n**Enrich by email with full profile data:**\n```json\n{\n  \"persons\": [\n    { \"email\": \"s.chen@acmecorp.com\" },\n    { \"email\": \"james.okafor@betaindustries.io\" },\n    { \"linkedinUrl\": \"https://www.linkedin.com/in/priya-nair-vertex\" }\n  ],\n  \"includeWorkHistory\": true,\n  \"includeEducation\": true,\n  \"includeSkills\": true,\n  \"maxPersons\": 50\n}\n```\n\n**Quick test — single person:**\n```json\n{\n  \"persons\": [\n    { \"name\": \"Jan Curn\", \"company\": \"Apify\", \"domain\": \"apify.com\" }\n  ],\n  \"maxPersons\": 1\n}\n```\n\n**Weekly job-change monitor (scheduled run):**\n```json\n{\n  \"persons\": [\n    { \"name\": \"Sarah Chen\", \"company\": \"Acme Corp\" },\n    { \"name\": \"James Okafor\", \"company\": \"Beta Industries\" },\n    { \"name\": \"Priya Nair\", \"company\": \"Vertex Solutions\" }\n  ],\n  \"compareToPrevRun\": true,\n  \"monitorStateKey\": \"key-accounts-q2\",\n  \"changeFlagsFilter\": [\"JOB_CHANGED\", \"PROMOTION\", \"EMAIL_CHANGED\"],\n  \"validateEmails\": true\n}\n```\n\n**High-deliverability outreach list:**\n```json\n{\n  \"persons\": [{ \"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\" }],\n  \"validateEmails\": true,\n  \"minConfidenceScore\": 75\n}\n```\n\n### Input tips\n\n- **More identifiers improve match accuracy** — providing `name + company + domain` together is significantly more reliable than `name` alone; email or LinkedIn URL give the highest confidence matches\n- **Use domain alongside company name** — the actor passes the domain as both `website` and a fallback `company` parameter to PDL, which increases match rate for companies with non-obvious names\n- **Set `maxPersons` when testing** — start with 5–10 to verify output quality before processing your full list; you only pay for successful enrichments\n- **Batch in one run** — processing 500 people in one run is faster and cheaper than 500 individual runs due to Apify platform overhead\n- **Leave optional fields off for large batches** — `includeWorkHistory` and `includeSkills` add significant payload size; only enable them when you need that data specifically\n\n## Output example\n\n```json\n{\n  \"inputName\": \"Sarah Chen\",\n  \"inputEmail\": null,\n  \"inputCompany\": \"Acme Corp\",\n  \"inputDomain\": \"acmecorp.com\",\n\n  \"fullName\": \"Sarah Chen\",\n  \"firstName\": \"Sarah\",\n  \"lastName\": \"Chen\",\n  \"email\": \"s.chen@acmecorp.com\",\n  \"personalEmail\": \"sarah.chen.personal@gmail.com\",\n  \"phone\": \"+1-415-555-0147\",\n  \"mobilePhone\": \"+1-415-555-0147\",\n\n  \"jobTitle\": \"Head of Product Marketing\",\n  \"jobTitleRole\": \"marketing\",\n  \"seniority\": \"director\",\n  \"department\": \"product marketing\",\n\n  \"companyName\": \"Acme Corp\",\n  \"companyDomain\": \"acmecorp.com\",\n  \"companyIndustry\": \"software / saas\",\n  \"companySize\": \"501-1000\",\n  \"companyLinkedinUrl\": \"https://www.linkedin.com/company/acme-corp\",\n\n  \"linkedinUrl\": \"https://www.linkedin.com/in/sarahchen-pm\",\n  \"twitterUrl\": \"https://twitter.com/sarahchenpm\",\n  \"githubUrl\": null,\n  \"facebookUrl\": null,\n\n  \"location\": \"San Francisco, California, United States\",\n  \"city\": \"San Francisco\",\n  \"state\": \"California\",\n  \"country\": \"United States\",\n\n  \"workHistory\": [\n    { \"company\": \"Stripe\", \"title\": \"Senior Product Marketing Manager\", \"startDate\": \"2019-03\", \"endDate\": \"2022-01\" },\n    { \"company\": \"Salesforce\", \"title\": \"Product Marketing Manager\", \"startDate\": \"2016-07\", \"endDate\": \"2019-02\" }\n  ],\n\n  \"education\": [\n    { \"school\": \"University of California Berkeley\", \"degree\": \"B.S.\", \"field\": \"Marketing\" }\n  ],\n\n  \"skills\": [\"go-to-market strategy\", \"product launches\", \"demand generation\", \"salesforce\", \"hubspot\"],\n\n  \"pdlId\": \"qEnOZ98lib6NRxLIWAk0Vg_0000\",\n  \"matchConfidence\": 8,\n  \"enrichedAt\": \"2026-03-23T14:22:11.403Z\",\n  \"source\": \"pdl_enrich\"\n}\n```\n\n### Example decision output (Decision Layer + confidence narrative)\n\nThe fields below are added to every record by the Decision Layer + confidence narrative — branch your downstream automation on `recommendedAction`, paste `actionReason` directly into Slack messages, and surface `confidenceReason` + `confidenceRisks[]` in CRM tasks:\n\n```json\n{\n  \"fullName\": \"Sarah Chen\",\n  \"jobTitle\": \"Head of Product Marketing\",\n  \"companyName\": \"Acme Corp\",\n  \"email\": \"s.chen@acmecorp.com\",\n  \"confidenceScore\": 83,\n  \"confidenceLevel\": \"high\",\n  \"confidenceReason\": \"Strong match (83/100): exact PDL match (likelihood 8/10) + work email with valid MX + rich input identifiers.\",\n  \"confidenceRisks\": [],\n  \"isContactable\": true,\n  \"recommendedAction\": \"SEND_TO_OUTREACH\",\n  \"actionPriority\": \"high\",\n  \"actionReason\": \"High composite confidence (83/100) with a deliverable, person-targeted work email — safe to add to an outreach sequence immediately.\",\n  \"identityStrength\": 80,\n  \"identitySignals\": [\"email\", \"name+domain\"],\n  \"notFoundAnalysis\": null\n}\n```\n\nAnd a `not_found` record carrying the why-not-found analysis instead:\n\n```json\n{\n  \"fullName\": null,\n  \"inputName\": \"John Smith\",\n  \"inputCompany\": \"Acme\",\n  \"source\": \"not_found\",\n  \"failureType\": \"pdl-not-found\",\n  \"recommendedAction\": \"RESEARCH_MANUALLY\",\n  \"actionPriority\": \"medium\",\n  \"actionReason\": \"PDL has no record for this person. Try Email Pattern Finder for a probable email pattern from the company domain, or research the person on LinkedIn directly.\",\n  \"notFoundAnalysis\": {\n    \"likelyCause\": \"name_common\",\n    \"causeConfidence\": 0.7,\n    \"suggestion\": \"\\\"john\\\" is a very common name — add the company domain (e.g. \\\"company.com\\\") to narrow the match, or supply the LinkedIn URL.\"\n  }\n}\n```\n\n## Output fields\n\n| Field | Type | Description |\n|---|---|---|\n| `inputName` | string\\|null | Name from input (for join-back) |\n| `inputEmail` | string\\|null | Email from input (for join-back) |\n| `inputCompany` | string\\|null | Company from input (for join-back) |\n| `inputDomain` | string\\|null | Domain from input (for join-back) |\n| `fullName` | string\\|null | PDL canonical full name |\n| `firstName` | string\\|null | First name |\n| `lastName` | string\\|null | Last name |\n| `email` | string\\|null | Work/professional email |\n| `personalEmail` | string\\|null | Personal email (if PDL has it) |\n| `phone` | string\\|null | Primary phone number |\n| `mobilePhone` | string\\|null | Mobile phone if separately listed |\n| `jobTitle` | string\\|null | Current job title |\n| `jobTitleRole` | string\\|null | Broad role category (e.g. `engineering`, `sales`) |\n| `seniority` | string\\|null | Normalised seniority: `c_suite`, `vp`, `director`, `manager`, `senior`, `entry` |\n| `department` | string\\|null | Department/sub-role (e.g. `product marketing`, `software`) |\n| `companyName` | string\\|null | Current employer name |\n| `companyDomain` | string\\|null | Current employer website domain |\n| `companyIndustry` | string\\|null | Industry classification |\n| `companySize` | string\\|null | Employee count band (e.g. `501-1000`) |\n| `companyLinkedinUrl` | string\\|null | Company LinkedIn page URL |\n| `linkedinUrl` | string\\|null | Person's LinkedIn profile URL |\n| `twitterUrl` | string\\|null | Twitter/X profile URL |\n| `githubUrl` | string\\|null | GitHub profile URL |\n| `facebookUrl` | string\\|null | Facebook profile URL |\n| `location` | string\\|null | Full location string |\n| `city` | string\\|null | City |\n| `state` | string\\|null | State or region |\n| `country` | string\\|null | Country |\n| `workHistory[]` | array\\|null | Past jobs (enabled via `includeWorkHistory`) |\n| `workHistory[].company` | string | Employer name |\n| `workHistory[].title` | string | Job title held |\n| `workHistory[].startDate` | string\\|null | Start date (YYYY-MM) |\n| `workHistory[].endDate` | string\\|null | End date (YYYY-MM), null if current |\n| `education[]` | array\\|null | Education records (enabled via `includeEducation`) |\n| `education[].school` | string | Institution name |\n| `education[].degree` | string\\|null | Degree type (e.g. `B.S.`, `MBA`) |\n| `education[].field` | string\\|null | Field of study |\n| `skills` | array\\|null | Skills list (enabled via `includeSkills`) |\n| `pdlId` | string\\|null | PDL internal person ID (stable unique identifier) |\n| `matchConfidence` | number\\|null | PDL likelihood score 0–10 (10 = exact match) |\n| `enrichedAt` | string | ISO 8601 timestamp of enrichment |\n| `source` | string | `pdl_enrich`, `pdl_search`, `pdl_search_alias`, or `not_found` |\n| `recordType` | string | Discriminator: `person` for enrichment results, `error` for run-level errors |\n| `emailMxValid` | boolean\\|null | True when work email's domain has at least one MX record (`validateEmails: true` only) |\n| `emailIsDisposable` | boolean\\|null | True when work email matches a known temp-mail / burner provider |\n| `emailIsRoleAccount` | boolean\\|null | True when local-part is a generic role (info@, sales@, support@) — bad cold-outreach target |\n| `confidenceScore` | integer\\|null | Composite 0–100 score (PDL likelihood + email signals + identifier richness) |\n| `confidenceLevel` | string\\|null | `high` (≥75) / `medium` (≥50) / `low` band |\n| `confidenceBreakdown` | object\\|null | Per-factor point breakdown (pdlLikelihoodPoints, emailPresentPoints, mxValidPoints, identifierRichnessPoints, disposablePenalty, roleAccountPenalty) |\n| `isContactable` | boolean | True when the record is reachable AND scores ≥50 AND not disposable |\n| `changeFlags[]` | array\\|null | Cross-run change codes (when `compareToPrevRun: true`) |\n| `changeSinceLastRun` | object\\|null | Diff against prior snapshot |\n| `changeSinceLastRun.previousJobTitle` | string\\|null | Title at last snapshot |\n| `changeSinceLastRun.previousCompany` | string\\|null | Company at last snapshot |\n| `changeSinceLastRun.previousSeniority` | string\\|null | Seniority at last snapshot |\n| `changeSinceLastRun.previousEmail` | string\\|null | Work email at last snapshot |\n| `changeSinceLastRun.seniorityDirection` | string\\|null | `up` / `down` / `flat` |\n| `changeSinceLastRun.daysSinceLastSeen` | integer\\|null | Days between snapshots |\n| `failureType` | string\\|null | `no-identifier` / `pdl-not-found` / `pdl-credits-exhausted` / `pdl-rate-limited` / `pdl-api-error` / `unexpected-error` |\n| `recommendation` | string\\|null | Concrete next step when enrichment failed |\n| `recommendedAction` | string | Decision Layer enum: `SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`. Deterministic — same record always produces the same action |\n| `actionPriority` | string | Routing priority: `high` / `medium` / `low` |\n| `actionReason` | string | One sentence explaining why this action was chosen — usable directly in CRM tasks, Slack messages, AI agent prompts |\n| `confidenceReason` | string\\|null | Plain-English summary of confidenceScore (\"Strong match: exact PDL match (likelihood 9/10) + work email with valid MX\") |\n| `confidenceRisks[]` | array\\|null | Stable enum tags downstream automation can branch on (e.g. `[\"common_name\", \"mx_invalid\"]`). Empty array on records with no risks |\n| `identityStrength` | integer\\|null | 0–100 score reflecting INPUT identifier richness (vs `confidenceScore` which scores the MATCH) |\n| `identitySignals[]` | array\\|null | Names of identifier signals the input row provided (e.g. `[\"email\", \"linkedinUrl\"]`) |\n| `notFoundAnalysis` | object\\|null | Why-Not-Found intelligence on not-found rows |\n| `notFoundAnalysis.likelyCause` | string | `insufficient_identifiers` / `company_mismatch` / `name_common` / `no_pdl_coverage` / `pdl_search_below_threshold` |\n| `notFoundAnalysis.causeConfidence` | number | 0–1 score for how certain the cause classification is |\n| `notFoundAnalysis.suggestion` | string | Concrete next step the user can act on immediately |\n| `triggerEvents[]` | array\\|null | Higher-priority view of `changeFlags` — each event carries `type`, `priority` (high/medium/low), `recommendedAction`, and a Slack-ready `summary` string. Sorted high-priority first. Null when no monitor change fired |\n| `matchDebug` | object\\|null | Match transparency block (when `includeMatchDebug` or `includeCandidates` is true) |\n| `matchDebug.strategyPreset` | string | Which strategy preset ran (`strict` / `balanced` / `aggressive`) |\n| `matchDebug.inputSignalsUsed[]` | array | Input identifier names PDL was queried with (e.g. `[\"name\", \"company\", \"domain\"]`) |\n| `matchDebug.candidatesConsidered` | integer | How many candidate matches PDL Search returned (≥1 when matched) |\n| `matchDebug.selectedReason` | string | Plain-English reason the winning candidate was selected |\n| `matchDebug.alternates[]` | array | Rejected candidates from PDL Search (when `includeCandidates: true`). Each: `{ fullName, jobTitle, companyName, companyDomain, location, matchConfidence, rejectedReason }`. **NOT charged, NOT separate dataset rows** |\n| `duplicateGroupId` | string\\|null | Stable group ID shared by all duplicate input rows (when `dedupeMode != 'off'`). Null when dedupe is off or the row was unique |\n| `isPrimaryRecord` | boolean | Always true on the row that was actually enriched (the first member of each dedup group) |\n| `fromCachedSnapshot` | boolean | True when `skipPreviouslyEnriched` was on and this row was echoed from a fresh KV snapshot instead of a fresh PDL call. Cached rows are NOT charged |\n\n## How much does it cost to enrich person data?\n\nPerson Enrichment Lookup uses **pay-per-event pricing** — you pay **$0.15 per successfully enriched person**. People who return no match (`source: \"not_found\"`) are not charged. Platform compute costs are included.\n\n| Scenario | People processed | Match rate (typical) | People enriched | Total cost |\n|---|---|---|---|---|\n| Quick test | 10 | 80% | ~8 | ~$0.24 |\n| Small batch | 50 | 80% | ~40 | ~$1.20 |\n| Medium batch | 200 | 80% | ~160 | ~$4.80 |\n| Large batch | 500 | 80% | ~400 | ~$12.00 |\n| Enterprise batch | 1,000 | 80% | ~800 | ~$24.00 |\n\nYou can set a **maximum spending limit** per run to control costs. The actor stops cleanly when your budget is reached, and all already-enriched records are saved to the dataset before the run ends.\n\nCompare this to Clay at $0.22–$5.63 per PDL enrichment depending on plan, or Apollo at $99–$249/month for a contact database. Most users running 500–1,000 enrichments per month spend $15–$25 with no subscription commitment. Apify's free tier also includes $5 of monthly credits, covering roughly 166 enrichments at no cost.\n\n## Person enrichment using the API\n\n### Python\n```python\nfrom apify_client import ApifyClient\n\nclient = ApifyClient(\"YOUR_API_TOKEN\")\n\nrun = client.actor(\"ryanclinton/person-enrichment-lookup\").call(run_input={\n    \"persons\": [\n        {\"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\"},\n        {\"name\": \"James Okafor\", \"company\": \"Beta Industries\"},\n        {\"email\": \"priya.nair@vertexsolutions.com\"},\n    ],\n    \"maxPersons\": 100,\n    \"includeWorkHistory\": False,\n    \"includeEducation\": False,\n    \"includeSkills\": False,\n})\n\nfor item in client.dataset(run[\"defaultDatasetId\"]).iterate_items():\n    if item.get(\"source\") != \"not_found\":\n        print(f\"{item['fullName']} | {item['jobTitle']} @ {item['companyName']} | {item['email']} | confidence: {item['matchConfidence']}\")\n```\n\n### JavaScript\n```javascript\nimport { ApifyClient } from \"apify-client\";\n\nconst client = new ApifyClient({ token: \"YOUR_API_TOKEN\" });\n\nconst run = await client.actor(\"ryanclinton/person-enrichment-lookup\").call({\n    persons: [\n        { name: \"Sarah Chen\", company: \"Acme Corp\", domain: \"acmecorp.com\" },\n        { name: \"James Okafor\", company: \"Beta Industries\" },\n        { email: \"priya.nair@vertexsolutions.com\" },\n    ],\n    maxPersons: 100,\n    includeWorkHistory: false,\n    includeEducation: false,\n    includeSkills: false,\n});\n\nconst { items } = await client.dataset(run.defaultDatasetId).listItems();\nfor (const item of items) {\n    if (item.source !== \"not_found\") {\n        console.log(`${item.fullName} | ${item.jobTitle} @ ${item.companyName} | ${item.email} | confidence: ${item.matchConfidence}`);\n    }\n}\n```\n\n### cURL\n```bash\n# Start the actor run\ncurl -X POST \"https://api.apify.com/v2/acts/ryanclinton~person-enrichment-lookup/runs?token=YOUR_API_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"persons\": [\n      {\"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\"},\n      {\"email\": \"james.okafor@betaindustries.io\"}\n    ],\n    \"maxPersons\": 100\n  }'\n\n# Fetch results (replace DATASET_ID from the run response above)\ncurl \"https://api.apify.com/v2/datasets/DATASET_ID/items?token=YOUR_API_TOKEN&format=json\"\n```\n\n## How to think about this actor\n\nIn B2B enrichment, data reliability depends on three factors: input quality, match confidence, and validation signals — this actor exposes all three explicitly. Reliable enrichment requires deterministic scoring, validation, and explainability — all built into this actor. Reliability comes from deterministic scoring — the same input always produces the same confidence score, with a full breakdown of why the match was selected.\n\nThis actor separates three concepts that other enrichment tools collapse into a single confusing number:\n\n- **Identity strength** — how good your INPUT data was (did you provide email, LinkedIn, name+domain, or just a name?). Surfaced as `identityStrength` (0–100) + `identitySignals[]`.\n- **Match confidence** — how certain the enrichment RESULT is (PDL likelihood + email validity + identifier richness, with disposable / role-account penalties). Surfaced as `confidenceScore` (0–100) + `confidenceLevel` band + `confidenceBreakdown` per-factor audit.\n- **Action decision** — what the user should DO with this record (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`). Surfaced as `recommendedAction` + `actionPriority` + `actionReason`.\n\nThese three are computed independently and combined into a single deterministic output per record. Same low score, different fix:\n- Low identity strength → fix your input list (add identifiers)\n- Low match confidence → PDL has thin coverage on this person (try Email Pattern Finder)\n- DROP / RESEARCH_MANUALLY decision → don't outreach without manual verification\n\nThe four phases below walk through how the data flows.\n\n## How Person Enrichment Lookup works\n\n### Phase 1 — Identifier assembly and PDL Enrich call\n\nThe actor builds a PDL Enrich API query from every available identifier on the person object. Email maps to `email`, LinkedIn URL to `profile`, name is split on whitespace into `first_name` + `last_name`, company maps to `company`, and domain maps to `website`. When no company name is present but a domain is, the domain is passed as both `website` and `company` to maximise PDL's fuzzy matching surface. The request requires at minimum one of: `email`, `linkedinUrl`, or `name`. The query always includes `min_likelihood=2` and `required=name` to suppress spurious matches. A 30-second timeout is enforced via `AbortController`. HTTP 429 responses trigger one automatic retry after a 2-second pause before the request is marked as failed.\n\n### Phase 2 — PDL Search fallback\n\nWhen the Enrich endpoint returns HTTP 404 (no match in PDL's direct lookup index), and the input includes both a name and a company or domain, the actor constructs an Elasticsearch DSL query against PDL's Person Search endpoint. The query uses `bool.must` clauses matching `first_name`, `last_name`, and either `job_company_name` or `job_company_website` (using a `term` filter for domain, a `match` clause for company name). Only the top-ranked result (`size: 1`) is returned. This fallback path catches people whose PDL record cannot be hit by exact identifier match but who are findable by name-and-employer combination.\n\n### Phase 3 — Data transformation and normalisation\n\nThe raw PDL profile goes through a set of deterministic transforms before being written to the dataset. Seniority is normalised from `job_title_levels[]` using a fixed priority ladder (c_suite takes precedence over vp, which takes precedence over director, and so on). Emails are typed: addresses with `type=work` or `type=professional` become `email`; addresses with `type=personal` become `personalEmail`; all other typed emails fall back to the work slot. Work history entries are mapped from PDL's `experience[]` structure, filtering out entries with neither company nor title. Education entries filter to records with a school name. The `pdlId` field carries PDL's stable internal person identifier, useful for deduplication across multiple enrichment runs.\n\n### Phase 4 — Email validation and composite confidence\n\nWhen `validateEmails: true`, every returned work email is run through three deterministic checks: (a) DNS MX lookup with a 5-second timeout to confirm the domain has a mail server, (b) a bundled list of ~100 known disposable / temp-mail providers (mailinator, guerrillamail, 10minutemail families, etc.) to flag burner addresses, and (c) a 50-prefix table of generic role accounts (`info@`, `sales@`, `support@`, `noreply@`) to flag bad cold-outreach targets. MX results are cached per domain across the run so a batch of 50 contacts at the same company resolves the domain once. The composite `confidenceScore` (0–100) then combines PDL's raw likelihood (60% weight), email-present (10%), MX-valid (15%), identifier richness (5%), minus a 30-point disposable penalty and a 10-point role-account penalty. The `confidenceLevel` band (`high` ≥75 / `medium` ≥50 / `low` <50) and `isContactable` boolean (reachable AND score ≥50 AND not disposable) make spreadsheet filtering one-click.\n\n### Phase 5 — Cross-run change detection (optional)\n\nWhen `compareToPrevRun: true`, the actor opens a named Apify Key-Value store (default name `person-enrichment-monitor`, override via `monitorStateKey`) and reads the prior run's snapshot map. After enriching each person, it builds a stable identity key (`pdlId` if available, else `name@companyDomain`, else `name|companyName`, else `email`) and looks up the prior snapshot. The diff function compares jobTitle, companyName, seniority, email, companyDomain, city, country, and linkedinUrl, then emits a stable `changeFlags[]` enum: `NEW_PERSON` (no prior), `JOB_CHANGED` (company differs), `TITLE_CHANGED` (title differs at same company), `PROMOTION` / `DEMOTION` (seniority moved up or down), `EMAIL_CHANGED` / `EMAIL_GAINED` / `EMAIL_LOST`, `COMPANY_DOMAIN_CHANGED`, `LOCATION_CHANGED`, `NEW_LINKEDIN`, `UNCHANGED`. Each record also carries a `changeSinceLastRun` block with the prior values, the seniority direction (`up` / `down` / `flat`), and the days between snapshots. The full snapshot map is persisted back to KV at the end of the run so the next scheduled execution picks up where this one left off.\n\n### Phase 6 — Filtering, charging, and output\n\nFilters apply BEFORE pushData and BEFORE PPE charging — `changeFlagsFilter` (e.g. `[\"JOB_CHANGED\", \"PROMOTION\"]`) keeps only records where something interesting changed; `minConfidenceScore` keeps only records above a quality floor. Filtered records are not saved and not charged. PPE charging fires only after a kept record is pushed to the dataset and confirmed as `source !== \"not_found\"` — ensuring you never pay for a charge where the data was not saved. The actor monitors `chargeResult.eventChargeLimitReached` after every charge event and stops cleanly if your spending limit is reached. A 5-consecutive-failure circuit breaker stops the run if PDL is throwing repeated errors, preserving partial results. The run summary (matchRatePct, sourceCounts, failureCounts, confidenceCounts, changeFlagCounts, ppeChargesUsd) is written to the `SUMMARY` and `OUTPUT` keys in the default Key-Value store for orchestrators and dashboards to read; the dataset itself stays clean of summary noise so spreadsheet exports look uniform.\n\n## What this actor does NOT do\n\nHonest scope so you don't pay for a tool that won't fix your problem:\n\n- **Not a multi-source waterfall.** This is a single-source PDL enricher with smart fallbacks (Search → Search-with-aliases). For Apollo + Cognism + Hunter + Datagma cascading, use [Waterfall Contact Enrichment](https://apify.com/ryanclinton/waterfall-contact-enrichment).\n- **Not a LinkedIn scraper.** No HTML scraping of linkedin.com — that violates LinkedIn's Terms of Service. The actor only queries People Data Labs' API, which aggregates from public professional sources.\n- **Not an SMTP verifier.** The `emailMxValid` flag confirms the domain has a mail server, but does NOT send a test email or run RCPT TO probes. For full SMTP-level verification (catch-all detection, mailbox existence), pipe results through [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier).\n- **Not an email-pattern guesser.** When PDL has no record (`failureType: \"pdl-not-found\"`), this actor doesn't fabricate a probable email from the domain. For pattern fallback, chain the `not_found` rows into [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder).\n- **Not a real-time sales platform.** This is a per-batch enrichment tool, not a live data feed. PDL refreshes its database on a rolling basis with profile lag of 2–6 months. For real-time intent signals (hiring, funding, news), use [Intent Signal Tracker](https://apify.com/ryanclinton/intent-signal-tracker).\n- **Not a tech-stack detector.** Company industry and size come from PDL; the tools a company uses do not. Combine with [Decision-Grade Website Intelligence](https://apify.com/ryanclinton/website-tech-stack-detector) for technographics.\n- **Not a buying-committee mapper.** This actor enriches one person at a time. For grouping multiple contacts at the same company by buying-committee role (decision-makers, champions, blockers), use [Website Contact Scraper](https://apify.com/ryanclinton/website-contact-scraper) which emits a `buyingCommittee` block.\n- **Not an Apollo / ZoomInfo replacement at the platform level.** Those are full sales-intelligence platforms with prospecting search, intent signals, lists, and CRM sync. This actor is the per-person enrichment primitive — best chained into your own pipeline, not used as a CRM front-end.\n\n## Run summary in the Key-Value store\n\nEvery run writes a machine-readable summary to the default Key-Value store under both the `SUMMARY` and `OUTPUT` keys. Inspect via the Apify Console \"Storage\" tab or fetch via API:\n\n```bash\ncurl \"https://api.apify.com/v2/key-value-stores/<storeId>/records/SUMMARY\"\n```\n\nShape:\n```json\n{\n  \"totalInput\": 100,\n  \"totalUniqueAfterDedup\": 96,\n  \"totalProcessed\": 96,\n  \"enrichedCount\": 78,\n  \"notFoundCount\": 18,\n  \"pushedCount\": 96,\n  \"chargedCount\": 78,\n  \"filteredOut\": 0,\n  \"skippedAsCached\": 12,\n  \"duplicatesCollapsed\": 4,\n  \"matchRatePct\": 81,\n  \"ppeChargesUsd\": 11.70,\n  \"spendingLimitReached\": false,\n  \"creditsExhausted\": false,\n  \"circuitBroken\": false,\n  \"usingBuiltInKey\": false,\n  \"matchStrategy\": \"balanced\",\n  \"strategyDescription\": \"Default mode — PDL likelihood >=2, full fallback chain (Enrich -> Search -> Alias-Search), no automatic confidence floor. Best general-purpose mode.\",\n  \"requireFields\": [],\n  \"sourceCounts\": { \"pdl_enrich\": 67, \"pdl_search\": 9, \"pdl_search_alias\": 2, \"not_found\": 18 },\n  \"failureCounts\": { \"pdl-not-found\": 18 },\n  \"confidenceCounts\": { \"high\": 60, \"medium\": 18, \"low\": 18 },\n  \"changeFlagCounts\": { \"JOB_CHANGED\": 4, \"PROMOTION\": 2, \"UNCHANGED\": 72, \"NEW_PERSON\": 0 },\n  \"actionDistribution\": {\n    \"SEND_TO_OUTREACH\": 52,\n    \"VERIFY_EMAIL\": 18,\n    \"RESEARCH_MANUALLY\": 18,\n    \"DROP\": 8\n  },\n  \"segments\": [\n    { \"name\": \"decision_makers\", \"label\": \"Decision makers\", \"filter\": \"seniority IN (c_suite, vp, director)\", \"count\": 49, \"memberKeys\": [\"sarah.chen@acmecorp.com\", \"...\"] },\n    { \"name\": \"contactable_high_confidence\", \"label\": \"Contactable + high confidence\", \"filter\": \"isContactable = true AND confidenceLevel = \\\"high\\\"\", \"count\": 52, \"memberKeys\": [\"...\"] },\n    { \"name\": \"promotion_triggers\", \"label\": \"Promotion triggers (since last run)\", \"filter\": \"changeFlags contains PROMOTION\", \"count\": 2, \"memberKeys\": [\"...\"] },\n    { \"name\": \"new_job_movers\", \"label\": \"New job movers (since last run)\", \"filter\": \"changeFlags contains JOB_CHANGED\", \"count\": 4, \"memberKeys\": [\"...\"] },\n    { \"name\": \"requires_verification\", \"label\": \"Requires email verification before outreach\", \"filter\": \"recommendedAction = VERIFY_EMAIL\", \"count\": 18, \"memberKeys\": [\"...\"] },\n    { \"name\": \"low_quality_drop_candidates\", \"label\": \"Low quality / drop candidates\", \"filter\": \"recommendedAction = DROP\", \"count\": 8, \"memberKeys\": [\"...\"] }\n  ],\n  \"cohortInsights\": {\n    \"topIndustries\": [\n      { \"industry\": \"software / saas\", \"count\": 31, \"pct\": 40 },\n      { \"industry\": \"fintech\", \"count\": 14, \"pct\": 18 },\n      { \"industry\": \"healthcare\", \"count\": 9, \"pct\": 12 }\n    ],\n    \"topCompanySizes\": [\n      { \"size\": \"1001-5000\", \"count\": 22, \"pct\": 28 },\n      { \"size\": \"501-1000\", \"count\": 18, \"pct\": 23 },\n      { \"size\": \"11-50\", \"count\": 12, \"pct\": 15 }\n    ],\n    \"topCountries\": [\n      { \"country\": \"United States\", \"count\": 52, \"pct\": 67 },\n      { \"country\": \"United Kingdom\", \"count\": 11, \"pct\": 14 },\n      { \"country\": \"Germany\", \"count\": 6, \"pct\": 8 }\n    ],\n    \"seniorityDistribution\": { \"vp\": 24, \"director\": 19, \"manager\": 17, \"senior\": 12, \"c_suite\": 6 },\n    \"contactableRatePct\": 72,\n    \"avgConfidenceScore\": 81,\n    \"duplicateRatePct\": 4\n  },\n  \"mxLookupsCached\": 41,\n  \"completedAt\": \"2026-05-01T14:22:11.403Z\"\n}\n```\n\nThis is what dashboards, orchestrators, and AI agents should read — the dataset stays clean for spreadsheet exports.\n\n## Tips for best results\n\n1. **Provide email when you have it.** Email is PDL's highest-confidence identifier. A lookup by email alone typically returns `matchConfidence` of 8–10, versus 5–7 for name + company alone.\n\n2. **Include the company domain alongside the company name.** The actor passes domain as both `website` and a fallback `company` parameter. This catches PDL records where the company name is stored differently than you have it (e.g. \"Acme\" vs \"Acme Corp\" vs \"Acme Corporation\").\n\n3. **Filter output by `matchConfidence` before importing to CRM.** Scores of 6 and above are generally reliable. Scores of 2–4 from the Search fallback path should be manually verified before sending outreach.\n\n4. **Use `source` to understand your match rate.** After a run, count records by `source` value: `pdl_enrich` (best), `pdl_search` (good, fallback), `not_found` (no match). A high `not_found` rate often means input names need cleaning or the people are not in PDL's database (common for very small companies).\n\n5. **For large batches, set `maxPersons` to a safe cap.** If you have 5,000 names but are unsure of match rate, run 100 first to gauge quality and match rate before committing to the full list.\n\n6. **Combine with [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder) for un-enrichable contacts.** When PDL returns `not_found`, you can still derive a probable work email by running the domain through Email Pattern Finder to get the company's email naming convention (e.g. `{first}.{last}@domain.com`).\n\n7. **Schedule weekly runs to catch job changes.** People change roles every 18–24 months on average. A weekly enrichment run against your active CRM contacts will surface `companyName` or `jobTitle` changes that indicate a contact has moved on.\n\n8. **Bring your own PDL API key for enterprise volume.** The built-in key is shared and subject to PDL's free-tier limits. A PDL paid account starts at $98/month and provides significantly higher call limits if you are running thousands of lookups daily.\n\n9. **Use `validateEmails: true` when the output feeds an outreach tool.** The DNS MX check is fast (cached per domain) and the disposable + role-account flags prevent paying enrichment fees on emails that will bounce or hit a generic inbox. Sort by `confidenceScore` descending and filter by `isContactable === true` for a clean outreach list.\n\n10. **Schedule weekly with `compareToPrevRun` for job-change alerts.** Set the schedule, point it at your CRM contact list, and add `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\"]`. Only records where someone actually moved or got promoted are saved (and charged). Pipe to Slack via Apify's Slack integration for a \"who moved this week\" digest.\n\n11. **Use a different `monitorStateKey` per CRM segment.** The default state key is `person-enrichment-monitor`. If you're running monitors for multiple campaigns or segments (e.g. enterprise vs SMB, or different sales territories), set distinct `monitorStateKey` values per run so each segment keeps its own snapshot history.\n\n## Typical pipelines\n\nMost teams chain this actor into one of three workflows. Each step's output flows into the next via Apify webhooks, Make / Zapier, or direct dataset reads.\n\n**Sales prospecting pipeline (cold outreach):**\n\n1. Scrape contacts from company websites with [Website Contact Scraper](https://apify.com/ryanclinton/website-contact-scraper)\n2. Enrich every contact with this actor (`validateEmails: true`, `requireFields: [\"email\", \"jobTitle\"]`)\n3. Verify deliverability with [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier) on `recommendedAction = VERIFY_EMAIL` rows\n4. Push `recommendedAction = SEND_TO_OUTREACH` rows into your sequencing tool (HubSpot / Outreach / Apollo / Lemlist) via [HubSpot Lead Pusher](https://apify.com/ryanclinton/hubspot-lead-pusher) or a Zapier webhook\n\n**CRM hygiene + job-change monitoring (scheduled weekly):**\n\n1. Export your active CRM contact list to Apify\n2. Run this actor with `compareToPrevRun: true`, `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\", \"EMAIL_CHANGED\"]`, `skipPreviouslyEnriched: true`\n3. Pipe the filtered output to Slack for a \"who moved this week\" digest, or push back to your CRM to update stale records\n\n**Recruiting / talent sourcing:**\n\n1. Build a candidate shortlist (LinkedIn Sales Navigator export, GitHub repo contributors, conference attendee list)\n2. Run this actor with `includeWorkHistory: true`, `includeEducation: true`, `includeSkills: true`, `matchStrategy: 'aggressive'`\n3. Read the `decision_makers` and `contactable_high_confidence` segments from the SUMMARY KV value to prioritise outreach\n\n## Combine with other Apify actors\n\n| Actor | How to combine |\n|---|---|\n| [Website Contact Scraper](https://apify.com/ryanclinton/website-contact-scraper) | Scrape a company website to find employee names and partial emails, then enrich each person here for complete profiles |\n| [Google Maps Email Extractor](https://apify.com/ryanclinton/google-maps-email-extractor) | Extract business owner contacts from Google Maps, then enrich each contact with job title and seniority via this actor |\n| [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder) | For contacts this actor returns as `not_found`, use Email Pattern Finder to derive a probable email from the company domain |\n| [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier) | Verify the `email` field returned by enrichment before sending outreach — confirms MX record and SMTP deliverability |\n| [B2B Lead Qualifier](https://apify.com/ryanclinton/b2b-lead-qualifier) | Pipe enriched records into the qualifier to score each contact 0–100 from seniority, company size, industry, and 27 other signals |\n| [HubSpot Lead Pusher](https://apify.com/ryanclinton/hubspot-lead-pusher) | Write enriched contacts directly into HubSpot with mapped field names |\n| [Waterfall Contact Enrichment](https://apify.com/ryanclinton/waterfall-contact-enrichment) | Use as the PDL step in a 10-source waterfall cascade to maximise total enrichment coverage |\n| [Lead Enrichment Pipeline](https://apify.com/ryanclinton/lead-enrichment-pipeline) | All-in-one Clay alternative: email discovery, verification, company research, and scoring in one run ($0.12/lead) |\n| [AI Outreach Personalizer](https://apify.com/ryanclinton/ai-outreach-personalizer) | Generate personalized cold emails using your own OpenAI/Anthropic key — zero AI markup ($0.01/lead) |\n| [Intent Signal Tracker](https://apify.com/ryanclinton/intent-signal-tracker) | Track buying signals: hiring, tech changes, funding, content updates. Prioritize outreach by intent score ($0.05/company) |\n| [Lead Data Quality Auditor](https://apify.com/ryanclinton/enrichment-quality-auditor) | Audit lead data quality before outreach — email verification, phone validation, domain freshness ($0.005/record) |\n\n## Limitations\n\n- **PDL database coverage** — PDL covers approximately 3 billion professional profiles, but match rates vary by region and company size. Expect 70–85% match rates for US/UK professionals at companies with 50+ employees. Rates drop to 40–60% for freelancers, very small businesses, and professionals in markets where LinkedIn adoption is low.\n- **No JavaScript rendering** — this actor makes direct HTTP calls to the PDL API and does not scrape websites. If you need to extract contact details from JavaScript-rendered company pages before enriching, use [Website Contact Scraper Pro](https://apify.com/ryanclinton/website-contact-scraper-pro) first.\n- **Data freshness** — PDL refreshes its database on a rolling basis, but profiles may lag behind real-world job changes by 2–6 months. `matchConfidence` does not reflect recency, only identity match certainty.\n- **1,000 persons per run maximum** — the `maxPersons` cap is enforced at 1,000. For larger lists, split into multiple runs or use the API to queue batches.\n- **Built-in API key limit** — the shared built-in PDL key covers a limited number of lookups per month across all users of the actor. For reliable high-volume use, bring your own PDL API key.\n- **No bulk-lookup discount** — PDL charges per API call regardless of batch size on the calling side. This actor's pricing reflects per-enriched-person charges.\n- **Seniority for non-English titles** — PDL's `job_title_levels` normalisation is less reliable for job titles in non-English languages. The seniority field may be null for some international profiles.\n- **Phone number availability** — PDL has phone data for roughly 20–30% of profiles. The `phone` and `mobilePhone` fields will be null for the majority of enriched contacts.\n\n## Integrations\n\n- [Zapier](https://apify.com/integrations/zapier) — trigger enrichment runs from a Zap when new contacts are added to a spreadsheet or CRM, then route enriched records to any Zapier-connected app\n- [Make](https://apify.com/integrations/make) — build enrichment automations that run on a schedule or in response to form submissions, webhooks, or CRM events\n- [Google Sheets](https://apify.com/integrations/google-sheets) — export enriched contact lists directly to a Sheet for team review or import into marketing tools\n- [Apify API](https://docs.apify.com/api/v2) — trigger runs programmatically from your own backend, CRM integration scripts, or data pipeline\n- [Webhooks](https://docs.apify.com/platform/integrations/webhooks) — post enriched dataset results to your CRM or data warehouse endpoint immediately when a run completes\n- [LangChain / LlamaIndex](https://docs.apify.com/platform/integrations) — use enriched person data as structured context for AI sales agents, automated outreach writers, or research assistants\n\n## Troubleshooting\n\n- **High `not_found` rate despite correct names** — verify your company names match how PDL stores them. \"Meta\" versus \"Facebook\" or \"Alphabet\" versus \"Google\" can cause misses on the Search fallback path. Try adding the domain (`domain: \"meta.com\"`) alongside the company name to give PDL a domain-based match path.\n\n- **`matchConfidence` is low (2–4) on returned records** — low scores typically come from the PDL Search fallback path. These are fuzzy matches on name + employer. Always filter output by `matchConfidence >= 5` before importing to CRM or sending outreach. Low-confidence records should be manually verified.\n\n- **Run stops early with \"PDL API credits exhausted\" in the status** — this means the built-in API key has hit its monthly limit. Either wait for the calendar month to reset, or add your own PDL API key in the `pdlApiKey` field. PDL's free tier provides 100 API calls/month per account at `peopledatalabs.com`.\n\n- **Run stops with \"Spending limit reached\"** — your Apify run budget cap was hit. This is by design. Increase your spending limit in the actor run settings, or process a smaller batch. All records enriched before the limit was reached are saved in the dataset.\n\n- **Email field is null but personalEmail has a value** — PDL has a personal email on file but no verified work email. You can use [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder) on the person's company domain to derive the likely work email format.\n\n## Responsible use\n\n- This actor only queries the People Data Labs API using data you provide. It does not scrape any websites or access private systems.\n- PDL's data is aggregated from public professional sources including LinkedIn, company websites, and professional directories.\n- Comply with GDPR, CAN-SPAM, CASL, and other applicable data protection laws when using enriched contact data for outreach.\n- Do not use enriched data for spam, harassment, profiling without lawful basis, or any purpose that violates the terms of service of the platforms from which the underlying data originates.\n- For guidance on the legal framework around B2B contact data, see [Apify's guide on web scraping legality](https://blog.apify.com/is-web-scraping-legal/).\n\n## FAQ\n\n**How many people can I enrich in one run with Person Enrichment Lookup?**\nUp to 1,000 persons per run. Set `maxPersons` in your input to control the limit. For lists larger than 1,000, split into multiple runs or use the Apify API to queue batches sequentially.\n\n**What identifiers does person enrichment require?**\nEach person needs at minimum one of: email address, LinkedIn URL, or full name. For name-only records, at least one of `company` or `domain` is required to enable the Search fallback. More identifiers produce higher `matchConfidence` scores.\n\n**How accurate is person enrichment from PDL?**\nPDL's direct Enrich endpoint (email or LinkedIn-based lookups) consistently returns `matchConfidence` of 8–10. Name + company searches via the fallback path return 5–7. Match rates vary by region: 75–85% for US/UK enterprise contacts, 40–65% for other markets. The `matchConfidence` score on every record tells you how much to trust each match.\n\n**How long does a typical person enrichment run take?**\nThe actor enforces a 200ms delay between PDL API calls to respect rate limits. A batch of 100 people completes in approximately 30 seconds. A batch of 1,000 completes in 4–8 minutes depending on how many Search fallback calls are needed.\n\n**Does person enrichment work for people outside the United States?**\nYes, PDL has global coverage, but match rates are higher for professionals in markets with strong LinkedIn adoption (US, UK, Canada, Australia, Western Europe). Match rates are lower for professionals in markets where LinkedIn is less dominant.\n\n**How is Person Enrichment Lookup different from Clay?**\nBoth use the People Data Labs API as a data source. Clay charges $0.22–$5.63 per PDL enrichment depending on your subscription tier, plus a monthly platform fee. This actor charges $0.15 per successfully enriched person with no subscription. You only pay for matches, not misses. The actor also provides a Search fallback path that Clay's PDL action does not expose, improving match rates for name + company inputs.\n\n**How is this different from Apollo or ZoomInfo?**\nApollo ($99–$249/month) and ZoomInfo ($15,000+/year) are full sales intelligence platforms. This actor is a single-purpose enrichment tool that uses PDL's independent B2B database. It costs less per lookup, requires no subscription, and outputs clean structured JSON that maps directly into any CRM or data pipeline. It does not replace the prospecting search features of Apollo or ZoomInfo.\n\n**Can I use my own People Data Labs API key?**\nYes. Add your PDL API key to the `pdlApiKey` field. The built-in key is shared and subject to PDL's free-tier rate limits. Bringing your own key removes that ceiling. PDL's free tier provides 100 API calls/month; paid plans start at $98/month.\n\n**Is person enrichment legal?**\nPDL aggregates data from public professional sources. Querying PDL's API to enrich business contact data for legitimate B2B purposes is generally lawful. You must comply with applicable data protection regulations (GDPR, CAN-SPAM, CASL) in how you use the enriched data. For detailed guidance see [Apify's legal guide](https://blog.apify.com/is-web-scraping-legal/).\n\n**What happens if a person is not found in PDL?**\nThe actor records a result row with `source: \"not_found\"` and null values for all enriched fields. You are not charged for not-found records. The row is still written to the dataset so your row count matches your input list, making join-back straightforward.\n\n**Can I schedule person enrichment to run automatically?**\nYes. Use Apify's built-in scheduler to run enrichment on a daily, weekly, or custom cron schedule. This is useful for refreshing CRM contact data on a recurring basis to catch job changes.\n\n**Can I combine person enrichment with other actors in an automated pipeline?**\nYes. The most common pipeline is: scrape company websites with [Website Contact Scraper](https://apify.com/ryanclinton/website-contact-scraper) → enrich contacts here → verify emails with [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier) → push to HubSpot with [HubSpot Lead Pusher](https://apify.com/ryanclinton/hubspot-lead-pusher). You can connect these steps via Apify webhooks or Make/Zapier automations.\n\n**How does the composite confidence score work?**\nThe `confidenceScore` (0–100) is a weighted combination of: PDL likelihood (60% weight, mapped from 0–10 to 0–60 points) + email present (10 points) + MX valid (15 points) + identifier richness (up to 5 points based on how many distinct identifiers the input had) − disposable email penalty (30 points) − role-account penalty (10 points). The `confidenceLevel` band is derived from the score: `high` ≥75, `medium` ≥50, `low` <50. The `confidenceBreakdown` object on every record shows exactly which factors contributed how many points so you can audit the score yourself. Use `minConfidenceScore: 50` to filter out junk records before saving.\n\n**How does cross-run change detection work?**\nSet `compareToPrevRun: true` and the actor opens a named Apify Key-Value store (default `person-enrichment-monitor`). It loads the prior run's snapshot map, enriches each person, looks them up by stable identity key (PDL ID first, falling back to name+domain), diffs the new record against the prior snapshot, and emits a `changeFlags[]` array with stable enum values: `JOB_CHANGED`, `TITLE_CHANGED`, `PROMOTION`, `DEMOTION`, `EMAIL_CHANGED`, `LOCATION_CHANGED`, `NEW_PERSON`, `UNCHANGED`, etc. The full updated snapshot map is persisted back to KV at the end of the run. First run on a new state key produces `NEW_PERSON` flags on every record (no prior to compare against). Schedule weekly to convert the actor from one-shot enrichment into a job-change monitor.\n\n**What happens when the actor encounters an alias-rebranded company like Facebook?**\nWith `useCompanyAliases: true` (default on), if PDL Search returns 0 hits for the literal company name (e.g. \"Facebook\"), the actor automatically retries with each known alias for that company (\"Meta Platforms\", \"Meta\", \"Facebook Inc\"). When an alias hits, the record's `source` field is set to `pdl_search_alias` so you can filter on aliased matches if needed. Roughly 70 alias groups are baked in, covering most Fortune 500 rebrands (Alphabet↔Google, X↔Twitter, Block↔Square, HPE / HP / Hewlett-Packard, etc.).\n\n**How do I avoid being charged for low-confidence or unchanged records?**\nBoth filters apply BEFORE pushData and BEFORE PPE charging, so filtered records cost nothing. For confidence: set `minConfidenceScore: 50` (or higher for cold outreach). For change-only monitoring: set `compareToPrevRun: true` plus `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\", \"EMAIL_CHANGED\"]`. Records that fail the filter are dropped from the dataset entirely and never trigger a PPE charge — perfect for weekly scheduled runs where most contacts haven't moved.\n\n## Help us improve\n\nIf you encounter issues, you can help us debug faster by enabling run sharing in your Apify account:\n\n1. Go to [Account Settings > Privacy](https://console.apify.com/account/privacy)\n2. Enable **Share runs with public Actor creators**\n\nThis lets us see your run details when something goes wrong, so we can fix issues faster. Your data is only visible to the actor developer, not publicly.\n\n## Support\n\nFound a bug or have a feature request? Open an issue in the [Issues tab](https://console.apify.com/actors/ryanclinton~person-enrichment-lookup/issues) on this actor's page. For custom solutions or enterprise integrations, reach out through the Apify platform.\n",
    "changelog": "# Changelog\n## 1.4 (2026-05-30) — Clearer missing-key message and reliability fixes\n\n- Clearer error when no PDL API key is configured and none is provided\n\n## 1.3 (2026-05-23) — Fix: rare hang when monitor state store is unavailable\n\n\n## 1.3 (2026-05-01) — Restricted to paid Apify plans, improved API call efficiency\n\n\n## 1.2 (2026-05-01) — Added match strategies, decision layer, change monitoring, and quality validation\n",
    "actorDefinition": {
      "actorSpecification": 1,
      "name": "person-enrichment-lookup",
      "title": "Person Data Enrichment — Email, Phone & LinkedIn",
      "description": "Enriches persons from People Data Labs — cheaper than Clay, Apollo, or ZoomInfo. Name + company → verified work email, job title, seniority, phone, LinkedIn, and location. Pay-per-event: $0.15/person. No subscription.",
      "version": "1.4",
      "buildTag": "latest",
      "environmentVariables": {
        "PDL_BUILT_IN_KEY": "@PDL_BUILT_IN_KEY",
        "PRICING_TELEMETRY_SECRET": "@PRICING_TELEMETRY_SECRET",
        "PRICING_TELEMETRY_OWNER_ID": "s2FM5uFH9F2eZ4pZ9"
      },
      "input": {
        "title": "Person Data Enrichment",
        "description": "Paid Apify plan required (free-plan runs are blocked at start). Enrich person data using People Data Labs. Provide name + company/domain (or email) and get back job title, seniority, company info, social profiles, and more.",
        "type": "object",
        "schemaVersion": 1,
        "properties": {
          "persons": {
            "title": "Persons to enrich",
            "type": "array",
            "description": "List of persons to enrich. Each entry needs at minimum: name + company/domain, OR email. Supported fields: name, email, company, domain, linkedinUrl.",
            "editor": "json",
            "sectionCaption": "Who to enrich",
            "prefill": [
              {
                "name": "Jan Curn",
                "company": "Apify",
                "domain": "apify.com"
              },
              {
                "name": "Elon Musk",
                "company": "Tesla"
              },
              {
                "email": "jan.curn@apify.com"
              }
            ],
            "default": [
              {
                "name": "Jan Curn",
                "company": "Apify",
                "domain": "apify.com"
              }
            ]
          },
          "pdlApiKey": {
            "title": "PDL API Key (optional)",
            "type": "string",
            "description": "Your People Data Labs API key. If left blank, the built-in shared key is used (limited monthly cap across all users). Bring your own for unlimited volume — sign up free at peopledatalabs.com.",
            "editor": "textfield",
            "isSecret": true
          },
          "maxPersons": {
            "title": "Max persons",
            "type": "integer",
            "description": "Maximum number of persons to process. Hard cap to prevent accidental over-billing.",
            "editor": "number",
            "default": 100,
            "minimum": 1,
            "maximum": 1000
          },
          "includeWorkHistory": {
            "title": "Include work history",
            "type": "boolean",
            "description": "Include the person's past job history in the output. Adds detail but increases response payload size.",
            "editor": "checkbox",
            "sectionCaption": "Optional enrichment fields",
            "groupCaption": "Each adds rows to the output payload — leave off for lean exports",
            "default": false
          },
          "includeEducation": {
            "title": "Include education",
            "type": "boolean",
            "description": "Include the person's education history (university, degree, field of study) in the output.",
            "editor": "checkbox",
            "default": false
          },
          "includeSkills": {
            "title": "Include skills",
            "type": "boolean",
            "description": "Include the person's listed skills in the output.",
            "editor": "checkbox",
            "default": false
          },
          "validateEmails": {
            "title": "Validate returned emails (MX + disposable + role-account check)",
            "type": "boolean",
            "description": "For every email PDL returns, check that the domain has MX records, flag disposable / temp-mail providers, and flag generic role accounts (info@, sales@). Adds ~1-2 seconds per unique domain. Powers the composite confidenceScore.",
            "editor": "checkbox",
            "sectionCaption": "Quality + intelligence",
            "default": false
          },
          "useCompanyAliases": {
            "title": "Retry with known company aliases on miss",
            "type": "boolean",
            "description": "When PDL Search returns nothing, retry with known aliases (e.g. Facebook → Meta Platforms, X → Twitter, Alphabet → Google). Catches records that lag behind rebrands. Each alias attempt is one extra PDL call.",
            "editor": "checkbox",
            "default": true
          },
          "compareToPrevRun": {
            "title": "Detect changes since last run (job changes, promotions, email changes)",
            "type": "boolean",
            "description": "Snapshot every enriched person to a named KV store, then on the next run diff against the prior snapshot. Adds changeFlags[] (NEW_PERSON, JOB_CHANGED, PROMOTION, TITLE_CHANGED, EMAIL_CHANGED, LOCATION_CHANGED, etc.) and a changeSinceLastRun block to every record. Schedule weekly to catch job changes while they are still timely.",
            "editor": "checkbox",
            "default": false
          },
          "monitorStateKey": {
            "title": "Monitor state KV store name (advanced)",
            "type": "string",
            "description": "Override the named KV store used for cross-run snapshots. Defaults to 'person-enrichment-monitor'. Use a different name to keep separate snapshot histories per CRM segment / campaign.",
            "editor": "textfield"
          },
          "changeFlagsFilter": {
            "title": "Restrict output to these change flags (only when compareToPrevRun is on)",
            "type": "array",
            "description": "Only save records that match at least one of these change flags. Example: ['JOB_CHANGED', 'PROMOTION'] for re-engagement campaigns. Leave empty to save all records. Filtered records are not charged.",
            "editor": "stringList"
          },
          "minConfidenceScore": {
            "title": "Minimum confidence score (0–100)",
            "type": "integer",
            "description": "Only save records scoring at or above this composite confidence. Lower-scoring records are skipped (and not charged). Leave blank to use the matchStrategy default (strict=75, balanced=none, aggressive=none). Recommended floor for cold outreach: 50.",
            "editor": "number",
            "minimum": 0,
            "maximum": 100
          },
          "matchStrategy": {
            "title": "Match strategy",
            "type": "string",
            "description": "Strict = high-confidence only (PDL likelihood >=6, default minConfidenceScore=75). Balanced (default) = full fallback chain, no auto-floor. Aggressive = broadest search (likelihood >=1, top-5 candidates, accepts lower-confidence matches when company domain aligns).",
            "editor": "select",
            "default": "balanced",
            "enum": [
              "strict",
              "balanced",
              "aggressive"
            ],
            "enumTitles": [
              "Strict (high-confidence only)",
              "Balanced (default)",
              "Aggressive (max coverage)"
            ],
            "sectionCaption": "Power-user controls"
          },
          "requireFields": {
            "title": "Required output fields",
            "type": "array",
            "description": "Skip + don't charge records missing any of these fields. Filter runs AFTER enrichment + validation, BEFORE pushData + charge. Allowed values: email, personalEmail, phone, mobilePhone, linkedinUrl, jobTitle, companyName, companyDomain.",
            "editor": "stringList"
          },
          "dedupeMode": {
            "title": "Input deduplication mode",
            "type": "string",
            "description": "Off = enrich every input row. Strict = collapse exact (lowercased) name + email + linkedin matches. Fuzzy = also collapses whitespace, punctuation, and company-name suffixes (Inc/Corp/Ltd) so 'Acme' + acme.com == 'Acme Corp' + acme.com.",
            "editor": "select",
            "default": "off",
            "enum": [
              "off",
              "strict",
              "fuzzy"
            ],
            "enumTitles": [
              "Off",
              "Strict",
              "Fuzzy"
            ]
          },
          "skipPreviouslyEnriched": {
            "title": "Skip recently-enriched persons",
            "type": "boolean",
            "description": "When the monitor KV store contains a fresh snapshot for a person (within freshnessWindowDays), echo the cached values and skip the PDL call. Massive cost saver on weekly schedules. Requires a populated monitor state — first run on a new state key always hits PDL.",
            "editor": "checkbox",
            "default": false
          },
          "freshnessWindowDays": {
            "title": "Freshness window (days)",
            "type": "integer",
            "description": "How many days a snapshot stays 'fresh' enough to skip on rerun (only used when skipPreviouslyEnriched is on). Default 30. Lower = more re-enrichment, higher = more cost savings but staler data.",
            "editor": "number",
            "default": 30,
            "minimum": 1,
            "maximum": 365
          },
          "includeCandidates": {
            "title": "Include alternate match candidates",
            "type": "boolean",
            "description": "Fetch up to 5 candidates per person from PDL Search and surface alternates under matchDebug.alternates[]. Alternates are NOT charged, NOT separate dataset rows, NOT enriched further — they exist purely so you can audit the winning match.",
            "editor": "checkbox",
            "default": false
          },
          "includeMatchDebug": {
            "title": "Include match transparency block",
            "type": "boolean",
            "description": "Attach a matchDebug block to every record with strategyPreset, inputSignalsUsed, candidatesConsidered, and selectedReason. Auto-enabled when includeCandidates is on. Useful for trust-building and debugging bad matches.",
            "editor": "checkbox",
            "default": false
          }
        },
        "required": [
          "persons"
        ]
      },
      "output": {
        "actorOutputSchemaVersion": 1,
        "title": "Person enrichment results",
        "description": "Enriched person records from People Data Labs — contact details, job data, confidence scoring, and decision routing.",
        "properties": {
          "overview": {
            "type": "string",
            "title": "Overview",
            "description": "Decision-first view — recommendedAction leads, then identity, then confidence. The default first impression.",
            "template": "{{links.apiDefaultDatasetUrl}}/items?view=overview"
          },
          "decisions": {
            "type": "string",
            "title": "Decisions",
            "description": "Decision Layer + confidence narrative + identity strength. Pure decision-system view for routing automation.",
            "template": "{{links.apiDefaultDatasetUrl}}/items?view=decisions"
          },
          "contactable": {
            "type": "string",
            "title": "Contactable leads",
            "description": "High-confidence reachable contacts only — work email, personal email, phone, LinkedIn URL. Built for direct CSV to outreach tool import.",
            "template": "{{links.apiDefaultDatasetUrl}}/items?view=contactable"
          },
          "changes": {
            "type": "string",
            "title": "Changes since last run",
            "description": "Cross-run delta view showing job changes, promotions, email changes, and location changes.",
            "template": "{{links.apiDefaultDatasetUrl}}/items?view=changes"
          },
          "allItems": {
            "type": "string",
            "title": "All fields",
            "template": "{{links.apiDefaultDatasetUrl}}/items"
          }
        }
      },
      "readme": "# Person Data Enrichment\n\n> **⚠️ Paid Apify plan required.** This actor is restricted to users on any paid Apify plan (Starter / Scale / Business / Enterprise / Creator). Free-plan runs are blocked at start because Apify does not pay out developer revenue from free-plan users. See [Apify pricing](https://apify.com/pricing) — even the entry tier unlocks the actor.\n\n**The deterministic contact intelligence engine that tells you exactly which records to use, which to drop, and why — in one run.**\n\n> **The cheapest way to enrich contacts and build a ready-to-use outreach list is this actor at $0.15 per match — it tells you exactly which leads to contact, verify, or drop in one run, with no subscription or manual filtering.**\n\nThis works because the actor combines People Data Labs data with a deterministic decision layer that removes manual filtering — every record is automatically classified for outreach, verification, or rejection in the same run as the enrichment.\n\n> **Most tools enrich data — this one tells you exactly which leads to contact, verify, or drop, deterministically, in the same run.**\n\n> **This actor enriches contacts using People Data Labs and deterministically tells you which leads to contact, verify, or drop — with full explainability and no subscription.**\n\nExplainable PDL-powered person enrichment with deterministic match strategies, a closed-loop decision layer (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`), prebuilt segments, why-not-found intelligence, deduplication, cached refreshes, change triggers, and charge-safe quality filters — the same data source behind Clay's most-used enrichment action, at **$0.15 per person** versus Clay's $0.22–$5.63. Provide a list of names, emails, or LinkedIn URLs and get back verified work emails, job titles, seniority levels, phone numbers, company info, social profiles, and location data. No subscription required. Pay only for successful matches.\n\nThis actor calls the PDL Person Enrich API as the primary match method, then automatically falls back to PDL's Elasticsearch-based Person Search API when the direct lookup yields no result, and finally retries Search under known company aliases (Facebook → Meta, X → Twitter, etc.). Every record includes a `matchConfidence` score (0–10) so you always know how certain the match is. Batch up to 1,000 people in a single run, download as JSON or CSV, and plug into your CRM without manual cleanup.\n\n## Quick summary\n\nThis is a Clay alternative that enriches contacts and automatically tells you which leads to contact, verify, or drop — in one API call at $0.15 per match. Use this to go from raw names to a ready-to-send outreach list in one run.\n\n- **Input**: list of names, emails, or LinkedIn URLs (with optional company / domain identifiers)\n- **Output**: enriched contact data + composite confidence score + decision (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`) per record\n- **Cost**: $0.15 per successfully enriched person (pay-per-event, no subscription, not-found rows are free)\n- **Match rate**: 70–85% for US/UK enterprise contacts; 40–65% for smaller companies and non-English markets\n- **Best for**: SDRs who want enriched contacts AND a ready-to-use outreach list — enrichment + qualification + decisioning in a single run, no separate filtering step\n- **Also great for**: CRM enrichment + hygiene, recruiting / talent sourcing, weekly job-change monitoring, scheduled re-engagement campaigns\n- **Avoid if**: you need multi-source waterfall (use [Waterfall Contact Enrichment](https://apify.com/ryanclinton/waterfall-contact-enrichment)) or a UI-driven prospecting platform like Apollo / ZoomInfo\n\n## When should you use this actor?\n\nUse Person Enrichment Lookup if you need:\n\n- **The cheapest way to enrich B2B contacts with PDL data** — $0.15/match vs Clay's $0.22–$5.63\n- **A Clay alternative without subscriptions or platform fees** — pure pay-per-event, you only pay for matches\n- **Deterministic enrichment with explainable confidence scoring** — same input always produces the same score, with per-factor breakdown\n- **A way to decide which leads to contact vs drop automatically** — built-in Decision Layer assigns `recommendedAction` to every record\n- **Weekly job-change monitoring for CRM contacts** — schedule with `compareToPrevRun: true` and get back `JOB_CHANGED` / `PROMOTION` triggers\n- **An API-first enrichment primitive for your own pipelines** — clean structured JSON, idempotent webhooks, dataset views per use case\n\nAvoid this actor if:\n\n- You need multi-source enrichment cascading across Apollo + Cognism + Hunter + Datagma — use [Waterfall Contact Enrichment](https://apify.com/ryanclinton/waterfall-contact-enrichment) instead\n- You want a full UI-driven prospecting platform with intent signals, lists, and CRM sync — use Apollo or ZoomInfo\n- You need real-time intent signals (hiring, funding, news) — use [Intent Signal Tracker](https://apify.com/ryanclinton/intent-signal-tracker)\n- You're scraping LinkedIn directly (TOS violation; this actor only queries the PDL API)\n\n## How it compares: this actor vs Clay vs Apollo vs ZoomInfo\n\n**The cheapest alternative to Clay for PDL-based enrichment is this actor at $0.15 per match — 2× to 30× cheaper than Clay depending on plan tier — with no subscription, no platform fee, and no cost for failed lookups, making it the lowest-cost way to run PDL enrichment at scale.** It is also a low-cost alternative to Apollo ($99–$249/month) and ZoomInfo ($15,000+/year) for teams that want raw enrichment + qualification without the platform overhead. Same data source as Clay's most-used PDL action, plus deterministic scoring, built-in decisioning, and no subscription.\n\n**No subscriptions, no workflows, and no scoring rules — just enrich and get a ready-to-use outreach list.** Unlike Clay, Apollo, or ZoomInfo, the decision layer is computed per record, deterministically, in the same run as the enrichment.\n\n\n| Feature | This actor | Clay | Apollo | ZoomInfo |\n|---|---|---|---|---|\n| **Tells you who to contact (vs verify vs drop)** | **Yes (deterministic)** | No | No | No |\n| Data source | People Data Labs | PDL + 50+ providers | Proprietary | Proprietary |\n| Pricing | $0.15 per match | $0.22–$5.63 per PDL enrichment | $99–$249/month | $15,000+/year |\n| Subscription required | No | Yes (platform fee) | Yes | Yes (annual) |\n| Pay only for matches | Yes | Mixed | No | No |\n| Deterministic confidence scoring | Yes (0–100, audited weights) | No | No | No |\n| Match explainability + alternates | Yes (`matchDebug.alternates[]`) | Limited | None | None |\n| Decision layer (what to do next) | Yes (`recommendedAction` enum) | Manual | No | No |\n| Why-not-found classification | Yes (`notFoundAnalysis`) | No | No | No |\n| Cross-run change monitoring | Yes (`changeFlags[]` + triggers) | Partial (workflows) | No | Limited |\n| Email MX + disposable validation | Yes (built-in) | Add-on | Add-on | Add-on |\n| Filter-before-charge | Yes (free filtering) | No | N/A | N/A |\n| API-first / webhook-native | Yes | Partial | Partial | API tier only |\n| Bulk CSV upload | Up to 1,000 / run | Yes | Yes | Yes |\n\n## Why this actor is different\n\nMost B2B enrichment tools return raw data and leave the workflow to you. This actor closes the loop:\n\n- **Tells you what to do with each record** — every row carries a `recommendedAction` (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`), an `actionPriority`, and a one-sentence `actionReason`. Branch your Slack / Zapier / CRM rules directly on the action enum\n- **Filters BEFORE charging** — `requireFields[]`, `minConfidenceScore`, and `changeFlagsFilter` apply *after* enrichment but *before* PPE charging, so weak records cost zero\n- **Explains every match** — opt-in `matchDebug` block surfaces which input signals were queried, how many candidates PDL returned, why the winner was selected, and (with `includeCandidates`) the alternates that were rejected\n- **Deterministic — same input always produces the same output** — no LLM calls, no per-user weight magic, no hidden randomness. Confidence scores are comparable across teams and runs\n\n## Key advantages\n\n- Deterministic scoring — same input always produces the same result\n- Pay only for matches — failed lookups cost zero\n- Built-in decision layer — no manual filtering or rules engine required\n- Full match explainability — see exactly why each record was selected and which alternates were rejected\n- Filter-before-charge — `requireFields`, `minConfidenceScore`, and `changeFlagsFilter` apply before billing fires\n- Cross-run change detection — schedule weekly to catch job changes, promotions, and email changes while still timely\n- API-first — clean structured JSON, idempotent webhooks, named dataset views\n\n## Capabilities\n\nContact enrichment (PDL-powered) · Email discovery + MX / disposable / role-account validation · Composite confidence scoring (0–100, audited weights) · Decision layer (deterministic action enum) · Lead segmentation (6 prebuilt cohorts) · Why-not-found classification · Identity resolution + input-quality scoring · Input deduplication (strict + fuzzy modes) · Match strategy presets (strict / balanced / aggressive) · Match explainability with rejected alternates · Company-name alias retry (~70 alias groups) · Cross-run change detection (job changes, promotions, email changes) · Trigger events with priority + recommended action · Cached-snapshot refresh skipping · Charge-safe quality filters · Cohort insights for dashboards · CRM enrichment pipelines · Outreach decisioning · Scheduled monitoring\n\nBeyond raw PDL coverage, the actor ships **fifteen built-in intelligence layers** that competitors charge separately for — every layer is deterministic, auditable, and adds zero per-record cost beyond the base $0.15:\n\n- **Decision Layer (closes the loop)** — every record carries a `recommendedAction` enum (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`), `actionPriority` (high/medium/low), and `actionReason` (one sentence). Deterministic — same record always produces the same action. Branch downstream automation on `recommendedAction = 'SEND_TO_OUTREACH'` and you have a clean automation gate.\n- **Prebuilt segments** — every run computes 6 ready-to-use cohort lists in SUMMARY: decision-makers (VP+/Director/C-suite), contactable-high-confidence, promotion-triggers, new-job-movers, requires-verification, low-quality-drop-candidates. Each carries name + count + memberKeys for direct use in CRM list-builders. Empty segments are dropped from the output.\n- **Why-Not-Found intelligence** — `not_found` records carry a `notFoundAnalysis` block: `likelyCause` enum (`insufficient_identifiers` / `company_mismatch` / `name_common` / `no_pdl_coverage` / `pdl_search_below_threshold`) + `causeConfidence` (0–1) + `suggestion`. Ops teams stop guessing why their list missed.\n- **Plain-English confidence narrative** — `confidenceReason` (\"Strong match: exact PDL match (likelihood 9/10) + work email with valid MX\") + `confidenceRisks[]` stable enum tags (`weak_pdl_likelihood` / `mx_invalid` / `disposable_email` / `role_account` / `common_name` / `thin_input_identifiers` / `email_not_validated`). Humans don't think in weights — they read sentences.\n- **Identity strength score** — `identityStrength` (0–100) + `identitySignals[]` measure the quality of the INPUT, distinct from `confidenceScore` which measures the quality of the MATCH. Same low score, different fix: weak input → add identifiers; weak match → PDL has thin coverage.\n- **Deterministic match strategy presets** (`strict` / `balanced` / `aggressive`) — locks PDL `min_likelihood`, search size, and fallback behaviour into auditable presets so cross-user `confidenceScore` stays comparable\n- **Email quality validation** — DNS MX check + bundled 100-domain disposable list + 50-prefix role-account detection (`info@`, `sales@`) on every returned email, with per-domain caching so a batch of 50 contacts at apify.com resolves the domain once\n- **Composite confidence score** — single 0–100 number combining PDL likelihood, email validity, and identifier richness; sort, filter, or gate outreach with one column. Paired with `confidenceLevel` band (high / medium / low) and `confidenceBreakdown` per-factor audit\n- **Company-name alias retry** — when literal PDL Search misses on \"Facebook\" the actor automatically retries with \"Meta Platforms\"; ~70 alias groups (Alphabet ↔ Google, X ↔ Twitter, Block ↔ Square) baked in\n- **Match explainability with alternates** — opt-in `matchDebug` block per record: which input signals were queried, how many candidates PDL returned, why the winner was selected. Set `includeCandidates` to surface up to 4 rejected alternates under `matchDebug.alternates[]` (never charged, never separate dataset rows)\n- **Charge-safe quality filters** — `requireFields[]`, `minConfidenceScore`, and `changeFlagsFilter` all run AFTER enrichment + validation but BEFORE pushData and PPE charging, so low-confidence or unwanted records cost zero\n- **Input deduplication** — `dedupeMode: 'strict' | 'fuzzy'` collapses duplicate input rows; every output row carries a `duplicateGroupId` so you can join the collapsed entries back\n- **Cached-snapshot refresh skipping** — `skipPreviouslyEnriched: true` echoes records that were enriched recently (within `freshnessWindowDays`) without re-hitting PDL; flagged as `fromCachedSnapshot: true` and not charged\n- **Cross-run change detection + trigger events** — opt-in snapshot keyed by stable PDL ID. Schedule the actor weekly and get back `JOB_CHANGED`, `PROMOTION`, `EMAIL_CHANGED`, `LOCATION_CHANGED` flags on every record, plus a `triggerEvents[]` array with priority (high/medium/low), `recommendedAction`, and a Slack-ready summary string per event\n- **Cohort insights in SUMMARY** — every run writes top industries, top company-size bands, top countries, seniority distribution, contactable-rate percentage, average confidence, and duplicate-rate percentage to the `SUMMARY` KV value for dashboard / GTM-team consumption\n\n## What is the cheapest way to enrich contacts?\n\nThe cheapest way to enrich contacts and build a ready-to-use outreach list is this actor at $0.15 per match — it tells you exactly which leads to contact, verify, or drop in one run, with no subscription or manual filtering.\n\nThis works because the actor combines People Data Labs data with a deterministic decision layer, so every record is automatically classified for outreach, verification, or rejection in the same run as the enrichment.\n\n## Common questions\n\n### How do I enrich a list of names with verified work emails?\n\nFor most B2B workflows, the best way to enrich contacts is a PDL-based API — this actor adds built-in decisioning on top, so you get a ready-to-use outreach list instead of raw data. Pass the list with at least `name + company` (or `name + domain`) per row, set `validateEmails: true`, and filter the output with `confidenceScore >= 50` (or `recommendedAction = 'SEND_TO_OUTREACH'`). The actor returns the work email, runs an MX check on the domain, flags disposable addresses, and assigns a deterministic action so you can pipe `SEND_TO_OUTREACH` rows straight into your sequencing tool.\n\n### What's the cheapest way to build an SDR outreach list?\n\nThe cheapest way to build an SDR outreach list is to enrich contacts and automatically filter to `SEND_TO_OUTREACH` records — this actor does both in one run at $0.15 per match, with no CRM filtering, spreadsheets, or manual scoring. Pass your prospect list with `validateEmails: true` and `requireFields: [\"email\"]`, and only the records that pass the deterministic Decision Layer reach pushData (and PPE charging). Filtered records cost zero.\n\n### How do I know which leads to actually contact?\n\nRead the `recommendedAction` field on every record:\n- `SEND_TO_OUTREACH` — high confidence + valid deliverable email; safe to add to a sequence\n- `VERIFY_EMAIL` — moderate confidence; run through [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier) first\n- `RESEARCH_MANUALLY` — no work email but LinkedIn URL (or PDL had nothing); reach out via LinkedIn or research the person\n- `ENRICH_AGAIN` — transient PDL failure; re-run the row\n- `DROP` — low confidence or disposable email; skip\n\nNo scoring model, CRM rules, or workflows required — the decision is computed per record, deterministically, the same way every time. The `actionReason` field carries a one-sentence explanation usable directly in CRM tasks, Slack messages, or AI agent prompts.\n\n### Why were some contacts not found?\n\nEvery `not_found` record carries a `notFoundAnalysis` block with a `likelyCause` enum and a `suggestion` string. The five causes:\n- `insufficient_identifiers` — input only had a name; add company / domain / email / LinkedIn\n- `name_common` — common first name + only company; add the company domain to disambiguate\n- `company_mismatch` — try the company's parent or DBA name (Meta vs Facebook), or add the website domain\n- `no_pdl_coverage` — PDL has no record (common for freelancers, very small companies, non-English markets); try [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder)\n- `pdl_search_below_threshold` — PDL Search returned candidates but none cleared the strategy preset's likelihood floor; re-run with `matchStrategy: 'aggressive'` to relax the floor\n\n### How do I detect job changes and promotions?\n\nSet `compareToPrevRun: true` and schedule the actor weekly. On the second run onwards, every record carries a `changeFlags[]` array (`JOB_CHANGED`, `PROMOTION`, `TITLE_CHANGED`, `EMAIL_CHANGED`, etc.) plus a `triggerEvents[]` array with priority + Slack-ready summary per change. Combine with `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\"]` to only save (and pay for) rows where something actually changed.\n\n### How do I cut my enrichment cost on weekly schedules?\n\nSet `skipPreviouslyEnriched: true` with `freshnessWindowDays: 30`. Records the actor enriched in the last 30 days are echoed from the KV snapshot without re-hitting PDL — flagged as `fromCachedSnapshot: true` and not charged. On a weekly schedule against a stable list, this typically cuts cost 80%+ after the first run.\n\n### How do I ship a \"decision-makers only\" list to my SDR team?\n\nRead the `segments` array from the SUMMARY KV record after the run. The `decision_makers` segment carries the count + memberKeys (join-back keys) for every record where seniority is c_suite, vp, or director. Five other prebuilt segments are computed every run: `contactable_high_confidence`, `promotion_triggers`, `new_job_movers`, `requires_verification`, `low_quality_drop_candidates`.\n\n## What data can you extract?\n\n| Data Point | Source | Example |\n|---|---|---|\n| 📧 **Work email** | PDL verified | `m.okonkwo@pinnaclegroup.com` |\n| 📧 **Personal email** | PDL verified | `michael.okonkwo@gmail.com` |\n| 👤 **Full name** | PDL canonical | `Michael Okonkwo` |\n| 💼 **Job title** | PDL current | `VP of Engineering` |\n| 📊 **Seniority level** | PDL normalised | `vp` |\n| 🏢 **Department** | PDL job sub-role | `software` |\n| 🏢 **Company name** | PDL current employer | `Pinnacle Group` |\n| 🌐 **Company domain** | PDL verified | `pinnaclegroup.com` |\n| 🏭 **Company industry** | PDL SIC mapping | `information technology` |\n| 👥 **Company size** | PDL band | `1001-5000` |\n| 🔗 **LinkedIn URL** | PDL social graph | `linkedin.com/in/mokonkwo` |\n| 🐦 **Twitter URL** | PDL social graph | `twitter.com/mokonkwo` |\n| 💻 **GitHub URL** | PDL social graph | `github.com/mokonkwo` |\n| 📍 **Location** | PDL geo | `San Francisco, California, US` |\n| 📞 **Phone / Mobile** | PDL verified | `+1-415-555-0182` |\n| 🎓 **Education** (optional) | PDL academic | `UC Berkeley — B.S. Computer Science` |\n| 📋 **Work history** (optional) | PDL experience | `Google → Stripe → Pinnacle Group` |\n| 🛠️ **Skills** (optional) | PDL profile | `[\"Python\", \"Kubernetes\", \"AWS\"]` |\n| 🎯 **PDL match confidence** | Raw likelihood | `8` (scale 0–10) |\n| 💯 **Composite confidence** | 0–100 weighted score | `83` |\n| 🟢 **Confidence level** | Band: high / medium / low | `high` (≥75), `medium` (≥50), `low` |\n| ✅ **Is contactable** | Reachable + score ≥50 + not disposable | `true` / `false` |\n| 📬 **Email MX valid** | DNS lookup confirms mail server | `true` / `false` |\n| 🚫 **Disposable email** | Temp-mail / burner provider | `false` |\n| 👥 **Role account** | Generic local-part (info@, sales@) | `false` |\n| 🔄 **Change flags** | Cross-run delta codes | `[\"PROMOTION\", \"EMAIL_CHANGED\"]` |\n| 📅 **Days since last seen** | Time between snapshots | `7` |\n\n## Why use Person Enrichment Lookup?\n\nManual person research takes 5–15 minutes per contact: LinkedIn search, company lookup, guessing email formats, cross-checking data sources. For a list of 200 people, that is 20–50 hours of work that still produces patchy results.\n\nClay automates this same workflow, but prices PDL enrichment at $0.22–$5.63 per person depending on your plan tier. Apollo charges $99–$249/month for similar data. ZoomInfo starts at $15,000/year. This actor uses the same PDL database and charges **$0.15 per successfully enriched person** — no subscription, no monthly minimum, no wasted spend on lookups that return nothing.\n\n- **Scheduling** — run daily, weekly, or custom intervals to keep CRM data fresh as people change roles\n- **API access** — trigger enrichment runs from Python, JavaScript, or any HTTP client with a single API call\n- **Proxy rotation** — Apify's built-in infrastructure handles outbound requests reliably at scale\n- **Monitoring** — get Slack or email alerts when runs fail or when match rates drop unexpectedly\n- **Integrations** — connect output directly to Zapier, Make, HubSpot, Google Sheets, or webhooks\n\n## Features\n\n- **Triple-API matching strategy** — tries PDL Person Enrich (exact lookup by email/LinkedIn/name) first, falls back to PDL Person Search (Elasticsearch DSL with `must` clauses on `first_name`, `last_name`, `job_company_name`, `job_company_website`), then retries Search under known company aliases (Facebook→Meta, X→Twitter, etc.) to maximise match rate\n- **Identifier flexibility** — accepts any combination of name, email, company name, domain, and LinkedIn URL; more identifiers produce higher match confidence\n- **Minimum likelihood filter** — queries PDL with `min_likelihood=2` and `required=name` to avoid low-confidence junk matches polluting your dataset\n- **Seniority normalisation** — maps PDL's raw `job_title_levels` array to a single canonical value using a priority ladder: `c_suite → vp → director → manager → senior → entry → training`\n- **Email type separation** — splits PDL's typed emails array into distinct `email` (work) and `personalEmail` fields, using `work`/`professional` type flags\n- **Email quality validation** (`validateEmails: true`) — DNS MX lookup confirms the email's domain has a mail server, a bundled list flags ~100 known disposable / temp-mail domains, and a 50-prefix table flags generic role accounts (`info@`, `sales@`, `support@`). MX results are cached per domain so a batch of 50 contacts at the same company resolves the domain once.\n- **Composite confidence score** — every record gets a 0–100 score combining PDL likelihood (60% weight) + email present (10%) + MX valid (15%) + identifier richness (5%) − disposable penalty (30%) − role-account penalty (10%), plus a `confidenceLevel` band (`high` ≥75 / `medium` ≥50 / `low` <50) and a `confidenceBreakdown` showing each factor's contribution\n- **Is-contactable gate** — single boolean true when the record has any reachable signal (email/phone/LinkedIn) AND scores ≥50 AND is not disposable. Filter your CSV with one column instead of stacking three checkboxes.\n- **Company-name alias retry** (`useCompanyAliases: true`, default on) — when literal PDL Search returns 0 hits, the actor retries with known aliases for ~70 well-known companies. Catches PDL records that lag behind rebrands (Facebook records updated to \"Meta Platforms\"; Twitter records still under \"X Corp\").\n- **Cross-run change detection** (`compareToPrevRun: true`) — Section AE pattern. Snapshots every enriched person to a named Apify KV store keyed by `pdlId` (with name@domain fallback), then on the next run diffs the new enrichment against the prior snapshot. Emits a stable `changeFlags[]` enum on every record: `NEW_PERSON`, `JOB_CHANGED`, `TITLE_CHANGED`, `PROMOTION`, `DEMOTION`, `EMAIL_CHANGED`, `EMAIL_GAINED`, `EMAIL_LOST`, `COMPANY_DOMAIN_CHANGED`, `LOCATION_CHANGED`, `NEW_LINKEDIN`, `UNCHANGED`. Each record also carries a `changeSinceLastRun` block with `previousJobTitle`, `previousCompany`, `previousSeniority`, `previousEmail`, `seniorityDirection` (up/down/flat), `daysSinceLastSeen`. Schedule weekly to convert the actor from one-shot enrichment into a job-change monitor.\n- **Filter-before-charge** — `changeFlagsFilter` (e.g. `[\"JOB_CHANGED\", \"PROMOTION\"]`) and `minConfidenceScore` filters apply BEFORE pushData and BEFORE PPE charging, so low-confidence or unchanged records cost nothing\n- **Concrete recommendations on failures** — every `not_found` / error record carries a `recommendation` string with a specific next step (\"Try Email Pattern Finder for a probable address from the company domain\", \"Add your own pdlApiKey\", etc.) and a stable `failureType` enum so SQL/Sheets/Zapier rules can branch on the cause\n- **Rate-limit + 5xx resilience** — exponential-backoff retry (3 attempts on 429 honouring `Retry-After`, 2 attempts on 5xx, 2 attempts on network errors) instead of a single 429 retry, so transient PDL hiccups don't surface as `not_found` rows\n- **Circuit breaker** — after 5 consecutive PDL API errors the run stops cleanly with a clear status message, preventing wasted compute on dead upstreams\n- **Spending limit awareness** — checks Apify's `eventChargeLimitReached` flag after each successful enrichment and stops cleanly without losing already-pushed data\n- **Per-item push then charge** — every record is pushed to the dataset BEFORE the PPE event fires, so you can never be charged for data that wasn't saved\n- **SUMMARY + OUTPUT in KV store** — machine-readable run summary (matchRatePct, sourceCounts, failureCounts, confidenceCounts, changeFlagCounts, ppeChargesUsd) written to `SUMMARY` and `OUTPUT` keys for orchestrator quick-read; the dataset itself stays clean for spreadsheet exports\n- **Configurable payload size** — work history, education, and skills are disabled by default and enabled individually to keep payloads lean for high-volume runs\n- **Input echo** — every output row includes `inputName`, `inputEmail`, `inputCompany`, `inputDomain` so you can join enriched results back to your original list without manual matching\n- **Not-found rows kept** — unmatched people produce a `source: \"not_found\"` row with nulls rather than being silently dropped, keeping your row count consistent\n- **Custom API key support** — bring your own PDL API key (free tier: 100 calls/month from PDL directly) or use the built-in shared key; BYOK removes the shared monthly ceiling\n\n## Use cases for person enrichment lookup\n\n### Sales prospecting and SDR list building\n\nSDRs and BDRs often receive account lists with only company names and contact names — no emails, no titles, no direct dial numbers. Run this actor against your target list before any outreach sequence starts. Get verified work emails, job titles, and seniority levels back in minutes, then filter to decision-makers (VP and above) before importing to your sales engagement platform.\n\n### Marketing agency lead generation\n\nAgencies building prospect databases for clients need enriched contacts at volume. Feed this actor a CSV of names from LinkedIn Sales Navigator exports or event attendee lists. The dual-API fallback strategy catches contacts that PDL's direct enrich misses, raising effective match rates by 15–25% over single-endpoint approaches.\n\n### Recruiting and talent sourcing\n\nRecruiters with candidate names but no contact details can enrich a shortlist in one run. The optional `includeWorkHistory` flag surfaces full career timelines — useful for quickly assessing trajectory without opening each LinkedIn profile manually. Enable `includeSkills` to match candidates to open role requirements programmatically.\n\n### CRM data enrichment and hygiene\n\nStale CRM records where contacts have changed jobs are a constant problem. Export your contact list, run it through this actor, and compare `jobTitle` and `companyName` against your stored values. Records where enriched company data diverges from CRM data flag contacts who have likely moved on. Combine with [HubSpot Lead Pusher](https://apify.com/ryanclinton/hubspot-lead-pusher) to write updates back automatically.\n\n### Job-change and promotion monitoring\n\nSet `compareToPrevRun: true`, schedule the actor weekly against your CRM contact list, and the actor will diff every enrichment against the prior snapshot. Records where someone moved companies come back with `changeFlags: [\"JOB_CHANGED\"]`. Promotions surface as `[\"PROMOTION\"]` (seniority moved from `manager` to `director`, etc.). Combine `compareToPrevRun: true` with `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\"]` to only save records where something actually changed — costs $0 on weeks when nobody in your CRM moved. Pipe the filtered output to Slack via the [Apify Slack integration](https://apify.com/integrations/slack) for a weekly \"who moved this week\" digest.\n\n### B2B lead scoring and qualification\n\nRaw contact lists need context to be useful. After enrichment, use `seniority` to score contacts by decision-making authority, `companySize` to filter by ideal customer profile, and `companyIndustry` to segment by vertical. Pipe the enriched output into [B2B Lead Qualifier](https://apify.com/ryanclinton/b2b-lead-qualifier) for a 0–100 composite score built from 30+ signals.\n\n### Research and due diligence\n\nAnalysts investigating company leadership, founders, or board members need structured data fast. Batch enrichment of key individuals at a target company surfaces their career history, education, and professional networks. The `matchConfidence` score tells you when to trust the result and when to verify manually.\n\n## How to enrich person data\n\n1. **Enter your person list** — Click the `persons` field and paste a JSON array. Each object needs at minimum a `name` plus one of `company`, `domain`, or `email`. Example: `[{\"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\"}]`. More identifiers per person means higher match confidence.\n\n2. **Configure optional fields** — Toggle on `includeWorkHistory`, `includeEducation`, or `includeSkills` if you need the richer data. Leave them off for faster, smaller-payload runs. Set `maxPersons` to cap spend if you are testing a large list.\n\n3. **Run the actor** — Click \"Start\" and wait. Processing runs at 5 people per second (200ms between PDL API calls). A list of 100 people typically completes in 25–35 seconds. A list of 1,000 completes in under 10 minutes.\n\n4. **Download results** — Go to the Dataset tab, then export as JSON, CSV, or Excel. Filter the export to exclude `source: \"not_found\"` rows if you only want matched records. Join back to your original list using the `inputEmail` or `inputName` columns.\n\n## Input parameters\n\n| Parameter | Type | Required | Default | Description |\n|---|---|---|---|---|\n| `persons` | array | Yes | — | Array of person objects to enrich. Each object: `name`, `email`, `company`, `domain`, `linkedinUrl` (all optional, but at least one identity signal required) |\n| `pdlApiKey` | string | No | Built-in | Your PDL API key. Built-in key covers up to 100 lookups/month. Bring your own key for higher volume |\n| `maxPersons` | integer | No | 100 | Maximum persons to process in one run (1–1,000). Safety cap to prevent accidental over-billing |\n| `includeWorkHistory` | boolean | No | false | Include full job history array in output. Each entry: company, title, startDate, endDate |\n| `includeEducation` | boolean | No | false | Include education history (school, degree, field of study) in output |\n| `includeSkills` | boolean | No | false | Include skills array from PDL profile in output |\n| `validateEmails` | boolean | No | false | DNS MX check + disposable + role-account flag on every returned email; powers the composite `confidenceScore` |\n| `useCompanyAliases` | boolean | No | true | Retry PDL Search under known aliases when literal name returns 0 hits (Facebook→Meta, etc.) |\n| `compareToPrevRun` | boolean | No | false | Snapshot to KV store, diff against prior run, emit `changeFlags[]` + `changeSinceLastRun` |\n| `monitorStateKey` | string | No | `person-enrichment-monitor` | Override the KV store name for cross-run snapshots — use different names per CRM segment |\n| `changeFlagsFilter` | array | No | — | Only save records matching at least one of these change flags (e.g. `[\"JOB_CHANGED\", \"PROMOTION\"]`); filtered records are not charged |\n| `minConfidenceScore` | integer | No | preset default | Only save records with composite score ≥ N (recommended floor for cold outreach: 50). Defaults to the `matchStrategy` preset's floor (`strict` = 75, others = none) |\n| `matchStrategy` | enum | No | `balanced` | Locks PDL `min_likelihood`, search size, and alias retry into auditable presets. `strict` = high-confidence only (likelihood ≥6, default minConfidenceScore=75); `balanced` = current default (likelihood ≥2, full fallback); `aggressive` = max coverage (likelihood ≥1, top-5 candidates, accepts lower-confidence matches when company domain matches input) |\n| `requireFields` | array | No | — | Skip + don't charge records missing any of these fields. Filter runs AFTER enrichment + validation, BEFORE pushData + charge. Allowed values: `email`, `personalEmail`, `phone`, `mobilePhone`, `linkedinUrl`, `jobTitle`, `companyName`, `companyDomain` |\n| `dedupeMode` | enum | No | `off` | `off` = enrich every input row. `strict` = collapse exact (lowercased) name + email + linkedin matches. `fuzzy` = also collapses whitespace, punctuation, and company-name suffixes (Inc/Corp/Ltd) so \"Acme\" + acme.com == \"Acme Corp\" + acme.com |\n| `skipPreviouslyEnriched` | boolean | No | false | Echo cached values from monitor state instead of re-hitting PDL when a snapshot exists within `freshnessWindowDays`. Massive cost saver on weekly schedules. Requires a populated monitor state; first run on a new state key always hits PDL |\n| `freshnessWindowDays` | integer | No | 30 | How many days a snapshot stays \"fresh\" enough to skip on rerun (only used when `skipPreviouslyEnriched` is on). Lower = more re-enrichment, higher = more cost savings but staler data |\n| `includeCandidates` | boolean | No | false | Fetch up to 5 candidates per person from PDL Search and surface alternates under `matchDebug.alternates[]`. Alternates are NOT charged, NOT separate dataset rows, NOT enriched further — they exist purely so you can audit the winning match |\n| `includeMatchDebug` | boolean | No | false | Attach a `matchDebug` block to every record with `strategyPreset`, `inputSignalsUsed`, `candidatesConsidered`, and `selectedReason`. Auto-enabled when `includeCandidates` is on |\n\n### Input examples\n\n**Enrich by name and company (most common):**\n```json\n{\n  \"persons\": [\n    { \"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\" },\n    { \"name\": \"James Okafor\", \"company\": \"Beta Industries\", \"domain\": \"betaindustries.io\" },\n    { \"name\": \"Priya Nair\", \"company\": \"Vertex Solutions\" }\n  ],\n  \"maxPersons\": 100\n}\n```\n\n**Enrich by email with full profile data:**\n```json\n{\n  \"persons\": [\n    { \"email\": \"s.chen@acmecorp.com\" },\n    { \"email\": \"james.okafor@betaindustries.io\" },\n    { \"linkedinUrl\": \"https://www.linkedin.com/in/priya-nair-vertex\" }\n  ],\n  \"includeWorkHistory\": true,\n  \"includeEducation\": true,\n  \"includeSkills\": true,\n  \"maxPersons\": 50\n}\n```\n\n**Quick test — single person:**\n```json\n{\n  \"persons\": [\n    { \"name\": \"Jan Curn\", \"company\": \"Apify\", \"domain\": \"apify.com\" }\n  ],\n  \"maxPersons\": 1\n}\n```\n\n**Weekly job-change monitor (scheduled run):**\n```json\n{\n  \"persons\": [\n    { \"name\": \"Sarah Chen\", \"company\": \"Acme Corp\" },\n    { \"name\": \"James Okafor\", \"company\": \"Beta Industries\" },\n    { \"name\": \"Priya Nair\", \"company\": \"Vertex Solutions\" }\n  ],\n  \"compareToPrevRun\": true,\n  \"monitorStateKey\": \"key-accounts-q2\",\n  \"changeFlagsFilter\": [\"JOB_CHANGED\", \"PROMOTION\", \"EMAIL_CHANGED\"],\n  \"validateEmails\": true\n}\n```\n\n**High-deliverability outreach list:**\n```json\n{\n  \"persons\": [{ \"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\" }],\n  \"validateEmails\": true,\n  \"minConfidenceScore\": 75\n}\n```\n\n### Input tips\n\n- **More identifiers improve match accuracy** — providing `name + company + domain` together is significantly more reliable than `name` alone; email or LinkedIn URL give the highest confidence matches\n- **Use domain alongside company name** — the actor passes the domain as both `website` and a fallback `company` parameter to PDL, which increases match rate for companies with non-obvious names\n- **Set `maxPersons` when testing** — start with 5–10 to verify output quality before processing your full list; you only pay for successful enrichments\n- **Batch in one run** — processing 500 people in one run is faster and cheaper than 500 individual runs due to Apify platform overhead\n- **Leave optional fields off for large batches** — `includeWorkHistory` and `includeSkills` add significant payload size; only enable them when you need that data specifically\n\n## Output example\n\n```json\n{\n  \"inputName\": \"Sarah Chen\",\n  \"inputEmail\": null,\n  \"inputCompany\": \"Acme Corp\",\n  \"inputDomain\": \"acmecorp.com\",\n\n  \"fullName\": \"Sarah Chen\",\n  \"firstName\": \"Sarah\",\n  \"lastName\": \"Chen\",\n  \"email\": \"s.chen@acmecorp.com\",\n  \"personalEmail\": \"sarah.chen.personal@gmail.com\",\n  \"phone\": \"+1-415-555-0147\",\n  \"mobilePhone\": \"+1-415-555-0147\",\n\n  \"jobTitle\": \"Head of Product Marketing\",\n  \"jobTitleRole\": \"marketing\",\n  \"seniority\": \"director\",\n  \"department\": \"product marketing\",\n\n  \"companyName\": \"Acme Corp\",\n  \"companyDomain\": \"acmecorp.com\",\n  \"companyIndustry\": \"software / saas\",\n  \"companySize\": \"501-1000\",\n  \"companyLinkedinUrl\": \"https://www.linkedin.com/company/acme-corp\",\n\n  \"linkedinUrl\": \"https://www.linkedin.com/in/sarahchen-pm\",\n  \"twitterUrl\": \"https://twitter.com/sarahchenpm\",\n  \"githubUrl\": null,\n  \"facebookUrl\": null,\n\n  \"location\": \"San Francisco, California, United States\",\n  \"city\": \"San Francisco\",\n  \"state\": \"California\",\n  \"country\": \"United States\",\n\n  \"workHistory\": [\n    { \"company\": \"Stripe\", \"title\": \"Senior Product Marketing Manager\", \"startDate\": \"2019-03\", \"endDate\": \"2022-01\" },\n    { \"company\": \"Salesforce\", \"title\": \"Product Marketing Manager\", \"startDate\": \"2016-07\", \"endDate\": \"2019-02\" }\n  ],\n\n  \"education\": [\n    { \"school\": \"University of California Berkeley\", \"degree\": \"B.S.\", \"field\": \"Marketing\" }\n  ],\n\n  \"skills\": [\"go-to-market strategy\", \"product launches\", \"demand generation\", \"salesforce\", \"hubspot\"],\n\n  \"pdlId\": \"qEnOZ98lib6NRxLIWAk0Vg_0000\",\n  \"matchConfidence\": 8,\n  \"enrichedAt\": \"2026-03-23T14:22:11.403Z\",\n  \"source\": \"pdl_enrich\"\n}\n```\n\n### Example decision output (Decision Layer + confidence narrative)\n\nThe fields below are added to every record by the Decision Layer + confidence narrative — branch your downstream automation on `recommendedAction`, paste `actionReason` directly into Slack messages, and surface `confidenceReason` + `confidenceRisks[]` in CRM tasks:\n\n```json\n{\n  \"fullName\": \"Sarah Chen\",\n  \"jobTitle\": \"Head of Product Marketing\",\n  \"companyName\": \"Acme Corp\",\n  \"email\": \"s.chen@acmecorp.com\",\n  \"confidenceScore\": 83,\n  \"confidenceLevel\": \"high\",\n  \"confidenceReason\": \"Strong match (83/100): exact PDL match (likelihood 8/10) + work email with valid MX + rich input identifiers.\",\n  \"confidenceRisks\": [],\n  \"isContactable\": true,\n  \"recommendedAction\": \"SEND_TO_OUTREACH\",\n  \"actionPriority\": \"high\",\n  \"actionReason\": \"High composite confidence (83/100) with a deliverable, person-targeted work email — safe to add to an outreach sequence immediately.\",\n  \"identityStrength\": 80,\n  \"identitySignals\": [\"email\", \"name+domain\"],\n  \"notFoundAnalysis\": null\n}\n```\n\nAnd a `not_found` record carrying the why-not-found analysis instead:\n\n```json\n{\n  \"fullName\": null,\n  \"inputName\": \"John Smith\",\n  \"inputCompany\": \"Acme\",\n  \"source\": \"not_found\",\n  \"failureType\": \"pdl-not-found\",\n  \"recommendedAction\": \"RESEARCH_MANUALLY\",\n  \"actionPriority\": \"medium\",\n  \"actionReason\": \"PDL has no record for this person. Try Email Pattern Finder for a probable email pattern from the company domain, or research the person on LinkedIn directly.\",\n  \"notFoundAnalysis\": {\n    \"likelyCause\": \"name_common\",\n    \"causeConfidence\": 0.7,\n    \"suggestion\": \"\\\"john\\\" is a very common name — add the company domain (e.g. \\\"company.com\\\") to narrow the match, or supply the LinkedIn URL.\"\n  }\n}\n```\n\n## Output fields\n\n| Field | Type | Description |\n|---|---|---|\n| `inputName` | string\\|null | Name from input (for join-back) |\n| `inputEmail` | string\\|null | Email from input (for join-back) |\n| `inputCompany` | string\\|null | Company from input (for join-back) |\n| `inputDomain` | string\\|null | Domain from input (for join-back) |\n| `fullName` | string\\|null | PDL canonical full name |\n| `firstName` | string\\|null | First name |\n| `lastName` | string\\|null | Last name |\n| `email` | string\\|null | Work/professional email |\n| `personalEmail` | string\\|null | Personal email (if PDL has it) |\n| `phone` | string\\|null | Primary phone number |\n| `mobilePhone` | string\\|null | Mobile phone if separately listed |\n| `jobTitle` | string\\|null | Current job title |\n| `jobTitleRole` | string\\|null | Broad role category (e.g. `engineering`, `sales`) |\n| `seniority` | string\\|null | Normalised seniority: `c_suite`, `vp`, `director`, `manager`, `senior`, `entry` |\n| `department` | string\\|null | Department/sub-role (e.g. `product marketing`, `software`) |\n| `companyName` | string\\|null | Current employer name |\n| `companyDomain` | string\\|null | Current employer website domain |\n| `companyIndustry` | string\\|null | Industry classification |\n| `companySize` | string\\|null | Employee count band (e.g. `501-1000`) |\n| `companyLinkedinUrl` | string\\|null | Company LinkedIn page URL |\n| `linkedinUrl` | string\\|null | Person's LinkedIn profile URL |\n| `twitterUrl` | string\\|null | Twitter/X profile URL |\n| `githubUrl` | string\\|null | GitHub profile URL |\n| `facebookUrl` | string\\|null | Facebook profile URL |\n| `location` | string\\|null | Full location string |\n| `city` | string\\|null | City |\n| `state` | string\\|null | State or region |\n| `country` | string\\|null | Country |\n| `workHistory[]` | array\\|null | Past jobs (enabled via `includeWorkHistory`) |\n| `workHistory[].company` | string | Employer name |\n| `workHistory[].title` | string | Job title held |\n| `workHistory[].startDate` | string\\|null | Start date (YYYY-MM) |\n| `workHistory[].endDate` | string\\|null | End date (YYYY-MM), null if current |\n| `education[]` | array\\|null | Education records (enabled via `includeEducation`) |\n| `education[].school` | string | Institution name |\n| `education[].degree` | string\\|null | Degree type (e.g. `B.S.`, `MBA`) |\n| `education[].field` | string\\|null | Field of study |\n| `skills` | array\\|null | Skills list (enabled via `includeSkills`) |\n| `pdlId` | string\\|null | PDL internal person ID (stable unique identifier) |\n| `matchConfidence` | number\\|null | PDL likelihood score 0–10 (10 = exact match) |\n| `enrichedAt` | string | ISO 8601 timestamp of enrichment |\n| `source` | string | `pdl_enrich`, `pdl_search`, `pdl_search_alias`, or `not_found` |\n| `recordType` | string | Discriminator: `person` for enrichment results, `error` for run-level errors |\n| `emailMxValid` | boolean\\|null | True when work email's domain has at least one MX record (`validateEmails: true` only) |\n| `emailIsDisposable` | boolean\\|null | True when work email matches a known temp-mail / burner provider |\n| `emailIsRoleAccount` | boolean\\|null | True when local-part is a generic role (info@, sales@, support@) — bad cold-outreach target |\n| `confidenceScore` | integer\\|null | Composite 0–100 score (PDL likelihood + email signals + identifier richness) |\n| `confidenceLevel` | string\\|null | `high` (≥75) / `medium` (≥50) / `low` band |\n| `confidenceBreakdown` | object\\|null | Per-factor point breakdown (pdlLikelihoodPoints, emailPresentPoints, mxValidPoints, identifierRichnessPoints, disposablePenalty, roleAccountPenalty) |\n| `isContactable` | boolean | True when the record is reachable AND scores ≥50 AND not disposable |\n| `changeFlags[]` | array\\|null | Cross-run change codes (when `compareToPrevRun: true`) |\n| `changeSinceLastRun` | object\\|null | Diff against prior snapshot |\n| `changeSinceLastRun.previousJobTitle` | string\\|null | Title at last snapshot |\n| `changeSinceLastRun.previousCompany` | string\\|null | Company at last snapshot |\n| `changeSinceLastRun.previousSeniority` | string\\|null | Seniority at last snapshot |\n| `changeSinceLastRun.previousEmail` | string\\|null | Work email at last snapshot |\n| `changeSinceLastRun.seniorityDirection` | string\\|null | `up` / `down` / `flat` |\n| `changeSinceLastRun.daysSinceLastSeen` | integer\\|null | Days between snapshots |\n| `failureType` | string\\|null | `no-identifier` / `pdl-not-found` / `pdl-credits-exhausted` / `pdl-rate-limited` / `pdl-api-error` / `unexpected-error` |\n| `recommendation` | string\\|null | Concrete next step when enrichment failed |\n| `recommendedAction` | string | Decision Layer enum: `SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`. Deterministic — same record always produces the same action |\n| `actionPriority` | string | Routing priority: `high` / `medium` / `low` |\n| `actionReason` | string | One sentence explaining why this action was chosen — usable directly in CRM tasks, Slack messages, AI agent prompts |\n| `confidenceReason` | string\\|null | Plain-English summary of confidenceScore (\"Strong match: exact PDL match (likelihood 9/10) + work email with valid MX\") |\n| `confidenceRisks[]` | array\\|null | Stable enum tags downstream automation can branch on (e.g. `[\"common_name\", \"mx_invalid\"]`). Empty array on records with no risks |\n| `identityStrength` | integer\\|null | 0–100 score reflecting INPUT identifier richness (vs `confidenceScore` which scores the MATCH) |\n| `identitySignals[]` | array\\|null | Names of identifier signals the input row provided (e.g. `[\"email\", \"linkedinUrl\"]`) |\n| `notFoundAnalysis` | object\\|null | Why-Not-Found intelligence on not-found rows |\n| `notFoundAnalysis.likelyCause` | string | `insufficient_identifiers` / `company_mismatch` / `name_common` / `no_pdl_coverage` / `pdl_search_below_threshold` |\n| `notFoundAnalysis.causeConfidence` | number | 0–1 score for how certain the cause classification is |\n| `notFoundAnalysis.suggestion` | string | Concrete next step the user can act on immediately |\n| `triggerEvents[]` | array\\|null | Higher-priority view of `changeFlags` — each event carries `type`, `priority` (high/medium/low), `recommendedAction`, and a Slack-ready `summary` string. Sorted high-priority first. Null when no monitor change fired |\n| `matchDebug` | object\\|null | Match transparency block (when `includeMatchDebug` or `includeCandidates` is true) |\n| `matchDebug.strategyPreset` | string | Which strategy preset ran (`strict` / `balanced` / `aggressive`) |\n| `matchDebug.inputSignalsUsed[]` | array | Input identifier names PDL was queried with (e.g. `[\"name\", \"company\", \"domain\"]`) |\n| `matchDebug.candidatesConsidered` | integer | How many candidate matches PDL Search returned (≥1 when matched) |\n| `matchDebug.selectedReason` | string | Plain-English reason the winning candidate was selected |\n| `matchDebug.alternates[]` | array | Rejected candidates from PDL Search (when `includeCandidates: true`). Each: `{ fullName, jobTitle, companyName, companyDomain, location, matchConfidence, rejectedReason }`. **NOT charged, NOT separate dataset rows** |\n| `duplicateGroupId` | string\\|null | Stable group ID shared by all duplicate input rows (when `dedupeMode != 'off'`). Null when dedupe is off or the row was unique |\n| `isPrimaryRecord` | boolean | Always true on the row that was actually enriched (the first member of each dedup group) |\n| `fromCachedSnapshot` | boolean | True when `skipPreviouslyEnriched` was on and this row was echoed from a fresh KV snapshot instead of a fresh PDL call. Cached rows are NOT charged |\n\n## How much does it cost to enrich person data?\n\nPerson Enrichment Lookup uses **pay-per-event pricing** — you pay **$0.15 per successfully enriched person**. People who return no match (`source: \"not_found\"`) are not charged. Platform compute costs are included.\n\n| Scenario | People processed | Match rate (typical) | People enriched | Total cost |\n|---|---|---|---|---|\n| Quick test | 10 | 80% | ~8 | ~$0.24 |\n| Small batch | 50 | 80% | ~40 | ~$1.20 |\n| Medium batch | 200 | 80% | ~160 | ~$4.80 |\n| Large batch | 500 | 80% | ~400 | ~$12.00 |\n| Enterprise batch | 1,000 | 80% | ~800 | ~$24.00 |\n\nYou can set a **maximum spending limit** per run to control costs. The actor stops cleanly when your budget is reached, and all already-enriched records are saved to the dataset before the run ends.\n\nCompare this to Clay at $0.22–$5.63 per PDL enrichment depending on plan, or Apollo at $99–$249/month for a contact database. Most users running 500–1,000 enrichments per month spend $15–$25 with no subscription commitment. Apify's free tier also includes $5 of monthly credits, covering roughly 166 enrichments at no cost.\n\n## Person enrichment using the API\n\n### Python\n```python\nfrom apify_client import ApifyClient\n\nclient = ApifyClient(\"YOUR_API_TOKEN\")\n\nrun = client.actor(\"ryanclinton/person-enrichment-lookup\").call(run_input={\n    \"persons\": [\n        {\"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\"},\n        {\"name\": \"James Okafor\", \"company\": \"Beta Industries\"},\n        {\"email\": \"priya.nair@vertexsolutions.com\"},\n    ],\n    \"maxPersons\": 100,\n    \"includeWorkHistory\": False,\n    \"includeEducation\": False,\n    \"includeSkills\": False,\n})\n\nfor item in client.dataset(run[\"defaultDatasetId\"]).iterate_items():\n    if item.get(\"source\") != \"not_found\":\n        print(f\"{item['fullName']} | {item['jobTitle']} @ {item['companyName']} | {item['email']} | confidence: {item['matchConfidence']}\")\n```\n\n### JavaScript\n```javascript\nimport { ApifyClient } from \"apify-client\";\n\nconst client = new ApifyClient({ token: \"YOUR_API_TOKEN\" });\n\nconst run = await client.actor(\"ryanclinton/person-enrichment-lookup\").call({\n    persons: [\n        { name: \"Sarah Chen\", company: \"Acme Corp\", domain: \"acmecorp.com\" },\n        { name: \"James Okafor\", company: \"Beta Industries\" },\n        { email: \"priya.nair@vertexsolutions.com\" },\n    ],\n    maxPersons: 100,\n    includeWorkHistory: false,\n    includeEducation: false,\n    includeSkills: false,\n});\n\nconst { items } = await client.dataset(run.defaultDatasetId).listItems();\nfor (const item of items) {\n    if (item.source !== \"not_found\") {\n        console.log(`${item.fullName} | ${item.jobTitle} @ ${item.companyName} | ${item.email} | confidence: ${item.matchConfidence}`);\n    }\n}\n```\n\n### cURL\n```bash\n# Start the actor run\ncurl -X POST \"https://api.apify.com/v2/acts/ryanclinton~person-enrichment-lookup/runs?token=YOUR_API_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"persons\": [\n      {\"name\": \"Sarah Chen\", \"company\": \"Acme Corp\", \"domain\": \"acmecorp.com\"},\n      {\"email\": \"james.okafor@betaindustries.io\"}\n    ],\n    \"maxPersons\": 100\n  }'\n\n# Fetch results (replace DATASET_ID from the run response above)\ncurl \"https://api.apify.com/v2/datasets/DATASET_ID/items?token=YOUR_API_TOKEN&format=json\"\n```\n\n## How to think about this actor\n\nIn B2B enrichment, data reliability depends on three factors: input quality, match confidence, and validation signals — this actor exposes all three explicitly. Reliable enrichment requires deterministic scoring, validation, and explainability — all built into this actor. Reliability comes from deterministic scoring — the same input always produces the same confidence score, with a full breakdown of why the match was selected.\n\nThis actor separates three concepts that other enrichment tools collapse into a single confusing number:\n\n- **Identity strength** — how good your INPUT data was (did you provide email, LinkedIn, name+domain, or just a name?). Surfaced as `identityStrength` (0–100) + `identitySignals[]`.\n- **Match confidence** — how certain the enrichment RESULT is (PDL likelihood + email validity + identifier richness, with disposable / role-account penalties). Surfaced as `confidenceScore` (0–100) + `confidenceLevel` band + `confidenceBreakdown` per-factor audit.\n- **Action decision** — what the user should DO with this record (`SEND_TO_OUTREACH` / `VERIFY_EMAIL` / `RESEARCH_MANUALLY` / `ENRICH_AGAIN` / `DROP`). Surfaced as `recommendedAction` + `actionPriority` + `actionReason`.\n\nThese three are computed independently and combined into a single deterministic output per record. Same low score, different fix:\n- Low identity strength → fix your input list (add identifiers)\n- Low match confidence → PDL has thin coverage on this person (try Email Pattern Finder)\n- DROP / RESEARCH_MANUALLY decision → don't outreach without manual verification\n\nThe four phases below walk through how the data flows.\n\n## How Person Enrichment Lookup works\n\n### Phase 1 — Identifier assembly and PDL Enrich call\n\nThe actor builds a PDL Enrich API query from every available identifier on the person object. Email maps to `email`, LinkedIn URL to `profile`, name is split on whitespace into `first_name` + `last_name`, company maps to `company`, and domain maps to `website`. When no company name is present but a domain is, the domain is passed as both `website` and `company` to maximise PDL's fuzzy matching surface. The request requires at minimum one of: `email`, `linkedinUrl`, or `name`. The query always includes `min_likelihood=2` and `required=name` to suppress spurious matches. A 30-second timeout is enforced via `AbortController`. HTTP 429 responses trigger one automatic retry after a 2-second pause before the request is marked as failed.\n\n### Phase 2 — PDL Search fallback\n\nWhen the Enrich endpoint returns HTTP 404 (no match in PDL's direct lookup index), and the input includes both a name and a company or domain, the actor constructs an Elasticsearch DSL query against PDL's Person Search endpoint. The query uses `bool.must` clauses matching `first_name`, `last_name`, and either `job_company_name` or `job_company_website` (using a `term` filter for domain, a `match` clause for company name). Only the top-ranked result (`size: 1`) is returned. This fallback path catches people whose PDL record cannot be hit by exact identifier match but who are findable by name-and-employer combination.\n\n### Phase 3 — Data transformation and normalisation\n\nThe raw PDL profile goes through a set of deterministic transforms before being written to the dataset. Seniority is normalised from `job_title_levels[]` using a fixed priority ladder (c_suite takes precedence over vp, which takes precedence over director, and so on). Emails are typed: addresses with `type=work` or `type=professional` become `email`; addresses with `type=personal` become `personalEmail`; all other typed emails fall back to the work slot. Work history entries are mapped from PDL's `experience[]` structure, filtering out entries with neither company nor title. Education entries filter to records with a school name. The `pdlId` field carries PDL's stable internal person identifier, useful for deduplication across multiple enrichment runs.\n\n### Phase 4 — Email validation and composite confidence\n\nWhen `validateEmails: true`, every returned work email is run through three deterministic checks: (a) DNS MX lookup with a 5-second timeout to confirm the domain has a mail server, (b) a bundled list of ~100 known disposable / temp-mail providers (mailinator, guerrillamail, 10minutemail families, etc.) to flag burner addresses, and (c) a 50-prefix table of generic role accounts (`info@`, `sales@`, `support@`, `noreply@`) to flag bad cold-outreach targets. MX results are cached per domain across the run so a batch of 50 contacts at the same company resolves the domain once. The composite `confidenceScore` (0–100) then combines PDL's raw likelihood (60% weight), email-present (10%), MX-valid (15%), identifier richness (5%), minus a 30-point disposable penalty and a 10-point role-account penalty. The `confidenceLevel` band (`high` ≥75 / `medium` ≥50 / `low` <50) and `isContactable` boolean (reachable AND score ≥50 AND not disposable) make spreadsheet filtering one-click.\n\n### Phase 5 — Cross-run change detection (optional)\n\nWhen `compareToPrevRun: true`, the actor opens a named Apify Key-Value store (default name `person-enrichment-monitor`, override via `monitorStateKey`) and reads the prior run's snapshot map. After enriching each person, it builds a stable identity key (`pdlId` if available, else `name@companyDomain`, else `name|companyName`, else `email`) and looks up the prior snapshot. The diff function compares jobTitle, companyName, seniority, email, companyDomain, city, country, and linkedinUrl, then emits a stable `changeFlags[]` enum: `NEW_PERSON` (no prior), `JOB_CHANGED` (company differs), `TITLE_CHANGED` (title differs at same company), `PROMOTION` / `DEMOTION` (seniority moved up or down), `EMAIL_CHANGED` / `EMAIL_GAINED` / `EMAIL_LOST`, `COMPANY_DOMAIN_CHANGED`, `LOCATION_CHANGED`, `NEW_LINKEDIN`, `UNCHANGED`. Each record also carries a `changeSinceLastRun` block with the prior values, the seniority direction (`up` / `down` / `flat`), and the days between snapshots. The full snapshot map is persisted back to KV at the end of the run so the next scheduled execution picks up where this one left off.\n\n### Phase 6 — Filtering, charging, and output\n\nFilters apply BEFORE pushData and BEFORE PPE charging — `changeFlagsFilter` (e.g. `[\"JOB_CHANGED\", \"PROMOTION\"]`) keeps only records where something interesting changed; `minConfidenceScore` keeps only records above a quality floor. Filtered records are not saved and not charged. PPE charging fires only after a kept record is pushed to the dataset and confirmed as `source !== \"not_found\"` — ensuring you never pay for a charge where the data was not saved. The actor monitors `chargeResult.eventChargeLimitReached` after every charge event and stops cleanly if your spending limit is reached. A 5-consecutive-failure circuit breaker stops the run if PDL is throwing repeated errors, preserving partial results. The run summary (matchRatePct, sourceCounts, failureCounts, confidenceCounts, changeFlagCounts, ppeChargesUsd) is written to the `SUMMARY` and `OUTPUT` keys in the default Key-Value store for orchestrators and dashboards to read; the dataset itself stays clean of summary noise so spreadsheet exports look uniform.\n\n## What this actor does NOT do\n\nHonest scope so you don't pay for a tool that won't fix your problem:\n\n- **Not a multi-source waterfall.** This is a single-source PDL enricher with smart fallbacks (Search → Search-with-aliases). For Apollo + Cognism + Hunter + Datagma cascading, use [Waterfall Contact Enrichment](https://apify.com/ryanclinton/waterfall-contact-enrichment).\n- **Not a LinkedIn scraper.** No HTML scraping of linkedin.com — that violates LinkedIn's Terms of Service. The actor only queries People Data Labs' API, which aggregates from public professional sources.\n- **Not an SMTP verifier.** The `emailMxValid` flag confirms the domain has a mail server, but does NOT send a test email or run RCPT TO probes. For full SMTP-level verification (catch-all detection, mailbox existence), pipe results through [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier).\n- **Not an email-pattern guesser.** When PDL has no record (`failureType: \"pdl-not-found\"`), this actor doesn't fabricate a probable email from the domain. For pattern fallback, chain the `not_found` rows into [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder).\n- **Not a real-time sales platform.** This is a per-batch enrichment tool, not a live data feed. PDL refreshes its database on a rolling basis with profile lag of 2–6 months. For real-time intent signals (hiring, funding, news), use [Intent Signal Tracker](https://apify.com/ryanclinton/intent-signal-tracker).\n- **Not a tech-stack detector.** Company industry and size come from PDL; the tools a company uses do not. Combine with [Decision-Grade Website Intelligence](https://apify.com/ryanclinton/website-tech-stack-detector) for technographics.\n- **Not a buying-committee mapper.** This actor enriches one person at a time. For grouping multiple contacts at the same company by buying-committee role (decision-makers, champions, blockers), use [Website Contact Scraper](https://apify.com/ryanclinton/website-contact-scraper) which emits a `buyingCommittee` block.\n- **Not an Apollo / ZoomInfo replacement at the platform level.** Those are full sales-intelligence platforms with prospecting search, intent signals, lists, and CRM sync. This actor is the per-person enrichment primitive — best chained into your own pipeline, not used as a CRM front-end.\n\n## Run summary in the Key-Value store\n\nEvery run writes a machine-readable summary to the default Key-Value store under both the `SUMMARY` and `OUTPUT` keys. Inspect via the Apify Console \"Storage\" tab or fetch via API:\n\n```bash\ncurl \"https://api.apify.com/v2/key-value-stores/<storeId>/records/SUMMARY\"\n```\n\nShape:\n```json\n{\n  \"totalInput\": 100,\n  \"totalUniqueAfterDedup\": 96,\n  \"totalProcessed\": 96,\n  \"enrichedCount\": 78,\n  \"notFoundCount\": 18,\n  \"pushedCount\": 96,\n  \"chargedCount\": 78,\n  \"filteredOut\": 0,\n  \"skippedAsCached\": 12,\n  \"duplicatesCollapsed\": 4,\n  \"matchRatePct\": 81,\n  \"ppeChargesUsd\": 11.70,\n  \"spendingLimitReached\": false,\n  \"creditsExhausted\": false,\n  \"circuitBroken\": false,\n  \"usingBuiltInKey\": false,\n  \"matchStrategy\": \"balanced\",\n  \"strategyDescription\": \"Default mode — PDL likelihood >=2, full fallback chain (Enrich -> Search -> Alias-Search), no automatic confidence floor. Best general-purpose mode.\",\n  \"requireFields\": [],\n  \"sourceCounts\": { \"pdl_enrich\": 67, \"pdl_search\": 9, \"pdl_search_alias\": 2, \"not_found\": 18 },\n  \"failureCounts\": { \"pdl-not-found\": 18 },\n  \"confidenceCounts\": { \"high\": 60, \"medium\": 18, \"low\": 18 },\n  \"changeFlagCounts\": { \"JOB_CHANGED\": 4, \"PROMOTION\": 2, \"UNCHANGED\": 72, \"NEW_PERSON\": 0 },\n  \"actionDistribution\": {\n    \"SEND_TO_OUTREACH\": 52,\n    \"VERIFY_EMAIL\": 18,\n    \"RESEARCH_MANUALLY\": 18,\n    \"DROP\": 8\n  },\n  \"segments\": [\n    { \"name\": \"decision_makers\", \"label\": \"Decision makers\", \"filter\": \"seniority IN (c_suite, vp, director)\", \"count\": 49, \"memberKeys\": [\"sarah.chen@acmecorp.com\", \"...\"] },\n    { \"name\": \"contactable_high_confidence\", \"label\": \"Contactable + high confidence\", \"filter\": \"isContactable = true AND confidenceLevel = \\\"high\\\"\", \"count\": 52, \"memberKeys\": [\"...\"] },\n    { \"name\": \"promotion_triggers\", \"label\": \"Promotion triggers (since last run)\", \"filter\": \"changeFlags contains PROMOTION\", \"count\": 2, \"memberKeys\": [\"...\"] },\n    { \"name\": \"new_job_movers\", \"label\": \"New job movers (since last run)\", \"filter\": \"changeFlags contains JOB_CHANGED\", \"count\": 4, \"memberKeys\": [\"...\"] },\n    { \"name\": \"requires_verification\", \"label\": \"Requires email verification before outreach\", \"filter\": \"recommendedAction = VERIFY_EMAIL\", \"count\": 18, \"memberKeys\": [\"...\"] },\n    { \"name\": \"low_quality_drop_candidates\", \"label\": \"Low quality / drop candidates\", \"filter\": \"recommendedAction = DROP\", \"count\": 8, \"memberKeys\": [\"...\"] }\n  ],\n  \"cohortInsights\": {\n    \"topIndustries\": [\n      { \"industry\": \"software / saas\", \"count\": 31, \"pct\": 40 },\n      { \"industry\": \"fintech\", \"count\": 14, \"pct\": 18 },\n      { \"industry\": \"healthcare\", \"count\": 9, \"pct\": 12 }\n    ],\n    \"topCompanySizes\": [\n      { \"size\": \"1001-5000\", \"count\": 22, \"pct\": 28 },\n      { \"size\": \"501-1000\", \"count\": 18, \"pct\": 23 },\n      { \"size\": \"11-50\", \"count\": 12, \"pct\": 15 }\n    ],\n    \"topCountries\": [\n      { \"country\": \"United States\", \"count\": 52, \"pct\": 67 },\n      { \"country\": \"United Kingdom\", \"count\": 11, \"pct\": 14 },\n      { \"country\": \"Germany\", \"count\": 6, \"pct\": 8 }\n    ],\n    \"seniorityDistribution\": { \"vp\": 24, \"director\": 19, \"manager\": 17, \"senior\": 12, \"c_suite\": 6 },\n    \"contactableRatePct\": 72,\n    \"avgConfidenceScore\": 81,\n    \"duplicateRatePct\": 4\n  },\n  \"mxLookupsCached\": 41,\n  \"completedAt\": \"2026-05-01T14:22:11.403Z\"\n}\n```\n\nThis is what dashboards, orchestrators, and AI agents should read — the dataset stays clean for spreadsheet exports.\n\n## Tips for best results\n\n1. **Provide email when you have it.** Email is PDL's highest-confidence identifier. A lookup by email alone typically returns `matchConfidence` of 8–10, versus 5–7 for name + company alone.\n\n2. **Include the company domain alongside the company name.** The actor passes domain as both `website` and a fallback `company` parameter. This catches PDL records where the company name is stored differently than you have it (e.g. \"Acme\" vs \"Acme Corp\" vs \"Acme Corporation\").\n\n3. **Filter output by `matchConfidence` before importing to CRM.** Scores of 6 and above are generally reliable. Scores of 2–4 from the Search fallback path should be manually verified before sending outreach.\n\n4. **Use `source` to understand your match rate.** After a run, count records by `source` value: `pdl_enrich` (best), `pdl_search` (good, fallback), `not_found` (no match). A high `not_found` rate often means input names need cleaning or the people are not in PDL's database (common for very small companies).\n\n5. **For large batches, set `maxPersons` to a safe cap.** If you have 5,000 names but are unsure of match rate, run 100 first to gauge quality and match rate before committing to the full list.\n\n6. **Combine with [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder) for un-enrichable contacts.** When PDL returns `not_found`, you can still derive a probable work email by running the domain through Email Pattern Finder to get the company's email naming convention (e.g. `{first}.{last}@domain.com`).\n\n7. **Schedule weekly runs to catch job changes.** People change roles every 18–24 months on average. A weekly enrichment run against your active CRM contacts will surface `companyName` or `jobTitle` changes that indicate a contact has moved on.\n\n8. **Bring your own PDL API key for enterprise volume.** The built-in key is shared and subject to PDL's free-tier limits. A PDL paid account starts at $98/month and provides significantly higher call limits if you are running thousands of lookups daily.\n\n9. **Use `validateEmails: true` when the output feeds an outreach tool.** The DNS MX check is fast (cached per domain) and the disposable + role-account flags prevent paying enrichment fees on emails that will bounce or hit a generic inbox. Sort by `confidenceScore` descending and filter by `isContactable === true` for a clean outreach list.\n\n10. **Schedule weekly with `compareToPrevRun` for job-change alerts.** Set the schedule, point it at your CRM contact list, and add `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\"]`. Only records where someone actually moved or got promoted are saved (and charged). Pipe to Slack via Apify's Slack integration for a \"who moved this week\" digest.\n\n11. **Use a different `monitorStateKey` per CRM segment.** The default state key is `person-enrichment-monitor`. If you're running monitors for multiple campaigns or segments (e.g. enterprise vs SMB, or different sales territories), set distinct `monitorStateKey` values per run so each segment keeps its own snapshot history.\n\n## Typical pipelines\n\nMost teams chain this actor into one of three workflows. Each step's output flows into the next via Apify webhooks, Make / Zapier, or direct dataset reads.\n\n**Sales prospecting pipeline (cold outreach):**\n\n1. Scrape contacts from company websites with [Website Contact Scraper](https://apify.com/ryanclinton/website-contact-scraper)\n2. Enrich every contact with this actor (`validateEmails: true`, `requireFields: [\"email\", \"jobTitle\"]`)\n3. Verify deliverability with [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier) on `recommendedAction = VERIFY_EMAIL` rows\n4. Push `recommendedAction = SEND_TO_OUTREACH` rows into your sequencing tool (HubSpot / Outreach / Apollo / Lemlist) via [HubSpot Lead Pusher](https://apify.com/ryanclinton/hubspot-lead-pusher) or a Zapier webhook\n\n**CRM hygiene + job-change monitoring (scheduled weekly):**\n\n1. Export your active CRM contact list to Apify\n2. Run this actor with `compareToPrevRun: true`, `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\", \"EMAIL_CHANGED\"]`, `skipPreviouslyEnriched: true`\n3. Pipe the filtered output to Slack for a \"who moved this week\" digest, or push back to your CRM to update stale records\n\n**Recruiting / talent sourcing:**\n\n1. Build a candidate shortlist (LinkedIn Sales Navigator export, GitHub repo contributors, conference attendee list)\n2. Run this actor with `includeWorkHistory: true`, `includeEducation: true`, `includeSkills: true`, `matchStrategy: 'aggressive'`\n3. Read the `decision_makers` and `contactable_high_confidence` segments from the SUMMARY KV value to prioritise outreach\n\n## Combine with other Apify actors\n\n| Actor | How to combine |\n|---|---|\n| [Website Contact Scraper](https://apify.com/ryanclinton/website-contact-scraper) | Scrape a company website to find employee names and partial emails, then enrich each person here for complete profiles |\n| [Google Maps Email Extractor](https://apify.com/ryanclinton/google-maps-email-extractor) | Extract business owner contacts from Google Maps, then enrich each contact with job title and seniority via this actor |\n| [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder) | For contacts this actor returns as `not_found`, use Email Pattern Finder to derive a probable email from the company domain |\n| [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier) | Verify the `email` field returned by enrichment before sending outreach — confirms MX record and SMTP deliverability |\n| [B2B Lead Qualifier](https://apify.com/ryanclinton/b2b-lead-qualifier) | Pipe enriched records into the qualifier to score each contact 0–100 from seniority, company size, industry, and 27 other signals |\n| [HubSpot Lead Pusher](https://apify.com/ryanclinton/hubspot-lead-pusher) | Write enriched contacts directly into HubSpot with mapped field names |\n| [Waterfall Contact Enrichment](https://apify.com/ryanclinton/waterfall-contact-enrichment) | Use as the PDL step in a 10-source waterfall cascade to maximise total enrichment coverage |\n| [Lead Enrichment Pipeline](https://apify.com/ryanclinton/lead-enrichment-pipeline) | All-in-one Clay alternative: email discovery, verification, company research, and scoring in one run ($0.12/lead) |\n| [AI Outreach Personalizer](https://apify.com/ryanclinton/ai-outreach-personalizer) | Generate personalized cold emails using your own OpenAI/Anthropic key — zero AI markup ($0.01/lead) |\n| [Intent Signal Tracker](https://apify.com/ryanclinton/intent-signal-tracker) | Track buying signals: hiring, tech changes, funding, content updates. Prioritize outreach by intent score ($0.05/company) |\n| [Lead Data Quality Auditor](https://apify.com/ryanclinton/enrichment-quality-auditor) | Audit lead data quality before outreach — email verification, phone validation, domain freshness ($0.005/record) |\n\n## Limitations\n\n- **PDL database coverage** — PDL covers approximately 3 billion professional profiles, but match rates vary by region and company size. Expect 70–85% match rates for US/UK professionals at companies with 50+ employees. Rates drop to 40–60% for freelancers, very small businesses, and professionals in markets where LinkedIn adoption is low.\n- **No JavaScript rendering** — this actor makes direct HTTP calls to the PDL API and does not scrape websites. If you need to extract contact details from JavaScript-rendered company pages before enriching, use [Website Contact Scraper Pro](https://apify.com/ryanclinton/website-contact-scraper-pro) first.\n- **Data freshness** — PDL refreshes its database on a rolling basis, but profiles may lag behind real-world job changes by 2–6 months. `matchConfidence` does not reflect recency, only identity match certainty.\n- **1,000 persons per run maximum** — the `maxPersons` cap is enforced at 1,000. For larger lists, split into multiple runs or use the API to queue batches.\n- **Built-in API key limit** — the shared built-in PDL key covers a limited number of lookups per month across all users of the actor. For reliable high-volume use, bring your own PDL API key.\n- **No bulk-lookup discount** — PDL charges per API call regardless of batch size on the calling side. This actor's pricing reflects per-enriched-person charges.\n- **Seniority for non-English titles** — PDL's `job_title_levels` normalisation is less reliable for job titles in non-English languages. The seniority field may be null for some international profiles.\n- **Phone number availability** — PDL has phone data for roughly 20–30% of profiles. The `phone` and `mobilePhone` fields will be null for the majority of enriched contacts.\n\n## Integrations\n\n- [Zapier](https://apify.com/integrations/zapier) — trigger enrichment runs from a Zap when new contacts are added to a spreadsheet or CRM, then route enriched records to any Zapier-connected app\n- [Make](https://apify.com/integrations/make) — build enrichment automations that run on a schedule or in response to form submissions, webhooks, or CRM events\n- [Google Sheets](https://apify.com/integrations/google-sheets) — export enriched contact lists directly to a Sheet for team review or import into marketing tools\n- [Apify API](https://docs.apify.com/api/v2) — trigger runs programmatically from your own backend, CRM integration scripts, or data pipeline\n- [Webhooks](https://docs.apify.com/platform/integrations/webhooks) — post enriched dataset results to your CRM or data warehouse endpoint immediately when a run completes\n- [LangChain / LlamaIndex](https://docs.apify.com/platform/integrations) — use enriched person data as structured context for AI sales agents, automated outreach writers, or research assistants\n\n## Troubleshooting\n\n- **High `not_found` rate despite correct names** — verify your company names match how PDL stores them. \"Meta\" versus \"Facebook\" or \"Alphabet\" versus \"Google\" can cause misses on the Search fallback path. Try adding the domain (`domain: \"meta.com\"`) alongside the company name to give PDL a domain-based match path.\n\n- **`matchConfidence` is low (2–4) on returned records** — low scores typically come from the PDL Search fallback path. These are fuzzy matches on name + employer. Always filter output by `matchConfidence >= 5` before importing to CRM or sending outreach. Low-confidence records should be manually verified.\n\n- **Run stops early with \"PDL API credits exhausted\" in the status** — this means the built-in API key has hit its monthly limit. Either wait for the calendar month to reset, or add your own PDL API key in the `pdlApiKey` field. PDL's free tier provides 100 API calls/month per account at `peopledatalabs.com`.\n\n- **Run stops with \"Spending limit reached\"** — your Apify run budget cap was hit. This is by design. Increase your spending limit in the actor run settings, or process a smaller batch. All records enriched before the limit was reached are saved in the dataset.\n\n- **Email field is null but personalEmail has a value** — PDL has a personal email on file but no verified work email. You can use [Email Pattern Finder](https://apify.com/ryanclinton/email-pattern-finder) on the person's company domain to derive the likely work email format.\n\n## Responsible use\n\n- This actor only queries the People Data Labs API using data you provide. It does not scrape any websites or access private systems.\n- PDL's data is aggregated from public professional sources including LinkedIn, company websites, and professional directories.\n- Comply with GDPR, CAN-SPAM, CASL, and other applicable data protection laws when using enriched contact data for outreach.\n- Do not use enriched data for spam, harassment, profiling without lawful basis, or any purpose that violates the terms of service of the platforms from which the underlying data originates.\n- For guidance on the legal framework around B2B contact data, see [Apify's guide on web scraping legality](https://blog.apify.com/is-web-scraping-legal/).\n\n## FAQ\n\n**How many people can I enrich in one run with Person Enrichment Lookup?**\nUp to 1,000 persons per run. Set `maxPersons` in your input to control the limit. For lists larger than 1,000, split into multiple runs or use the Apify API to queue batches sequentially.\n\n**What identifiers does person enrichment require?**\nEach person needs at minimum one of: email address, LinkedIn URL, or full name. For name-only records, at least one of `company` or `domain` is required to enable the Search fallback. More identifiers produce higher `matchConfidence` scores.\n\n**How accurate is person enrichment from PDL?**\nPDL's direct Enrich endpoint (email or LinkedIn-based lookups) consistently returns `matchConfidence` of 8–10. Name + company searches via the fallback path return 5–7. Match rates vary by region: 75–85% for US/UK enterprise contacts, 40–65% for other markets. The `matchConfidence` score on every record tells you how much to trust each match.\n\n**How long does a typical person enrichment run take?**\nThe actor enforces a 200ms delay between PDL API calls to respect rate limits. A batch of 100 people completes in approximately 30 seconds. A batch of 1,000 completes in 4–8 minutes depending on how many Search fallback calls are needed.\n\n**Does person enrichment work for people outside the United States?**\nYes, PDL has global coverage, but match rates are higher for professionals in markets with strong LinkedIn adoption (US, UK, Canada, Australia, Western Europe). Match rates are lower for professionals in markets where LinkedIn is less dominant.\n\n**How is Person Enrichment Lookup different from Clay?**\nBoth use the People Data Labs API as a data source. Clay charges $0.22–$5.63 per PDL enrichment depending on your subscription tier, plus a monthly platform fee. This actor charges $0.15 per successfully enriched person with no subscription. You only pay for matches, not misses. The actor also provides a Search fallback path that Clay's PDL action does not expose, improving match rates for name + company inputs.\n\n**How is this different from Apollo or ZoomInfo?**\nApollo ($99–$249/month) and ZoomInfo ($15,000+/year) are full sales intelligence platforms. This actor is a single-purpose enrichment tool that uses PDL's independent B2B database. It costs less per lookup, requires no subscription, and outputs clean structured JSON that maps directly into any CRM or data pipeline. It does not replace the prospecting search features of Apollo or ZoomInfo.\n\n**Can I use my own People Data Labs API key?**\nYes. Add your PDL API key to the `pdlApiKey` field. The built-in key is shared and subject to PDL's free-tier rate limits. Bringing your own key removes that ceiling. PDL's free tier provides 100 API calls/month; paid plans start at $98/month.\n\n**Is person enrichment legal?**\nPDL aggregates data from public professional sources. Querying PDL's API to enrich business contact data for legitimate B2B purposes is generally lawful. You must comply with applicable data protection regulations (GDPR, CAN-SPAM, CASL) in how you use the enriched data. For detailed guidance see [Apify's legal guide](https://blog.apify.com/is-web-scraping-legal/).\n\n**What happens if a person is not found in PDL?**\nThe actor records a result row with `source: \"not_found\"` and null values for all enriched fields. You are not charged for not-found records. The row is still written to the dataset so your row count matches your input list, making join-back straightforward.\n\n**Can I schedule person enrichment to run automatically?**\nYes. Use Apify's built-in scheduler to run enrichment on a daily, weekly, or custom cron schedule. This is useful for refreshing CRM contact data on a recurring basis to catch job changes.\n\n**Can I combine person enrichment with other actors in an automated pipeline?**\nYes. The most common pipeline is: scrape company websites with [Website Contact Scraper](https://apify.com/ryanclinton/website-contact-scraper) → enrich contacts here → verify emails with [Bulk Email Verifier](https://apify.com/ryanclinton/bulk-email-verifier) → push to HubSpot with [HubSpot Lead Pusher](https://apify.com/ryanclinton/hubspot-lead-pusher). You can connect these steps via Apify webhooks or Make/Zapier automations.\n\n**How does the composite confidence score work?**\nThe `confidenceScore` (0–100) is a weighted combination of: PDL likelihood (60% weight, mapped from 0–10 to 0–60 points) + email present (10 points) + MX valid (15 points) + identifier richness (up to 5 points based on how many distinct identifiers the input had) − disposable email penalty (30 points) − role-account penalty (10 points). The `confidenceLevel` band is derived from the score: `high` ≥75, `medium` ≥50, `low` <50. The `confidenceBreakdown` object on every record shows exactly which factors contributed how many points so you can audit the score yourself. Use `minConfidenceScore: 50` to filter out junk records before saving.\n\n**How does cross-run change detection work?**\nSet `compareToPrevRun: true` and the actor opens a named Apify Key-Value store (default `person-enrichment-monitor`). It loads the prior run's snapshot map, enriches each person, looks them up by stable identity key (PDL ID first, falling back to name+domain), diffs the new record against the prior snapshot, and emits a `changeFlags[]` array with stable enum values: `JOB_CHANGED`, `TITLE_CHANGED`, `PROMOTION`, `DEMOTION`, `EMAIL_CHANGED`, `LOCATION_CHANGED`, `NEW_PERSON`, `UNCHANGED`, etc. The full updated snapshot map is persisted back to KV at the end of the run. First run on a new state key produces `NEW_PERSON` flags on every record (no prior to compare against). Schedule weekly to convert the actor from one-shot enrichment into a job-change monitor.\n\n**What happens when the actor encounters an alias-rebranded company like Facebook?**\nWith `useCompanyAliases: true` (default on), if PDL Search returns 0 hits for the literal company name (e.g. \"Facebook\"), the actor automatically retries with each known alias for that company (\"Meta Platforms\", \"Meta\", \"Facebook Inc\"). When an alias hits, the record's `source` field is set to `pdl_search_alias` so you can filter on aliased matches if needed. Roughly 70 alias groups are baked in, covering most Fortune 500 rebrands (Alphabet↔Google, X↔Twitter, Block↔Square, HPE / HP / Hewlett-Packard, etc.).\n\n**How do I avoid being charged for low-confidence or unchanged records?**\nBoth filters apply BEFORE pushData and BEFORE PPE charging, so filtered records cost nothing. For confidence: set `minConfidenceScore: 50` (or higher for cold outreach). For change-only monitoring: set `compareToPrevRun: true` plus `changeFlagsFilter: [\"JOB_CHANGED\", \"PROMOTION\", \"EMAIL_CHANGED\"]`. Records that fail the filter are dropped from the dataset entirely and never trigger a PPE charge — perfect for weekly scheduled runs where most contacts haven't moved.\n\n## Help us improve\n\nIf you encounter issues, you can help us debug faster by enabling run sharing in your Apify account:\n\n1. Go to [Account Settings > Privacy](https://console.apify.com/account/privacy)\n2. Enable **Share runs with public Actor creators**\n\nThis lets us see your run details when something goes wrong, so we can fix issues faster. Your data is only visible to the actor developer, not publicly.\n\n## Support\n\nFound a bug or have a feature request? Open an issue in the [Issues tab](https://console.apify.com/actors/ryanclinton~person-enrichment-lookup/issues) on this actor's page. For custom solutions or enterprise integrations, reach out through the Apify platform.\n",
      "changelog": "# Changelog\n## 1.4 (2026-05-30) — Clearer missing-key message and reliability fixes\n\n- Clearer error when no PDL API key is configured and none is provided\n\n## 1.3 (2026-05-23) — Fix: rare hang when monitor state store is unavailable\n\n\n## 1.3 (2026-05-01) — Restricted to paid Apify plans, improved API call efficiency\n\n\n## 1.2 (2026-05-01) — Added match strategies, decision layer, change monitoring, and quality validation\n",
      "storages": {
        "dataset": {
          "actorSpecification": 1,
          "fields": {
            "type": "object",
            "properties": {
              "recordType": {
                "type": "string",
                "title": "Record type",
                "description": "Discriminator: 'person' for enrichment results, 'error' for run-level error records. Filter downstream with WHERE recordType = 'person'."
              },
              "inputName": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Input name",
                "description": "Name field from the input row, echoed for spreadsheet join-back."
              },
              "inputEmail": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Input email",
                "description": "Email field from the input row, echoed for join-back."
              },
              "inputCompany": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Input company",
                "description": "Company field from the input row, echoed for join-back."
              },
              "inputDomain": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Input domain",
                "description": "Domain field from the input row, echoed for join-back."
              },
              "fullName": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Full name",
                "description": "Canonical full name from People Data Labs."
              },
              "firstName": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "First name",
                "description": "First name from PDL."
              },
              "lastName": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Last name",
                "description": "Last name from PDL."
              },
              "email": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Work email",
                "description": "Verified work / professional email from PDL."
              },
              "personalEmail": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Personal email",
                "description": "Personal email from PDL when separately listed."
              },
              "phone": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Phone",
                "description": "Primary phone number from PDL."
              },
              "mobilePhone": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Mobile phone",
                "description": "Mobile phone from PDL when separately listed."
              },
              "jobTitle": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Job title",
                "description": "Current job title from PDL."
              },
              "jobTitleRole": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Job role",
                "description": "Broad role category (engineering, sales, marketing)."
              },
              "seniority": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Seniority",
                "description": "Normalised seniority: c_suite > vp > director > manager > senior > entry > training."
              },
              "department": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Department",
                "description": "Department / sub-role (product marketing, software, etc.)."
              },
              "companyName": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Company name",
                "description": "Current employer name from PDL."
              },
              "companyDomain": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Company domain",
                "description": "Current employer website domain."
              },
              "companyIndustry": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Company industry",
                "description": "Industry classification from PDL."
              },
              "companySize": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Company size",
                "description": "Employee count band (e.g. 501-1000)."
              },
              "companyLinkedinUrl": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Company LinkedIn URL",
                "description": "Company LinkedIn page URL."
              },
              "linkedinUrl": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "LinkedIn URL",
                "description": "Person's LinkedIn profile URL."
              },
              "twitterUrl": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Twitter / X URL",
                "description": "Twitter / X profile URL."
              },
              "githubUrl": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "GitHub URL",
                "description": "GitHub profile URL."
              },
              "facebookUrl": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Facebook URL",
                "description": "Facebook profile URL."
              },
              "location": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Location",
                "description": "Full location string from PDL."
              },
              "city": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "City",
                "description": "City."
              },
              "state": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "State / region",
                "description": "State or region."
              },
              "country": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Country",
                "description": "Country."
              },
              "workHistory": {
                "title": "Work history",
                "description": "Past jobs (enabled via includeWorkHistory). Each entry: { company, title, startDate, endDate }."
              },
              "education": {
                "title": "Education",
                "description": "Education records (enabled via includeEducation). Each entry: { school, degree, field }."
              },
              "skills": {
                "title": "Skills",
                "description": "Skills list (enabled via includeSkills)."
              },
              "emailMxValid": {
                "type": [
                  "boolean",
                  "null"
                ],
                "title": "Email MX valid",
                "description": "True when work email's domain has at least one MX record (mail server configured). Null when validateEmails is off or no work email."
              },
              "emailIsDisposable": {
                "type": [
                  "boolean",
                  "null"
                ],
                "title": "Email is disposable",
                "description": "True when work email matches a known temp-mail / burner provider. Null when validateEmails is off."
              },
              "emailIsRoleAccount": {
                "type": [
                  "boolean",
                  "null"
                ],
                "title": "Email is role account",
                "description": "True when work email's local-part is a generic role (info@, sales@, support@). These addresses are bad cold-outreach targets."
              },
              "confidenceScore": {
                "type": [
                  "integer",
                  "null"
                ],
                "title": "Confidence score (0-100)",
                "description": "Composite score: PDL likelihood (60%) + email present (10%) + MX valid (15%) + identifier richness (5%) − disposable (-30%) − role account (-10%). Sortable, comparable across runs."
              },
              "confidenceLevel": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Confidence level",
                "description": "Band derived from confidenceScore: 'high' (>=75), 'medium' (>=50), 'low' (<50). Spreadsheet-ready filter."
              },
              "confidenceBreakdown": {
                "title": "Confidence breakdown",
                "description": "Per-factor point breakdown so users see exactly why the score is what it is."
              },
              "isContactable": {
                "type": "boolean",
                "title": "Is contactable",
                "description": "True when the record has a reachable signal (email/phone/LinkedIn) AND confidenceScore >= 50 AND not disposable. One-tick filter for outreach lists."
              },
              "changeFlags": {
                "title": "Change flags",
                "description": "Cross-run change codes (when compareToPrevRun is on). Possible values: NEW_PERSON, JOB_CHANGED, TITLE_CHANGED, PROMOTION, DEMOTION, EMAIL_CHANGED, EMAIL_GAINED, EMAIL_LOST, COMPANY_DOMAIN_CHANGED, LOCATION_CHANGED, NEW_LINKEDIN, UNCHANGED."
              },
              "changeSinceLastRun": {
                "title": "Change since last run",
                "description": "Diff against prior snapshot: previousJobTitle, previousCompany, previousSeniority, previousEmail, seniorityDirection (up/down/flat), daysSinceLastSeen."
              },
              "triggerEvents": {
                "title": "Trigger events",
                "description": "Higher-priority view of changeFlags — each event carries type, priority (high/medium/low), recommendedAction, and a Slack-ready summary string. Sorted high-priority first. Null when no monitor change fired."
              },
              "matchDebug": {
                "title": "Match debug (transparency)",
                "description": "Strategy used, input signals queried, candidate count, selected-winner reason, and (when includeCandidates) the alternate matches PDL Search returned. Alternates are NOT charged and NOT separate dataset rows."
              },
              "duplicateGroupId": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Duplicate group ID",
                "description": "When dedupeMode is on and this row had duplicates in the input, all members of a group share the same ID. Null when dedupe is off or the row was unique."
              },
              "isPrimaryRecord": {
                "type": "boolean",
                "title": "Is primary record",
                "description": "Always true on the row that was actually enriched (dedup keeps the first member of each group)."
              },
              "fromCachedSnapshot": {
                "type": "boolean",
                "title": "From cached snapshot",
                "description": "True when skipPreviouslyEnriched was on and this row was echoed from a fresh KV snapshot instead of a fresh PDL call. Cached rows are NOT charged."
              },
              "failureType": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Failure type",
                "description": "Why this record was not enriched. One of: 'no-identifier' (input row missing name/email/linkedin), 'pdl-not-found' (PDL had no record), 'pdl-credits-exhausted', 'pdl-rate-limited', 'pdl-api-error', 'unexpected-error'. Null on successful records."
              },
              "recommendation": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Recommendation",
                "description": "Concrete next step when enrichment failed (e.g. 'Try Email Pattern Finder for a probable address from the company domain'). Null on successful records."
              },
              "recommendedAction": {
                "type": "string",
                "title": "Recommended action",
                "description": "Decision Layer enum: SEND_TO_OUTREACH (high confidence + valid email), VERIFY_EMAIL (moderate confidence, deliverability uncertain), RESEARCH_MANUALLY (no email but LinkedIn or PDL had nothing), ENRICH_AGAIN (transient PDL failure — re-run), DROP (low confidence or disposable email). Deterministic — same record always produces the same action."
              },
              "actionPriority": {
                "type": "string",
                "title": "Action priority",
                "description": "Slack/queue routing priority: 'high' for SEND_TO_OUTREACH, 'medium' for VERIFY_EMAIL / RESEARCH_MANUALLY, 'low' for ENRICH_AGAIN / DROP."
              },
              "actionReason": {
                "type": "string",
                "title": "Action reason",
                "description": "One sentence explaining why this recommendedAction was chosen — usable directly in CRM tasks, Slack messages, and AI agent prompts."
              },
              "confidenceReason": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "Confidence reason",
                "description": "Plain-English summary of the composite confidence score (e.g. 'Strong match: exact PDL match (likelihood 9/10) + work email with valid MX'). Null on not-found records."
              },
              "confidenceRisks": {
                "title": "Confidence risks",
                "description": "Stable enum tags downstream automation can branch on: weak_pdl_likelihood / mx_invalid / no_work_email / disposable_email / role_account / thin_input_identifiers / common_name / email_not_validated. Empty array on records with no risks."
              },
              "identityStrength": {
                "type": [
                  "integer",
                  "null"
                ],
                "title": "Identity strength (0-100)",
                "description": "Quality of the INPUT identifiers (vs confidenceScore = quality of the MATCH). email=50, linkedinUrl=40, name+domain=30, name+company=20, name=10, with +10 per extra distinct signal capped at 100. Use to debug poor-quality input lists."
              },
              "identitySignals": {
                "title": "Identity signals",
                "description": "Names of identifier signals the input row provided (e.g. ['email', 'linkedinUrl'])."
              },
              "notFoundAnalysis": {
                "title": "Not-found analysis",
                "description": "Why-Not-Found intelligence on not-found rows: { likelyCause: insufficient_identifiers / company_mismatch / name_common / no_pdl_coverage / pdl_search_below_threshold, causeConfidence (0-1), suggestion (concrete next step) }. Null on successfully-enriched rows."
              },
              "pdlId": {
                "type": [
                  "string",
                  "null"
                ],
                "title": "PDL ID",
                "description": "PDL's stable internal person identifier — useful for dedup across runs."
              },
              "matchConfidence": {
                "type": [
                  "integer",
                  "null"
                ],
                "title": "PDL match confidence",
                "description": "PDL's raw likelihood score 0-10 (10 = exact identifier match). Used as primary input to the composite confidenceScore."
              },
              "enrichedAt": {
                "type": "string",
                "title": "Enriched at",
                "description": "ISO 8601 timestamp of enrichment."
              },
              "source": {
                "type": "string",
                "title": "Source",
                "description": "How this record was matched: 'pdl_enrich' (direct lookup, highest confidence), 'pdl_search' (Elasticsearch fallback), 'pdl_search_alias' (alias retry — Facebook→Meta), 'not_found'."
              }
            }
          },
          "views": {
            "overview": {
              "title": "Overview",
              "description": "Decision-first view — recommendedAction leads, then identity, then confidence. The default first impression.",
              "transformation": {
                "fields": [
                  "recommendedAction",
                  "actionPriority",
                  "fullName",
                  "jobTitle",
                  "seniority",
                  "companyName",
                  "email",
                  "linkedinUrl",
                  "confidenceScore",
                  "confidenceLevel",
                  "isContactable",
                  "actionReason",
                  "source"
                ]
              },
              "display": {
                "component": "table",
                "properties": {
                  "recommendedAction": {
                    "label": "Action"
                  },
                  "actionPriority": {
                    "label": "Priority"
                  },
                  "fullName": {
                    "label": "Name"
                  },
                  "jobTitle": {
                    "label": "Title"
                  },
                  "seniority": {
                    "label": "Seniority"
                  },
                  "companyName": {
                    "label": "Company"
                  },
                  "email": {
                    "label": "Work email"
                  },
                  "linkedinUrl": {
                    "label": "LinkedIn"
                  },
                  "confidenceScore": {
                    "label": "Score"
                  },
                  "confidenceLevel": {
                    "label": "Level"
                  },
                  "isContactable": {
                    "label": "Contactable"
                  },
                  "actionReason": {
                    "label": "Why this action"
                  },
                  "source": {
                    "label": "Source"
                  }
                }
              }
            },
            "decisions": {
              "title": "Decisions",
              "description": "Decision Layer + confidence narrative + identity strength. Pure decision-system view for routing automation.",
              "transformation": {
                "fields": [
                  "fullName",
                  "recommendedAction",
                  "actionPriority",
                  "actionReason",
                  "confidenceScore",
                  "confidenceLevel",
                  "confidenceReason",
                  "confidenceRisks",
                  "identityStrength",
                  "identitySignals",
                  "notFoundAnalysis"
                ]
              },
              "display": {
                "component": "table",
                "properties": {
                  "fullName": {
                    "label": "Name"
                  },
                  "recommendedAction": {
                    "label": "Action"
                  },
                  "actionPriority": {
                    "label": "Priority"
                  },
                  "actionReason": {
                    "label": "Why"
                  },
                  "confidenceScore": {
                    "label": "Score"
                  },
                  "confidenceLevel": {
                    "label": "Level"
                  },
                  "confidenceReason": {
                    "label": "Confidence reason"
                  },
                  "confidenceRisks": {
                    "label": "Risks"
                  },
                  "identityStrength": {
                    "label": "Input quality"
                  },
                  "identitySignals": {
                    "label": "Signals"
                  },
                  "notFoundAnalysis": {
                    "label": "Why not found"
                  }
                }
              }
            },
            "contactable": {
              "title": "Contactable leads",
              "description": "High-confidence reachable contacts only — work email, personal email, phone, LinkedIn URL. Built for direct CSV → outreach tool import.",
              "transformation": {
                "fields": [
                  "fullName",
                  "jobTitle",
                  "seniority",
                  "companyName",
                  "companyDomain",
                  "email",
                  "personalEmail",
                  "phone",
                  "mobilePhone",
                  "linkedinUrl",
                  "city",
                  "country",
                  "isContactable",
                  "confidenceScore",
                  "emailMxValid",
                  "emailIsDisposable",
                  "emailIsRoleAccount"
                ]
              },
              "display": {
                "component": "table",
                "properties": {
                  "fullName": {
                    "label": "Name"
                  },
                  "jobTitle": {
                    "label": "Title"
                  },
                  "seniority": {
                    "label": "Seniority"
                  },
                  "companyName": {
                    "label": "Company"
                  },
                  "companyDomain": {
                    "label": "Company domain"
                  },
                  "email": {
                    "label": "Work email"
                  },
                  "personalEmail": {
                    "label": "Personal email"
                  },
                  "phone": {
                    "label": "Phone"
                  },
                  "mobilePhone": {
                    "label": "Mobile"
                  },
                  "linkedinUrl": {
                    "label": "LinkedIn"
                  },
                  "city": {
                    "label": "City"
                  },
                  "country": {
                    "label": "Country"
                  },
                  "isContactable": {
                    "label": "Contactable"
                  },
                  "confidenceScore": {
                    "label": "Score"
                  },
                  "emailMxValid": {
                    "label": "MX valid"
                  },
                  "emailIsDisposable": {
                    "label": "Disposable"
                  },
                  "emailIsRoleAccount": {
                    "label": "Role acct"
                  }
                }
              }
            },
            "changes": {
              "title": "Changes since last run",
              "description": "Cross-run delta view (compareToPrevRun = true). Shows job changes, promotions, email changes, location changes — perfect for re-engagement campaigns.",
              "transformation": {
                "fields": [
                  "fullName",
                  "changeFlags",
                  "jobTitle",
                  "companyName",
                  "seniority",
                  "email",
                  "changeSinceLastRun",
                  "linkedinUrl",
                  "confidenceScore"
                ]
              },
              "display": {
                "component": "table",
                "properties": {
                  "fullName": {
                    "label": "Name"
                  },
                  "changeFlags": {
                    "label": "Changes"
                  },
                  "jobTitle": {
                    "label": "Title now"
                  },
                  "companyName": {
                    "label": "Company now"
                  },
                  "seniority": {
                    "label": "Seniority now"
                  },
                  "email": {
                    "label": "Work email now"
                  },
                  "changeSinceLastRun": {
                    "label": "Prior values"
                  },
                  "linkedinUrl": {
                    "label": "LinkedIn"
                  },
                  "confidenceScore": {
                    "label": "Score"
                  }
                }
              }
            }
          }
        }
      },
      "minMemoryMbytes": 256,
      "maxMemoryMbytes": 512,
      "defaultRunOptions": {
        "build": "latest",
        "timeoutSecs": 3600,
        "memoryMbytes": 256
      },
      "pricingInfos": [
        {
          "pricingModel": "PAY_PER_EVENT",
          "pricingPerEvent": {
            "actorChargeEvents": {
              "person-enriched": {
                "eventTitle": "Person Enriched",
                "eventDescription": "Successfully enriched one person with job, company, and social profile data from People Data Labs.",
                "eventPriceUsd": 0.15
              }
            }
          }
        }
      ]
    },
    "buildNumber": "1.4.7",
    "actVersion": {
      "sourceType": "SOURCE_FILES",
      "buildTag": "latest",
      "versionNumber": "1.4"
    },
    "usage": {
      "ACTOR_COMPUTE_UNITS": 0.008705555555555555
    }
  }
}