← Back to KPIs

Cost per Lead

Also known as: CPL, Cost per Acquired Lead, Lead Acquisition Cost

AcquisitionkpiE-commerce: Top-of-Funnel & Marketing AcquisitionE-commerce

Marketing spend divided by net-new leads captured in the same period, where a lead is a generate_lead event such as an email or SMS opt-in. Read by channel and campaign to price list growth.

Business Use Case

1️⃣ Paid media team pauses campaigns whose weekly CPL runs 20% over target and moves budget to lower-CPL channels. 2️⃣ CRM team compares CPL by sign-up offer with the share of those leads that later purchase before scaling an offer. 3️⃣ Finance sets the quarterly lead budget from blended CPL and the target list-growth volume.

Business Criticality

High

Journey / Navigation Area

Generic

Owning Function

Marketing

Scope

Global

Formula

Total Marketing Spend / Net-New Leads Generated

Calculation Notes

1️⃣ Count distinct lead_id from generate_lead; form resubmits and repeat opt-ins carrying the same lead_id count once. 2️⃣ Net-new only: no hit for the lead_id has customer_type other than 'new', and its first generate_lead falls in the period. 3️⃣ Spend and leads share the date range and source / medium / campaign keys; leads are attributed last non-direct click. 4️⃣ Spend is media cost in reporting currency, net of credits; agency and tool fees only if Finance includes them. 5️⃣ Zero leads returns NULL, not 0; list campaigns with spend and no leads separately. 6️⃣ Exclude internal, test and bot traffic, and leads failing validation (invalid or disposable email). 7️⃣ Spend comes from the warehouse ad-spend table by date, source and medium, not GA4 cost import; reconcile to ad platform invoices monthly.

Technical Details

Measure TypeCurrency
Aggregation WindowDaily, Weekly, Monthly, Quarterly

Events

PlatformEvent
Google Analytics 4generate_lead
Adobegenerate_lead

Data Mappings

W3 Data Layer
{
  "event": [
    {
      "eventInfo": {
        "eventName": "generate_lead",
        "eventAction": "generate_lead",
        "eventTimestamp": "2026-09-23T16:12:00Z",
        "trafficType": "customer"
      },
      "lead": {
        "leadID": "L20260923-004821",
        "leadType": "email_optin",
        "leadSource": "paid_search",
        "formID": "footer_newsletter_signup",
        "customerType": "new",
        "leadValidationStatus": "valid",
        "currency": "USD",
        "value": 0
      },
      "campaign": {
        "campaignName": "spring_newsletter_promo",
        "campaignSource": "google",
        "campaignMedium": "cpc"
      },
      "user": {
        "userID": "u_5039281"
      }
    }
  ]
}
GA4 Data Layer
{
  "event": "generate_lead",
  "lead_id": "L20260923-004821",
  "form_id": "footer_newsletter_signup",
  "lead_type": "email_optin",
  "lead_source": "paid_search",
  "customer_type": "new",
  "lead_validation_status": "valid",
  "currency": "USD",
  "value": 0,
  "campaign": "spring_newsletter_promo",
  "campaign_source": "google",
  "campaign_medium": "cpc",
  "traffic_type": "customer",
  "user_id": "u_5039281"
}
Adobe Client Data Layer
{
  "event": "generate_lead",
  "leadDetail": {
    "lead_id": "L20260923-004821",
    "form_id": "footer_newsletter_signup",
    "lead_type": "email_optin",
    "lead_source": "paid_search",
    "customer_type": "new",
    "lead_validation_status": "valid",
    "currency": "USD",
    "value": 0,
    "campaign": "spring_newsletter_promo",
    "campaign_source": "google",
    "campaign_medium": "cpc",
    "traffic_type": "customer",
    "user_id": "u_5039281"
  }
}
XDM Mapping
{
  "eventType": "generate_lead",
  "_id": "3f6a1c9e-8b2d-4e11-9c47-2a6f0b1d7e55",
  "_tenant": {
    "leadID": "L20260923-004821"
  },
  "web": {
    "webInteraction": {
      "name": "footer_newsletter_signup",
      "type": "other"
    }
  },
  "_experience": {
    "analytics": {
      "customDimensions": {
        "eVars": {
          "eVar1": "paid_search",
          "eVar2": "email_optin",
          "eVar3": "google",
          "eVar4": "cpc",
          "eVar5": "new",
          "eVar6": "spring_newsletter_promo",
          "eVar7": "customer",
          "eVar8": "valid"
        }
      },
      "event1to100": {
        "event1": {
          "value": 0
        }
      }
    }
  },
  "identityMap": {
    "loginID": [
      {
        "id": "u_5039281"
      }
    ]
  }
}

