---
# === IDENTITY ===
id: signal-library/retail/enrichment-mapping/2026
canonical_question: "How do you resolve retail signals to specific companies and identify decision-makers?"
aliases:
  - "retail signal enrichment"
  - "retail company resolution"
  - "retail decision-maker identification"
  - "retail firmographic enrichment"
entity_type: execution_recipe
domain: signal-library > retail > enrichment mapping
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === VERIFICATION ===
last_verified: 2026-03-30
confidence: 0.85
version: 1.0
first_published: 2026-03-30

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: null
  next_review: 2026-09-26
  change_sensitivity: high

# === CONSTRAINTS ===
constraints:
  - "GDPR applies to all EU retailer contacts — personal email scraping without legitimate interest basis is prohibited; B2B work emails are generally permissible under legitimate interest but require documented justification [src1]"
  - "CAN-SPAM compliance required for US outreach — all emails must include physical address, unsubscribe mechanism, and accurate subject lines; violations carry $51,744 per email penalty [src2]"
  - "Do not target contacts below Director level — retail decision-making authority for technology/operations purchases sits at VP+ level; targeting below Director wastes outreach credits and damages sender reputation [src3]"
  - "Contact data must be refreshed every 90 days — retail industry has 18-22% annual turnover at VP+ level, and job title changes happen without public announcement in 30-40% of cases [src4]"
  - "Apollo.io free tier limits to 50 credits/month — sufficient for proof-of-concept but requires paid tier ($49+/month) for production enrichment of 50+ companies per batch [src5]"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs the signal detection rules and scoring thresholds, not enrichment of already-detected signals"
    use_instead: "signal-library/retail/detection-rules/2026"
  - condition: "User needs the overall retail signal library framework and taxonomy"
    use_instead: "signal-library/retail/overview/2026"
  - condition: "User needs to configure signal sources (SEC filings, job boards, DNS monitoring) rather than enrich detected signals"
    use_instead: "signal-library/retail-sources/sec-financial-filings/2026"
  - condition: "Scale exceeds 500 companies per batch — enterprise enrichment tooling (ZoomInfo, 6sense) required"
    use_instead: "Enterprise enrichment platforms with bulk API access"

# === AGENT HINTS ===
inputs_needed:
  - key: signal_type
    question: "Which signal type triggered the enrichment?"
    type: choice
    options: ["inventory/supply chain distress", "digital transformation gap", "AI readiness failure", "store operations distress", "mixed/compound signal"]
  - key: target_region
    question: "What region are the target retailers in?"
    type: choice
    options: ["US only", "EU only", "US + EU", "global"]
  - key: enrichment_budget
    question: "What is the monthly enrichment tool budget?"
    type: choice
    options: ["free tier only", "up to $200/month", "up to $500/month", "enterprise"]

# === EXECUTION METADATA ===
execution:
  required_inputs:
    - name: "Detected signal report"
      source: "signal-library/retail/detection-rules/2026"
      format: "structured data"
    - name: "Company identifiers (domain, name, filing entity)"
      source: "signal source cards"
      format: "structured data"
  outputs:
    - name: "Enriched company profiles"
      format: "JSON/CSV"
      description: "Firmographic data + decision-maker contacts for scored companies"
    - name: "Decision-maker contact list"
      format: "CSV"
      description: "Verified contacts with role, email, LinkedIn, signal relevance"
  tools_required:
    - name: "Apollo.io"
      purpose: "Firmographic enrichment and people search"
      tier: "paid"
      cost: "$49-$99/month"
      alternatives: ["Clearbit", "ZoomInfo"]
    - name: "Hunter.io"
      purpose: "Email verification"
      tier: "freemium"
      cost: "$0-$49/month"
      alternatives: ["NeverBounce", "ZeroBounce"]
    - name: "LinkedIn Sales Navigator"
      purpose: "Decision-maker identification"
      tier: "paid"
      cost: "$99/month"
      alternatives: ["Apollo.io people search"]
    - name: "Dropcontact"
      purpose: "GDPR-compliant EU contact enrichment"
      tier: "paid"
      cost: "$24-$96/month"
      alternatives: ["Kaspr", "Lusha (with GDPR mode)"]
  credentials_needed:
    - service: "Apollo.io"
      type: "API key"
      where_to_get: "https://www.apollo.io"
      free_tier_limits: "50 credits/month"
    - service: "Hunter.io"
      type: "API key"
      where_to_get: "https://hunter.io"
      free_tier_limits: "25 searches + 50 verifications/month"
  estimated_duration: "30-60 minutes per 50 companies"
  estimated_cost: "$50-$200/month for tools"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/signal-library/retail/enrichment-mapping/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-03-30)"

