Home / Playbooks / Google Ads Data Manager API Ingestion

Offline Conversion Tracking in Google Ads: The Data Manager API Migration

A complete architectural guide to capturing click identifiers, storing attribution in your CRM, and uploading qualified deal stages via Google's modern Data Manager API (/v1/events:ingest) to retrain Smart Bidding on verified revenue.

1. The Cutover: Why Legacy UploadClickConversions Snippets No Longer Work

If you search online for guides on setting up Google Ads Offline Conversion Tracking (OCT), virtually every tutorial, blog post, and YouTube video tells you to use the Google Ads API endpoint ConversionUploadService.UploadClickConversions. If you write code using those snippets today, your requests will fail.

Google officially closed the UploadClickConversions endpoint to new adopters on June 15, 2026. While existing developer integrations with grandfathered access continue running, any new offline conversion tracking pipeline must use Google's unified Data Manager API and its /v1/events:ingest endpoint.

The transition to Data Manager is not just a URL change; it introduces a unified ingestion model across Google Ads, Google Analytics, and Floodlight, paired with mandatory consent parameters and asynchronous processing diagnostics. As a specialized Google Ads management agency, we build production Data Manager pipelines that feed qualified CRM pipeline data back to Google Ads to retrain Smart Bidding models on actual closed revenue.

The Core Mandate: The Google Ads API UploadClickConversions endpoint is closed to new integrations. All new offline conversion uploads must target the Data Manager API (/v1/events:ingest) using numeric product destination IDs and asynchronous status polling.

2. The Seven Breaking Differences in Data Manager API

Developers attempting to migrate existing Google Ads upload scripts frequently run into silent failures or schema rejections. Here are the seven breaking structural differences between the legacy API and the Data Manager API:

# Legacy Google Ads API (Closed) Google Data Manager API (Modern) Breaking Impact
1 conversion_action: "customers/123/conversionActions/456" product_destination_id: "456" Data Manager expects a numeric string ID, NOT a full resource name path.
2 currency_code: "USD" currency: "USD" Field renamed to currency; using currency_code triggers payload validation errors.
3 hashed_email, hashed_phone_number user_data: UserData(user_identifiers=[...]) User identifiers are strictly nested inside a UserData wrapper object on the Event.
4 gclid set directly on top-level conversion object ad_identifiers { gclid, gbraid, wbraid } Click identifiers are strictly nested inside an ad_identifiers sub-object.
5 Consent strings GRANTED / DENIED CONSENT_GRANTED / CONSENT_DENIED Consent enums are prefixed; using legacy values causes enum parsing exceptions.
6 Custom string date formatting (yyyy-mm-dd hh:mm:ss) RFC 3339 Timestamp / Typed Protobuf Timestamps must represent the actual conversion occurrence time with explicit timezone offsets.
7 HTTP 200 indicates immediate write success HTTP 200 means received only (Asynchronous) Ingestion is async; final record validation must be polled via retrieve_request_status.

3. Required Python Packages and SDK Versioning

The official Data Manager API client and its data formatting utilities are distributed as two distinct PyPI packages. Because both packages are pre-1.0, pin their exact versions in your deployment environment:

# Install Google Data Manager API client and utility packages
pip install google-ads-datamanager==0.9.1 google-ads-datamanager-util==0.4.0

4. The End-to-End Offline Attribution Data Pipeline

Below is the architecture for capturing ad click identifiers on landing pages, persisting them across sessions, storing attribution in your CRM, and dispatching verified conversions to the Data Manager API:

+-------------------------------------------------------------------------+
|                           1. AD CLICK & CAPTURE                         |
|                                                                         |
|  User Clicks Google Ad:                                                 |
|  https://spark5x.com/services/?gclid=Cj0KCQjw_...&wbraid=CjgKCAi...      |
|         │                                                               |
|         ▼                                                               |
|  JavaScript / Edge Worker:                                              |
|  - Extracts `gclid`, `wbraid`, `gbraid` from URL parameters             |
|  - Stores in 400-day 1st-party cookies (`sp_gclid`, `sp_wbraid`)        |
|  - Records consent state (`sp_c`) from cookie consent banner            |
+─────────┼───────────────────────────────────────────────────────────────+
          │
          ▼