SQL Queries

Google Analytics 4 (BigQuery)
-- ==========================================
-- 1. GOOGLE BIGQUERY SQL
-- ==========================================
DECLARE start_date DATE DEFAULT DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY);
DECLARE end_date DATE DEFAULT DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY);

WITH lead_events AS (
  -- Valid generate_lead hits from real traffic, parameters pivoted, full history up to end_date (to find leads that existed before the period)
  SELECT event_dt, event_ts, lead_id, customer_type, source, medium, campaign
  FROM (
    SELECT
      PARSE_DATE('%Y%m%d', event_date) AS event_dt,
      event_timestamp AS event_ts,
      (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'lead_id') AS lead_id,
      (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'customer_type') AS customer_type,
      (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'traffic_type') AS traffic_type,
      (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'lead_validation_status') AS lead_validation_status,
      COALESCE((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'campaign_source'), '(direct)') AS source,
      COALESCE((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'campaign_medium'), '(none)') AS medium,
      COALESCE((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'campaign'), '(not set)') AS campaign
    FROM `your-project.analytics_123456789.events_*`
    WHERE _TABLE_SUFFIX <= FORMAT_DATE('%Y%m%d', end_date)
      AND event_name = 'generate_lead'
  )
  WHERE COALESCE(traffic_type, '') NOT IN ('internal', 'test', 'bot')
    AND lead_validation_status = 'valid'
),

net_new_leads AS (
  -- One row per lead_id: first generate_lead in the period, no hit with customer_type other than 'new', attributed last non-direct click
  SELECT lead_id, source, medium, campaign
  FROM (
    SELECT
      lead_id, source, medium, campaign,
      MIN(event_dt) OVER (PARTITION BY lead_id) AS first_dt,
      SUM(IF(COALESCE(customer_type, '') != 'new', 1, 0)) OVER (PARTITION BY lead_id) AS existing_flags,
      ROW_NUMBER() OVER (PARTITION BY lead_id ORDER BY IF(source = '(direct)', 1, 0), event_ts DESC) AS rn
    FROM lead_events
    WHERE lead_id IS NOT NULL
  )
  WHERE rn = 1 AND first_dt >= start_date AND existing_flags = 0
),

leads_by_key AS (
  -- Net-new leads per source / medium / campaign
  SELECT source, medium, campaign, COUNT(*) AS leads
  FROM net_new_leads
  GROUP BY source, medium, campaign
),

spend_by_key AS (
  -- Warehouse media cost in reporting currency net of credits, fees only where Finance includes them, same date range and keys
  SELECT
    source, medium, campaign,
    SUM(media_cost - credits + IF(finance_includes_fees, fees, 0)) AS spend
  FROM `your-project.marketing.ad_spend_daily`
  WHERE spend_date BETWEEN start_date AND end_date
  GROUP BY source, medium, campaign
)

-- CPL per source / medium / campaign plus the blended total; zero leads returns NULL and spend-without-leads rows are listed first
SELECT
  source,
  medium,
  campaign,
  SUM(COALESCE(s.spend, 0)) AS spend,
  SUM(COALESCE(l.leads, 0)) AS net_new_leads,
  ROUND(SUM(COALESCE(s.spend, 0)) / NULLIF(SUM(COALESCE(l.leads, 0)), 0), 2) AS cost_per_lead
FROM spend_by_key AS s
FULL OUTER JOIN leads_by_key AS l USING (source, medium, campaign)
GROUP BY GROUPING SETS ((source, medium, campaign), ())
ORDER BY GROUPING(source), SUM(COALESCE(l.leads, 0)) = 0 DESC, SUM(COALESCE(s.spend, 0)) DESC;
Adobe Experience Platform (AEP Query Service)
-- ==========================================
-- 2. ADOBE AEP QUERY SERVICE SQL
-- ==========================================
WITH params AS (
  -- Relative reporting window
  SELECT DATE_SUB(CURRENT_DATE, 90) AS start_date, DATE_SUB(CURRENT_DATE, 1) AS end_date
),