# === RELATED UNITS ===
related_kos:
  depends_on:
    - id: "signal-library/retail/detection-rules/2026"
      label: "Detection rules that produce the signal report this recipe enriches"
    - id: "signal-library/retail/overview/2026"
      label: "Retail signal taxonomy and targeting framework"
  feeds_into:
    - id: "business/lead-generation/outreach-sequence-loading/2026"
      label: "Loading and launching outreach sequences in Instantly, Lemlist, Apollo or HubSpot — list import, A/B variants, warmup and sending limits, reply tracking (generic B2B tooling, not retail-signal messaging)"
  related_to:
    - id: "signal-library/retail-sources/sec-financial-filings/2026"
      label: "SEC filing source for company resolution"
    - id: "signal-library/retail-sources/customer-review-sentiment/2026"
      label: "Review sentiment source for signal context"
  alternative_to: []

# === SOURCES ===
sources:
  - id: src1
    title: "GDPR and B2B Marketing: Legitimate Interest for Direct Marketing"
    author: European Data Protection Board
    url: https://edpb.europa.eu/our-work-tools/general-guidance/legitimate-interest_en
    type: official_docs
    published: 2024-06-15
    reliability: authoritative
  - id: src2
    title: "CAN-SPAM Act: A Compliance Guide for Business"
    author: Federal Trade Commission
    url: https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business
    type: official_docs
    published: 2024-01-01
    reliability: authoritative
  - id: src3
    title: "The B2B Buying Committee: Who Makes Technology Purchase Decisions"
    author: Gartner
    url: https://www.gartner.com/en/sales/insights/b2b-buying-journey
    type: industry_report
    published: 2025-03-01
    reliability: authoritative
  - id: src4
    title: "Executive Turnover Rates in Retail Industry 2024-2025"
    author: Korn Ferry
    url: https://www.kornferry.com/insights/briefings-for-the-boardroom
    type: industry_report
    published: 2025-06-01
    reliability: high
  - id: src5
    title: "Apollo.io API Documentation and Rate Limits"
    author: Apollo.io
    url: https://apolloio.github.io/apollo-api-docs/
    type: official_docs
    published: 2025-12-01
    reliability: authoritative
  - id: src6
    title: "Hunter.io Email Verification API Documentation"
    author: Hunter.io
    url: https://hunter.io/api-documentation
    type: official_docs
    published: 2025-09-01
    reliability: authoritative
  - id: src7
    title: "LinkedIn Sales Navigator Best Practices for Account-Based Selling"
    author: LinkedIn
    url: https://business.linkedin.com/sales-solutions/sales-navigator
    type: official_docs
    published: 2025-11-01
    reliability: authoritative
---

# Retail Signal Enrichment Mapping

## Purpose

This recipe transforms raw retail distress signals (inventory anomalies, job posting patterns, SEC filing keywords, satellite data) into enriched company profiles with verified decision-maker contacts. The output is a structured database mapping each detected signal to a specific company, its firmographic profile, and the 2-3 people most likely to authorize a purchase — ready for outreach sequence loading. [src3, src5]

## Prerequisites
<!-- Agents: verify ALL prerequisites before executing. Missing prerequisites = failed execution. -->