+-------------------------------------------------------------------------+
|                        2. LEAD FORM SUBMISSION                          |
|                                                                         |
|  User Submits Form on Website / Landing Page:                           |
|  - Hidden fields pass `gclid`, `wbraid`, `gbraid`, and user consent     |
|  - Payload dispatched to CRM Webhook / Worker                           |
+─────────┼───────────────────────────────────────────────────────────────+
          │
          ▼
+-------------------------------------------------------------------------+
|                      3. CRM PIPELINE & DEAL STAGE                       |
|                                                                         |
|  CRM (HubSpot / Salesforce / Zoho / Airtable):                          |
|  - Contact Record created with custom properties:                       |
|    * Google Click ID: `gclid`, `wbraid`, or `gbraid`                    |
|    * Stored Consent: `ad_user_data=CONSENT_GRANTED`                     |
|    * Conversion Timestamp: `2026-08-14T11:20:00Z` (Deal Close Time)     |
|  - Deal progresses through pipeline stages:                             |
|    [ New Lead ] → [ Discovery Held ] → [ Closed-Won ($5,000) ]       |
+─────────┼───────────────────────────────────────────────────────────────+
          │
          ▼
+-------------------------------------------------------------------------+
|                   4. DATA MANAGER API INGESTION PIPELINE                |
|                                                                         |
|  Serverless Uploader (Python / google-ads-datamanager):                 |
|  - Triggered via CRM Webhook on deal stage transition                   |
|  - Calls `/v1/events:ingest` endpoint:                                  |
|    * product_destination_id: "456"                                      |
|    * operating_account: ProductAccount(product=Product.GOOGLE_ADS, ...) |
|    * ad_identifiers: { gclid: "..." } or { wbraid: "..." }              |
|    * user_data: UserData(user_identifiers=[...])                        |
|    * event_timestamp: Deal Close Time (RFC 3339 Protobuf Timestamp)     |
|    * conversion_value: 5000.00 | currency: "USD"                        |
|    * consent: { ad_user_data: "CONSENT_GRANTED" }                       |
|  - Receives HTTP 200 OK + `request_id`                                   |
+─────────┼───────────────────────────────────────────────────────────────+
          │
          ▼
+-------------------------------------------------------------------------+
|             5. ASYNC DIAGNOSTICS STATUS POLLING (30+ MIN LATER)          |
|                                                                         |
|  Polls `client.retrieve_request_status(request_id=...)`:                |
|  - Evaluates `request_status`: SUCCESS | PARTIAL_SUCCESS | FAILED       |
|  - Inspects `error_info.error_counts` and `warning_info.warning_counts` |
|  - Confirms conversion is processed and attributed in Google Ads        |
+─────────────────────────────────────────────────────────────────────────+

5. Step 1: Capturing and Persisting Click Identifiers & Consent

When a user clicks a Google Ad, Google appends click parameters to the URL. On desktop and standard web browsers, this is the gclid. On iOS and privacy-mode browsers where modeled attribution applies, Google passes wbraid (web conversions) or gbraid (app conversions).

Because B2B buyers rarely convert on their initial landing pageview, these identifiers—along with the visitor's explicit consent state—must be stored in long-lived first-party cookies:

// Frontend: Capture Click Identifiers and Consent State
(function() {
  function getUrlParam(name) {
    const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)');
    const results = regex.exec(window.location.href);
    if (!results || !results[2]) return null;
    return decodeURIComponent(results[2].replace(/\+/g, ' '));
  }

  const gclid = getUrlParam('gclid');
  const wbraid = getUrlParam('wbraid');
  const gbraid = getUrlParam('gbraid');

  // Persist click identifiers in 1st-party cookies (400-day expiry)
  const maxAge = 400 * 24 * 60 * 60;
  if (gclid) document.cookie = `sp_gclid=${gclid}; Max-Age=${maxAge}; Path=/; SameSite=Lax; Secure`;
  if (wbraid) document.cookie = `sp_wbraid=${wbraid}; Max-Age=${maxAge}; Path=/; SameSite=Lax; Secure`;
  if (gbraid) document.cookie = `sp_gbraid=${gbraid}; Max-Age=${maxAge}; Path=/; SameSite=Lax; Secure`;

  // Auto-populate hidden inputs on form load
  document.addEventListener('DOMContentLoaded', function() {
    function getCookie(name) {
      const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
      return v ? v[2] : null;
    }

    const savedGclid = gclid || getCookie('sp_gclid');
    const savedWbraid = wbraid || getCookie('sp_wbraid');
    const savedGbraid = gbraid || getCookie('sp_gbraid');
    const consentState = getCookie('sp_c') || 'analytics,marketing';

    if (savedGclid) {
      document.querySelectorAll('input[name="gclid"]').forEach(el => el.value = savedGclid);
    }
    if (savedWbraid) {
      document.querySelectorAll('input[name="wbraid"]').forEach(el => el.value = savedWbraid);
    }
    if (savedGbraid) {
      document.querySelectorAll('input[name="gbraid"]').forEach(el => el.value = savedGbraid);
    }
    document.querySelectorAll('input[name="consent_marketing"]').forEach(el => {
      el.value = consentState.includes('marketing') ? 'CONSENT_GRANTED' : 'CONSENT_DENIED';
    });
  });
})();

6. Step 2: Normalising & Hashing User Identifiers Across International Markets

Hand-rolled string hashing is a frequent source of unmatched conversions. Minor inconsistencies—such as un-trimmed whitespace, capital letters in emails, or missing international dial codes on phone numbers—produce SHA-256 hashes that fail to match Google account databases.

Google provides the official google-ads-datamanager-util library to guarantee exact compliance with Google's normalization rules. Notice that process_phone_number has no country/region argument—it strictly expects the phone number to already be in international E.164 format (e.g. +14155552671, +971501234567, or +966501234567):

# Python: Normalizing and Hashing Identifiers with the Official Utility
from google.ads.datamanager_util import Formatter
from google.ads.datamanager_util.format import Encoding

formatter = Formatter()

# Automatically trims, lowercases, removes dots from gmail addresses, and hashes with SHA-256
hashed_email = formatter.process_email_address("[email protected] ", Encoding.HEX)

# Automatically formats international E.164 phone number to SHA-256 hex
customer_phone_e164 = "+971501234567"
hashed_phone = formatter.process_phone_number(customer_phone_e164, Encoding.HEX)

7. Step 3: Python Implementation for Data Manager API Ingestion

Below is the complete, production-grade Python script using the official Google Data Manager client library (google-ads-datamanager==0.9.1) to dispatch offline conversion events to the /v1/events:ingest endpoint.

Crucial Rule on Click Identifiers & Timezones:
1. Never guess click-ID types from string formats: wbraid and gbraid formats evolve, and gclid formats vary. Always map explicitly from named CRM fields.
2. Always use timezone-aware timestamps: Timestamp.FromDatetime() assumes a naive datetime is UTC. If your CRM passes a naive local datetime (e.g., Riyadh UTC+3 or Dubai UTC+4), it will misdate the conversion by 3 to 4 hours. Always supply timezone-aware datetime objects.

# Python: Data Manager API Event Ingestion Pipeline
import datetime
from google.protobuf.timestamp_pb2 import Timestamp
from google.ads.datamanager_v1 import (
    IngestionServiceClient,
    IngestEventsRequest,
    Destination,
    ProductAccount,
    Product,
    Event,
    AdIdentifiers,
    UserData,
    UserIdentifier,
    Consent,
    ConsentStatus
)
from google.ads.datamanager_util import Formatter
from google.ads.datamanager_util.format import Encoding