lead_events AS (
  -- Valid generate_lead hits from real traffic, eVars mapped, full history up to end_date (to find leads that existed before the period)
  SELECT
    TO_DATE(e.timestamp) AS event_dt,
    e.timestamp AS event_ts,
    e._tenant.leadID AS lead_id,
    e._experience.analytics.customDimensions.eVars.eVar5 AS customer_type,
    COALESCE(e._experience.analytics.customDimensions.eVars.eVar3, '(direct)') AS source,
    COALESCE(e._experience.analytics.customDimensions.eVars.eVar4, '(none)') AS medium,
    COALESCE(e._experience.analytics.customDimensions.eVars.eVar6, '(not set)') AS campaign
  FROM aep_web_events_dataset AS e
  CROSS JOIN params AS p
  WHERE TO_DATE(e.timestamp) <= p.end_date
    AND e.eventType = 'generate_lead'
    AND COALESCE(e._experience.analytics.customDimensions.eVars.eVar7, '') NOT IN ('internal', 'test', 'bot')
    AND e._experience.analytics.customDimensions.eVars.eVar8 = 'valid'
),

net_new_leads AS (
  -- One row per lead_id: first generate_lead in the period, no hit with customer_type other than 'new', attributed last non-direct click
  SELECT r.lead_id, r.source, r.medium, r.campaign
  FROM (
    SELECT
      lead_id, source, medium, campaign,
      MIN(event_dt) OVER (PARTITION BY lead_id) AS first_dt,
      SUM(IF(COALESCE(customer_type, '') != 'new', 1, 0)) OVER (PARTITION BY lead_id) AS existing_flags,
      ROW_NUMBER() OVER (PARTITION BY lead_id ORDER BY IF(source = '(direct)', 1, 0), event_ts DESC) AS rn
    FROM lead_events
    WHERE lead_id IS NOT NULL
  ) AS r
  CROSS JOIN params AS p
  WHERE r.rn = 1 AND r.first_dt >= p.start_date AND r.existing_flags = 0
),

leads_by_key AS (
  -- Net-new leads per source / medium / campaign
  SELECT source, medium, campaign, COUNT(*) AS leads
  FROM net_new_leads
  GROUP BY source, medium, campaign
),

spend_by_key AS (
  -- Warehouse media cost in reporting currency net of credits, fees only where Finance includes them, same date range and keys
  SELECT
    a.source, a.medium, a.campaign,
    SUM(a.media_cost - a.credits + IF(a.finance_includes_fees, a.fees, 0)) AS spend
  FROM marketing_ad_spend_daily AS a
  CROSS JOIN params AS p
  WHERE a.spend_date BETWEEN p.start_date AND p.end_date
  GROUP BY a.source, a.medium, a.campaign
)

-- CPL per source / medium / campaign plus the blended total; zero leads returns NULL and spend-without-leads rows are listed first
SELECT
  source,
  medium,
  campaign,
  SUM(COALESCE(s.spend, 0)) AS spend,
  SUM(COALESCE(l.leads, 0)) AS net_new_leads,
  ROUND(SUM(COALESCE(s.spend, 0)) / NULLIF(SUM(COALESCE(l.leads, 0)), 0), 2) AS cost_per_lead
FROM spend_by_key AS s
FULL OUTER JOIN leads_by_key AS l USING (source, medium, campaign)
GROUP BY GROUPING SETS ((source, medium, campaign), ())
ORDER BY GROUPING(source), SUM(COALESCE(l.leads, 0)) = 0 DESC, SUM(COALESCE(s.spend, 0)) DESC;

Source Data

Digital Analytics (GA4)

Dependencies

Prerequisite: List the dependencies required for this KPI

Metrics:

ad-spend

Dimensions:

traffic-source

Report Attributes

Traffic Source Campaign First Touch Channel Grouping Device Category

Recommended Reporting Views

Views
Paid Media PerformanceMarketing Acquisition OverviewLead Generation Funnel

Segment Eligibility

true

Governance

Data SensitivityInternal

Governance

Created bydevyendarm
Created on9/23/2026
Last modified bydevyendarm
Last modified on9/23/2026
Version1.0.0
Validation stateqa passed
StatusPUBLISHED

GitHub

View related Pull Requestcatalog/kpis/cost-per-lead.yml

Community Discussion