- [ ] **Detected signal report** available from `signal-library/retail/detection-rules/2026` — [Retail Detection Rules](/signal-library/retail/detection-rules/2026)
- [ ] **Company identifiers** (domain, name, or filing entity) extracted from signal sources — [SEC Financial Filings](/signal-library/retail-sources/sec-financial-filings/2026)
- [ ] **Apollo.io API key** — API key from [Apollo.io](https://www.apollo.io) (free tier: 50 credits/month)
- [ ] **Hunter.io API key** — API key from [Hunter.io](https://hunter.io) (free tier: 25 searches + 50 verifications/month)
- [ ] **LinkedIn Sales Navigator account** (optional) — from [LinkedIn Sales Solutions](https://business.linkedin.com/sales-solutions) ($99/month)
- [ ] **Dropcontact account** (required for EU targets) — from [Dropcontact](https://www.dropcontact.com) ($24/month)
- [ ] **Python 3.10+ or Node.js 18+** installed with HTTP client library (requests/axios)

## Constraints
<!-- Hard rules. Agents: enforce throughout execution. Violating these = broken output or legal risk. -->

- GDPR: Do not scrape personal emails of EU-based retail executives without documented legitimate interest basis. B2B work emails are generally permissible, but use Dropcontact (GDPR-compliant by design) for EU contacts rather than Apollo.io or Hunter.io. [src1]
- CAN-SPAM: All US outreach emails must include physical address, functioning unsubscribe mechanism, and non-deceptive subject lines. Penalties are $51,744 per non-compliant email. [src2]
- Do not target contacts below Director level. Retail technology and operations purchases require VP+ approval authority. Targeting managers and individual contributors wastes enrichment credits and damages sender reputation. [src3]
- Contact data expires after 90 days. Retail VP+ turnover is 18-22% annually, and 30-40% of role changes occur without public announcement. Schedule re-enrichment every quarter. [src4]
- Apollo.io rate limit: 100 requests/minute on paid plans, 10 requests/minute on free tier. Batch requests and add sleep intervals to avoid 429 errors. [src5]
- Hunter.io verification: Free tier allows 50 verifications/month. Budget $49/month for Hunter.io if verifying more than 50 contacts per batch. [src6]

## Tool Selection Decision

<!-- Agent selects the right tool path based on user inputs. -->

```
Which path?
├── Budget = free tier only
│   └── PATH A: Free — Apollo.io free (50 credits) + Hunter.io free (50 verifications)
├── Budget = up to $200/month AND region = US only
│   └── PATH B: Paid US — Apollo.io Basic ($49) + Hunter.io Starter ($49) + LinkedIn Sales Nav ($99)
├── Budget = up to $200/month AND region includes EU
│   └── PATH C: Paid EU-Compliant — Apollo.io Basic ($49) + Dropcontact ($24) + Hunter.io Starter ($49)
└── Budget = $500+/month (enterprise)
    └── PATH D: Enterprise — Apollo.io Professional ($99) + LinkedIn Sales Nav ($99) + Dropcontact ($96) + Hunter.io Growth ($99)
```

| Path | Tools | Cost | Speed (50 companies) | Output Quality |
|------|-------|------|----------------------|---------------|
| A: Free | Apollo.io free + Hunter.io free | $0 | 2-3 hours (rate limited) | Basic — 50 credits limits depth, ~60% email verification coverage |
| B: Paid US | Apollo.io + Hunter.io + LinkedIn | $197/mo | 30-45 min | High — full firmographic + verified contacts, US-optimized |
| C: Paid EU | Apollo.io + Dropcontact + Hunter.io | $122/mo | 45-60 min | High — GDPR-compliant EU contacts, full firmographic |
| D: Enterprise | Full stack | $393/mo | 20-30 min | Excellent — multi-source verification, deepest coverage |

## Execution Flow

### Step 1: Company Resolution

**Duration**: 10-15 minutes per 50 signals
**Tool**: Python/Node.js script + signal source data

Resolve raw signal data to specific company identities. Each signal type produces different identifiers that must be mapped to a canonical company record.

```python
# company_resolution.py — Map signal identifiers to company records
import json

RESOLUTION_METHODS = {
    "job_posting": {
        "primary": "domain_match",  # Extract domain from job posting URL
        "fallback": "company_name_fuzzy_match",
        "fields": ["posting_url", "company_name", "location"]
    },
    "sec_filing": {
        "primary": "entity_extraction",  # CIK number --> company
        "fallback": "filing_entity_name_match",
        "fields": ["cik", "filing_entity", "sic_code"]
    },
    "warn_notice": {
        "primary": "company_name_match",  # Exact name from WARN filing
        "fallback": "location_geocode_to_hq",
        "fields": ["company_name", "location", "employees_affected"]
    },
    "foot_traffic": {
        "primary": "geocode_to_corporate_hq",  # Lat/lon --> nearest HQ
        "fallback": "store_brand_to_parent_company",
        "fields": ["latitude", "longitude", "brand_name"]
    },
    "satellite_data": {
        "primary": "geocode_to_corporate_hq",
        "fallback": "facility_address_to_company",
        "fields": ["latitude", "longitude", "facility_type"]
    }
}

def resolve_company(signal):
    """Resolve a signal to a company identity."""
    method = RESOLUTION_METHODS.get(signal["source_type"], {})
    resolved = {
        "company_name": None,
        "domain": None,
        "resolution_method": method.get("primary"),
        "resolution_confidence": 0.0,
        "signal_id": signal["id"]
    }

    if signal["source_type"] == "job_posting":
        # Extract domain from posting URL
        from urllib.parse import urlparse
        parsed = urlparse(signal.get("posting_url", ""))
        resolved["domain"] = parsed.netloc.replace("www.", "")
        resolved["company_name"] = signal.get("company_name")
        resolved["resolution_confidence"] = 0.95

    elif signal["source_type"] == "sec_filing":
        # CIK lookup via SEC EDGAR
        resolved["company_name"] = signal.get("filing_entity")
        resolved["cik"] = signal.get("cik")
        resolved["resolution_confidence"] = 0.99

    elif signal["source_type"] == "warn_notice":
        resolved["company_name"] = signal.get("company_name")
        resolved["resolution_confidence"] = 0.90

    return resolved
```

**Verify**: Each resolved company has a `company_name` and either a `domain` or `cik` identifier. Resolution confidence > 0.80 for all entries.
**If failed**: For signals with confidence < 0.80, run manual lookup via Apollo.io company search using the company name + location as search terms.

### Step 2: Firmographic Enrichment

**Duration**: 10-15 minutes per 50 companies
**Tool**: Apollo.io API
**Rate limit**: Free tier: 10 req/min. Paid tier: 100 req/min. Sleep 1s between requests on free tier.

Enrich resolved companies with firmographic data using Apollo.io organization enrichment endpoint.

```python
# firmographic_enrichment.py
import requests
import time

APOLLO_API_KEY = "your_apollo_api_key"
APOLLO_BASE = "https://api.apollo.io/v1"

def enrich_company(company):
    """Enrich a resolved company with firmographic data."""
    # Try domain enrichment first (highest accuracy)
    if company.get("domain"):
        resp = requests.post(
            f"{APOLLO_BASE}/organizations/enrich",
            headers={"Content-Type": "application/json"},
            json={"api_key": APOLLO_API_KEY, "domain": company["domain"]}
        )
    else:
        # Fall back to name search
        resp = requests.post(
            f"{APOLLO_BASE}/mixed_companies/search",
            headers={"Content-Type": "application/json"},
            json={
                "api_key": APOLLO_API_KEY,
                "organization_name": company["company_name"],
                "organization_num_employees_ranges": ["51,200", "201,500", "501,1000", "1001,5000", "5001,10000"]
            }
        )

    if resp.status_code == 200:
        org = resp.json().get("organization", {})
        return {
            "company_name": org.get("name"),
            "domain": org.get("primary_domain"),
            "hq_city": org.get("city"),
            "hq_state": org.get("state"),
            "hq_country": org.get("country"),
            "employee_count": org.get("estimated_num_employees"),
            "annual_revenue_estimate": org.get("annual_revenue_printed"),
            "industry": org.get("industry"),
            "keywords": org.get("keywords", []),
            "linkedin_url": org.get("linkedin_url"),
            "phone": org.get("phone"),
            "apollo_id": org.get("id")
        }
    elif resp.status_code == 429:
        time.sleep(60)  # Rate limited — wait and retry
        return enrich_company(company)
    return None
```

**Expected output**: JSON records with company_name, domain, hq_location, employee_count, annual_revenue_estimate, industry, linkedin_url for each company.
**Verify**: > 80% of companies have employee_count and annual_revenue_estimate populated. Domain field populated for > 90%.
**If failed**: If Apollo.io returns sparse data (< 60% field coverage), supplement with Clearbit Enrichment API as a secondary source.

### Step 3: Retail-Specific Fields

**Duration**: 5-10 minutes per 50 companies
**Tool**: Apollo.io technographics + manual web research

Augment firmographic data with retail-specific fields not available from standard enrichment APIs.

```python
# retail_specific_enrichment.py

RETAIL_FIELDS = {
    "store_count": None,           # Physical retail locations
    "ecommerce_presence": {
        "dtc_website": None,       # Direct-to-consumer domain
        "amazon_seller": None,     # Amazon marketplace presence
        "shopify_store": None,     # Shopify-powered storefront
        "other_marketplaces": []   # Walmart, Target+, etc.
    },
    "primary_retail_category": None,  # Apparel, grocery, electronics, etc.
    "tech_stack_signals": [],      # From job posts + BuiltWith/Wappalyzer
    "current_vendors_inferred": [] # Inferred from job posts and tech stack
}

def infer_tech_stack_from_jobs(company_domain, apollo_api_key):
    """Search recent job postings to infer tech stack and current vendors."""
    resp = requests.post(
        f"{APOLLO_BASE}/mixed_people/search",
        json={
            "api_key": apollo_api_key,
            "organization_domains": [company_domain],
            "person_titles": ["engineer", "developer", "architect", "IT"],
            "page": 1, "per_page": 10
        }
    )
    # Extract technology keywords from job titles and seniority
    people = resp.json().get("people", [])
    tech_signals = []
    for person in people:
        title = (person.get("title") or "").lower()
        if "salesforce" in title: tech_signals.append("Salesforce")
        if "sap" in title: tech_signals.append("SAP")
        if "oracle" in title: tech_signals.append("Oracle")
        if "shopify" in title: tech_signals.append("Shopify")
        if "magento" in title: tech_signals.append("Magento/Adobe Commerce")
    return list(set(tech_signals))

def estimate_store_count(company_name):
    """Estimate store count from public sources."""
    # Sources: SEC 10-K filings, company press releases,
    # retail industry databases (NRF, Chain Store Age)
    # This requires manual lookup or specialized retail data provider
    return None  # Placeholder — manual research required
```

**Verify**: At least 3 of 5 retail-specific fields populated per company (store_count, ecommerce_presence, primary_retail_category, tech_stack, current_vendors).
**If failed**: For companies with < 3 fields populated, flag for manual research. Use SEC 10-K filings (store count), SimilarWeb (ecommerce presence), and BuiltWith (tech stack) as supplementary sources.

### Step 4: Decision-Maker Identification

**Duration**: 10-15 minutes per 50 companies
**Tool**: Apollo.io people search + LinkedIn Sales Navigator
**Rate limit**: Apollo.io people search costs 1 credit per result page.

Identify the 2-3 decision-makers per company based on the signal type that triggered enrichment.

```python
# decision_maker_targeting.py

# Signal-type to decision-maker role mapping
TARGETING_MAP = {
    "inventory/supply chain distress": {
        "P1": ["VP Supply Chain", "Chief Merchandising Officer", "SVP Merchandising", "VP Inventory"],
        "P2": ["COO", "Chief Operating Officer", "SVP Operations"]
    },
    "digital transformation gap": {
        "P1": ["CTO", "VP Digital", "Head of Ecommerce", "VP Ecommerce", "Chief Digital Officer"],
        "P2": ["CMO", "Chief Marketing Officer", "VP Marketing"]
    },
    "AI readiness failure": {
        "P1": ["Chief Data Officer", "VP Analytics", "CTO", "VP Data Science", "Head of AI"],
        "P2": ["CDO", "Chief Digital Officer", "VP Engineering"]
    },
    "store operations distress": {
        "P1": ["COO", "VP Retail Operations", "SVP Store Operations", "VP Operations"],
        "P2": ["CFO", "VP Real Estate", "Chief Stores Officer"]
    },
    "mixed/compound signal": {
        "P1": ["COO", "CTO", "Chief Digital Officer"],
        "P2": ["CFO", "CEO"]
    }
}

def find_decision_makers(company, signal_type, apollo_api_key):
    """Find P1 and P2 decision-makers for a company based on signal type."""
    targets = TARGETING_MAP.get(signal_type, TARGETING_MAP["mixed/compound signal"])
    contacts = []

    for priority, titles in targets.items():
        resp = requests.post(
            f"{APOLLO_BASE}/mixed_people/search",
            json={
                "api_key": apollo_api_key,
                "organization_domains": [company["domain"]],
                "person_titles": titles,
                "person_seniorities": ["vp", "c_suite", "director"],
                "page": 1,
                "per_page": 5
            }
        )
        people = resp.json().get("people", [])
        for person in people:
            contacts.append({
                "full_name": person.get("name"),
                "title": person.get("title"),
                "email": person.get("email"),
                "linkedin_url": person.get("linkedin_url"),
                "priority": priority,
                "signal_relevance": signal_type,
                "company_domain": company["domain"],
                "company_name": company["company_name"]
            })

    return contacts
```

**Expected output**: 2-3 contacts per company with name, title, email (if available), LinkedIn URL, and priority tier (P1/P2).
**Verify**: At least 1 P1 contact identified for > 75% of companies. All contacts are Director-level or above.
**If failed**: If Apollo.io returns < 1 P1 contact for a company, use LinkedIn Sales Navigator manual search: filter by company + seniority (VP+) + function (matching signal type). Add results manually.

### Step 5: Contact Verification

**Duration**: 5-10 minutes per 50 contacts
**Tool**: Hunter.io (US contacts) + Dropcontact (EU contacts)
**Rate limit**: Hunter.io: 10 verifications/second on paid plans.

Verify all email addresses before adding to final output. Unverified emails damage sender reputation.

```python
# contact_verification.py
import requests

HUNTER_API_KEY = "your_hunter_api_key"

def verify_email_hunter(email):
    """Verify email via Hunter.io — use for US contacts."""
    resp = requests.get(
        "https://api.hunter.io/v2/email-verifier",
        params={"email": email, "api_key": HUNTER_API_KEY}
    )
    if resp.status_code == 200:
        data = resp.json().get("data", {})
        return {
            "email": email,
            "status": data.get("status"),      # valid, invalid, accept_all, unknown
            "score": data.get("score"),         # 0-100 confidence
            "verified": data.get("status") == "valid",
            "disposable": data.get("disposable", False),
            "webmail": data.get("webmail", False)
        }
    return {"email": email, "status": "error", "verified": False}

def filter_verified_contacts(contacts, min_score=85):
    """Remove contacts with unverified or low-confidence emails."""
    verified = []
    for contact in contacts:
        if not contact.get("email"):
            contact["email_status"] = "missing"
            verified.append(contact)  # Keep — can reach via LinkedIn
            continue

        result = verify_email_hunter(contact["email"])
        contact["email_status"] = result["status"]
        contact["email_score"] = result.get("score", 0)

        if result.get("score", 0) >= min_score:
            contact["email_verified"] = True
            verified.append(contact)
        elif contact.get("linkedin_url"):
            contact["email_verified"] = False
            contact["fallback_channel"] = "linkedin"
            verified.append(contact)
        # Drop contacts with bad email AND no LinkedIn

    return verified
```

**Verify**: Email verification rate > 75% (verified emails / total emails attempted). No disposable or webmail addresses in final list.
**If failed**: If verification rate < 60%, the enrichment source data may be stale. Re-run Step 4 with LinkedIn Sales Navigator as primary source instead of Apollo.io.

### Step 6: Output Generation

**Duration**: 5 minutes
**Tool**: Python script

Generate the final enriched company profiles and decision-maker contact list in structured format.

```python
# output_generation.py
import json
import csv
from datetime import datetime

def generate_output(enriched_companies, verified_contacts, signal_type):
    """Generate final enrichment output files."""
    timestamp = datetime.now().strftime("%Y%m%d_%H%M")

    # 1. Enriched company profiles (JSON)
    company_output = {
        "generated_at": datetime.now().isoformat(),
        "signal_type": signal_type,
        "total_companies": len(enriched_companies),
        "companies": enriched_companies
    }
    with open(f"enriched_companies_{timestamp}.json", "w") as f:
        json.dump(company_output, f, indent=2)

    # 2. Decision-maker contact list (CSV)
    csv_fields = [
        "company_name", "company_domain", "full_name", "title",
        "email", "email_verified", "email_score", "linkedin_url",
        "priority", "signal_relevance", "hq_country",
        "employee_count", "annual_revenue_estimate",
        "store_count", "primary_retail_category"
    ]
    with open(f"decision_makers_{timestamp}.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=csv_fields, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(verified_contacts)

    # 3. Summary statistics
    stats = {
        "total_companies": len(enriched_companies),
        "total_contacts": len(verified_contacts),
        "p1_contacts": sum(1 for c in verified_contacts if c.get("priority") == "P1"),
        "p2_contacts": sum(1 for c in verified_contacts if c.get("priority") == "P2"),
        "verified_emails": sum(1 for c in verified_contacts if c.get("email_verified")),
        "linkedin_only": sum(1 for c in verified_contacts if not c.get("email_verified") and c.get("linkedin_url")),
        "signal_type": signal_type,
        "generated_at": datetime.now().isoformat()
    }
    print(json.dumps(stats, indent=2))
    return stats
```

**Output files**:
- `enriched_companies_{timestamp}.json` — Full firmographic profiles with retail-specific fields, sorted by signal score
- `decision_makers_{timestamp}.csv` — Verified contacts with role, email, LinkedIn, priority tier, signal relevance
- Console output — Summary statistics and quality metrics

## Output Schema

<!-- Exact format of the deliverable. Downstream agents and outreach cards reference this schema. -->

```json
{
  "output_type": "enriched_retail_signal",
  "format": "JSON + CSV",
  "company_columns": [
    {"name": "company_name", "type": "string", "description": "Canonical company name", "required": true},
    {"name": "domain", "type": "string", "description": "Primary company domain", "required": true},
    {"name": "hq_city", "type": "string", "description": "Headquarters city", "required": false},
    {"name": "hq_state", "type": "string", "description": "Headquarters state/region", "required": false},
    {"name": "hq_country", "type": "string", "description": "Headquarters country (ISO 3166)", "required": true},
    {"name": "employee_count", "type": "number", "description": "Estimated total employees", "required": true},
    {"name": "annual_revenue_estimate", "type": "string", "description": "Revenue range (e.g., $100M-$500M)", "required": true},
    {"name": "store_count", "type": "number", "description": "Number of physical retail locations", "required": false},
    {"name": "ecommerce_presence", "type": "object", "description": "DTC site, marketplace channels", "required": false},
    {"name": "primary_retail_category", "type": "string", "description": "Apparel, grocery, electronics, etc.", "required": true},
    {"name": "tech_stack_signals", "type": "array", "description": "Inferred technology platforms", "required": false},
    {"name": "current_vendors_inferred", "type": "array", "description": "Current vendors from job posts/tech stack", "required": false}
  ],
  "contact_columns": [
    {"name": "full_name", "type": "string", "description": "Contact full name", "required": true},
    {"name": "title", "type": "string", "description": "Current job title", "required": true},
    {"name": "email", "type": "string", "description": "Work email address", "required": false},
    {"name": "email_verified", "type": "boolean", "description": "Email passed verification", "required": true},
    {"name": "email_score", "type": "number", "description": "Verification confidence 0-100", "required": false},
    {"name": "linkedin_url", "type": "string", "description": "LinkedIn profile URL", "required": false},
    {"name": "priority", "type": "string", "description": "P1 (primary) or P2 (secondary)", "required": true},
    {"name": "signal_relevance", "type": "string", "description": "Which signal type this contact maps to", "required": true}
  ],
  "expected_row_count": "100-150 contacts for 50 companies",
  "sort_order": "priority ascending, then company_name",
  "deduplication_key": "email OR linkedin_url"
}
```

## Quality Benchmarks

<!-- How to evaluate if the output is good enough. -->

| Quality Metric | Minimum Acceptable | Good | Excellent |
|---------------|-------------------|------|-----------|
| Company resolution rate | > 70% of signals resolved | > 85% resolved | > 95% resolved |
| Firmographic completeness | > 60% fields populated | > 80% populated | > 90% populated |
| Email verification rate | > 65% verified | > 80% verified | > 90% verified |
| P1 contact coverage | > 60% of companies have P1 | > 75% have P1 | > 90% have P1 |
| Duplicate rate | < 5% | < 2% | < 0.5% |
| Director+ title accuracy | > 85% at Director+ level | > 90% | > 98% |

**If below minimum**: Re-run Step 2 with Clearbit as secondary enrichment source. If P1 contact coverage is low, supplement with LinkedIn Sales Navigator manual search for the gap companies.

## Error Handling

<!-- Common failures and recovery actions. -->

| Error | Likely Cause | Recovery Action |
|-------|-------------|----------------|
| Apollo.io 429 (rate limit) | Too many requests in time window | Wait 60 seconds, reduce batch size to 5 per minute on free tier |
| Apollo.io 401 (auth failed) | API key expired or invalid | Regenerate API key at apollo.io/settings, update config |
| Hunter.io returns "accept_all" | Company mail server accepts all addresses | Treat as unverified — use LinkedIn as primary channel for this contact |
| Empty Apollo.io results for company | Company too small or not in Apollo database | Try Clearbit enrichment, or manual LinkedIn company search |
| GDPR region detected but using non-compliant tool | EU company routed through Apollo.io instead of Dropcontact | Re-route EU contacts through Dropcontact pipeline (Step 5 variant) |
| Duplicate contacts across companies | Same person listed at multiple subsidiaries | Deduplicate by email + LinkedIn URL; keep the parent company record |
| SEC CIK lookup returns no match | Company is private or uses different filing entity name | Fall back to company name fuzzy match + domain enrichment |

## Cost Breakdown

<!-- Real cost analysis so agents can warn users before execution. -->

| Component | Free Tier | Paid Tier | At Scale (500 companies) |
|-----------|-----------|-----------|--------------------------|
| Company enrichment (Apollo.io) | 50 credits/mo (50 companies) | $49/mo = 500 credits | $99/mo = 2000 credits |
| People search (Apollo.io) | Included in credits | Included | Included |
| Email verification (Hunter.io) | 50 verifications/mo | $49/mo = 1000 verifications | $99/mo = 5000 verifications |
| LinkedIn Sales Navigator | N/A | $99/mo | $99/mo |
| EU contacts (Dropcontact) | N/A | $24/mo = 1000 contacts | $96/mo = 5000 contacts |
| **Total for 50 companies** | **$0** | **$147-$221/mo** | **$393/mo** |

## Anti-Patterns

### Wrong: Enriching before resolving

Sending raw signal data (lat/lon coordinates, SEC CIK numbers, job posting URLs) directly to enrichment APIs without first resolving to a canonical company identity. Result: duplicate records, mismatched firmographics, and wasted API credits when the same company appears via multiple signal types under different identifiers. [src5]

### Correct: Resolution-first pipeline

Always run Step 1 (Company Resolution) first to create a deduplicated company list with canonical identifiers. Then enrich once per company, not once per signal.

### Wrong: Skipping email verification

Loading unverified email addresses directly into outreach sequences. Result: 15-25% bounce rate, sender domain reputation damage, potential blacklisting by ESPs, and all future emails routed to spam. [src6]

### Correct: Verify everything, fallback to LinkedIn

Run all emails through Hunter.io or equivalent verification. Remove emails scoring below 85% confidence. For contacts with no verified email, use LinkedIn InMail or connection request as fallback channel.

### Wrong: One-size-fits-all targeting

Using the same decision-maker titles regardless of signal type. Sending supply chain distress signals to the CTO, or AI readiness signals to the VP Supply Chain. Result: irrelevant outreach, low response rates, and wasted contacts. [src3]

### Correct: Signal-to-role mapping

Use the TARGETING_MAP (Step 4) to route each signal type to the correct decision-maker function. Inventory signals go to VP Supply Chain (P1) and COO (P2). Digital transformation signals go to CTO and VP Digital (P1) and CMO (P2).

## When This Matters

Use this recipe when raw retail distress signals have been detected and scored but have not yet been mapped to specific companies and contacts. This is the bridge between signal detection (upstream) and outreach execution (downstream). Without enrichment mapping, detected signals remain abstract market intelligence rather than actionable sales opportunities.

## Related Units

- [Retail Signal Library Overview](/signal-library/retail/overview/2026) — Framework and taxonomy this recipe operates within
- [Retail Detection Rules](/signal-library/retail/detection-rules/2026) — Upstream: produces the signal reports this recipe enriches
- [SEC Financial Filings Source](/signal-library/retail-sources/sec-financial-filings/2026) — Primary source for company resolution from SEC data
- [Customer Review Sentiment Source](/signal-library/retail-sources/customer-review-sentiment/2026) — Supplementary signal context for enrichment