def ingest_offline_conversion(
    google_ads_customer_id: str,
    product_destination_id: str,
    conversion_datetime: datetime.datetime,
    deal_value: float,
    currency: str = "USD",
    gclid: str | None = None,
    wbraid: str | None = None,
    gbraid: str | None = None,
    customer_email: str | None = None,
    customer_phone_e164: str | None = None,
    user_consent_granted: bool = True,
    validate_only: bool = False
):
    """
    Ingests an offline conversion into Google Ads via the Data Manager API.
    
    Args:
        google_ads_customer_id: Numeric Google Ads account ID (e.g. '3956686521')
        product_destination_id: Numeric conversion action destination ID (e.g. '7722120851')
        conversion_datetime: Timezone-aware timestamp when the deal closed in the CRM
        deal_value: Monetary value of the deal
        currency: 3-letter currency code (e.g. 'USD')
        gclid: Google Click Identifier (if captured)
        wbraid: Modeled Web Click Identifier (if captured)
        gbraid: Modeled App Click Identifier (if captured)
        customer_email: Raw customer email address
        customer_phone_e164: Customer phone in E.164 format (+1..., +971..., etc.)
        user_consent_granted: User's stored marketing consent boolean
        validate_only: Set True to validate payload schema without writing
    """
    client = IngestionServiceClient()
    formatter = Formatter()

    # 1. Build Destination Configuration using ProductAccount
    destination = Destination(
        product_destination_id=str(product_destination_id),  # Numeric string
        operating_account=ProductAccount(
            account_id=str(google_ads_customer_id),
            product=Product.GOOGLE_ADS
        )
    )

    # 2. Assign Click Identifiers Explicitly from Named CRM Fields
    ad_identifiers = AdIdentifiers()
    if gclid:
        ad_identifiers.gclid = gclid
    elif wbraid:
        ad_identifiers.wbraid = wbraid
    elif gbraid:
        ad_identifiers.gbraid = gbraid
    else:
        raise ValueError("At least one click identifier (gclid, wbraid, or gbraid) is required.")

    # 3. Format and Hash User Identifiers inside UserData wrapper
    user_identifiers = []
    if customer_email:
        user_identifiers.append(
            UserIdentifier(
                email_address=formatter.process_email_address(customer_email, Encoding.HEX)
            )
        )
    if customer_phone_e164:
        user_identifiers.append(
            UserIdentifier(
                phone_number=formatter.process_phone_number(customer_phone_e164, Encoding.HEX)
            )
        )
    user_data = UserData(user_identifiers=user_identifiers)

    # 4. Map Per-Event Consent (Reflects actual stored visitor choice)
    consent_status = (
        ConsentStatus.CONSENT_GRANTED if user_consent_granted else ConsentStatus.CONSENT_DENIED
    )
    event_consent = Consent(
        ad_user_data=consent_status,
        ad_personalization=consent_status
    )

    # 5. Build Event Object with Timezone-Aware Protobuf Timestamp
    if conversion_datetime.tzinfo is None:
        raise ValueError("conversion_datetime must be a timezone-aware datetime object.")
        
    event_timestamp = Timestamp()
    event_timestamp.FromDatetime(conversion_datetime)

    event = Event(
        ad_identifiers=ad_identifiers,
        user_data=user_data,
        event_timestamp=event_timestamp,
        conversion_value=float(deal_value),
        currency=currency,  # Renamed from currency_code
        consent=event_consent
    )

    # 6. Construct and Send IngestEventsRequest
    request = IngestEventsRequest(
        destinations=[destination],
        events=[event],
        validate_only=validate_only
    )

    response = client.ingest_events(request=request)
    print(f"Ingest dispatched successfully. Request ID: {response.request_id}")
    
    # Check for immediate field validation warnings
    if response.field_warnings:
        for warning in response.field_warnings:
            print(f"Warning on field {warning.field}: {warning.description}")
            
    return response.request_id

8. Step 4: Asynchronous Request Status Diagnostics (The Missing Step)

A widespread mistake when working with the Data Manager API is assuming that receiving an HTTP 200 response means the conversion was successfully recorded. The Data Manager API processes ingestion payloads asynchronously.

To verify whether records were accepted, partially accepted, or rejected, implement a status check scheduled at least 30 minutes after upload using exponential backoff. Note that when validate_only=True is passed during schema validation, Google does not queue an asynchronous task, so polling status is skipped:

# Python: Asynchronous Diagnostics Polling
from google.ads.datamanager_v1 import IngestionServiceClient, RetrieveRequestStatusRequest

def poll_ingestion_diagnostics(request_id: str, validate_only: bool = False):
    """
    Polls Data Manager API diagnostics to verify async conversion processing.
    
    Note: When validate_only=True was used, no asynchronous processing task is queued,
    so calling retrieve_request_status is skipped.
    """
    if validate_only:
        print("validate_only=True: skipping retrieve_request_status (no async job queued).")
        return None

    client = IngestionServiceClient()
    request = RetrieveRequestStatusRequest(request_id=request_id)
    
    response = client.retrieve_request_status(request=request)
    
    for status_per_dest in response.request_status_per_destination:
        dest_id = status_per_dest.destination.product_destination_id
        status = status_per_dest.request_status
        print(f"Destination {dest_id} Processing Status: {status.name}")
        
        # Check overall record count
        if status_per_dest.events_ingestion_status:
            print(f"Processed records: {status_per_dest.events_ingestion_status.record_count}")
            
        # Check errors on PARTIAL_SUCCESS or FAILED
        if status.name in ["PARTIAL_SUCCESS", "FAILED"]:
            if status_per_dest.error_info:
                for error in status_per_dest.error_info.error_counts:
                    print(f"Error [{error.reason}]: {error.record_count} records affected")
                    
        # Check warnings even on SUCCESS
        if status_per_dest.warning_info:
            for warning in status_per_dest.warning_info.warning_counts:
                print(f"Warning [{warning.reason}]: {warning.record_count} records affected")

9. Step 5: Retraining Google Smart Bidding on CRM Revenue

Once Data Manager API conversion uploads have operated cleanly for 3 to 4 weeks and accumulated at least 30 verified events, transition your campaign bidding strategy:

  • Phase 1 (Observation): Keep the Data Manager conversion action configured as Secondary (Observation) in Google Ads. Let the algorithm observe and correlate backend deal stages against historical auction parameters.
  • Phase 2 (Primary Milestone Switch): Promote the offline conversion action to Primary, and demote raw frontend form fills to Secondary. Google Smart Bidding will now optimize auctions strictly for searchers who convert into qualified pipeline.
  • Phase 3 (Value-Based Bidding): Switch campaign bidding to Maximize Conversion Value (Target ROAS), allowing Google to bid aggressively on keywords and audiences that produce higher contracted revenue.

Speed to Lead Synergy: Offline conversion tracking delivers maximum ROI when paired with immediate response times. Discover how our speed-to-lead automation framework eliminates lead decay before sales qualification begins.

10. Connecting Data Manager with Your Complete Tech Stack

Offline conversion ingestion is the critical data bridge between marketing spend and audited revenue. Discover our full-stack server-side tracking implementations, explore our conversion-engineered landing page designs, or learn how we build unified pipeline systems in CRM automation and workflow automation.

Frequently Asked Questions

Why does UploadClickConversions fail for new Google Ads API integrations?

Google closed the legacy UploadClickConversions endpoint in the Google Ads API to new adopters on June 15, 2026. All new offline conversion ingestion pipelines must use the unified Google Data Manager API (/v1/events:ingest).

Why does an HTTP 200 OK response from the Data Manager API not guarantee conversion success?

The Data Manager API processes ingestion requests asynchronously. An HTTP 200 response with a request_id only confirms that Google received the payload. To verify whether conversion records succeeded or encountered errors, you must poll client.retrieve_request_status starting at least 30 minutes after upload.

What click identifiers are supported in the Data Manager API ad_identifiers block?

The ad_identifiers object supports gclid (standard Google Click ID), wbraid (modeled web conversions on iOS), and gbraid (modeled app conversions on iOS). Your backend should persist all three parameters in distinct CRM fields and pass whichever identifier is present on the lead record.

How should consent parameters be formatted in Data Manager API event payloads?

Consent must use the enum values CONSENT_GRANTED or CONSENT_DENIED for both ad_user_data and ad_personalization. The payload must reflect the user's actual consent choice collected at form submission time rather than a hardcoded grant.

Book a discovery call