Offline Conversion Import + Google Ads Setup Guide

I see offline conversion tracking completely broken in about 60% of Google Ads accounts I audit. The most common issue? Companies set up the import structure but never actually send the conversion data, or they send it in the wrong format and Google silently rejects it. You end up with a “successful” import showing 0 conversions processed.

This happens because Google’s offline conversion system has specific requirements for data formatting, timing windows, and API authentication that most guides gloss over. I’ve implemented this for 40+ clients, and the devil is absolutely in the details.

What You’ll Have Working By The End

Prerequisites

Step 1: Configure Offline Conversion Actions in Google Ads

First, you need to create the conversion action that will receive your offline data. This isn’t just a checkbox — the settings here determine how Google processes your imports.

In Google Ads, go to Tools & Settings → Conversions → ”+” → “Import”.

Select “Other data sources or CRMs” → “Track conversions from clicks”.

Critical settings:

The conversion action will generate a Conversion Action ID — you’ll need this for the API calls.

Step 2: Capture Google Click IDs (GCLIDs)

Your website needs to capture the GCLID parameter from Google Ads traffic and associate it with form submissions. This is what links your offline conversion back to the original ad click.

GTM Setup for GCLID Capture:

Create a new variable in GTM:

Create a trigger for form submissions:

Create a tag that fires on form submission:

<script>
// Extract GCLID from URL or cookie
function getGCLID() {
    // Check URL parameter first
    const urlParams = new URLSearchParams(window.location.search);
    let gclid = urlParams.get('gclid');
    
    if (!gclid) {
        // Check cookie if not in URL
        const gclCookie = document.cookie
            .split('; ')
            .find(row => row.startsWith('_gcl_aw='));
        
        if (gclCookie) {
            gclid = gclCookie.split('=')[1].split('.')[2];
        }
    }
    
    return gclid;
}

// Send to your server endpoint
const gclid = getGCLID();
if (gclid) {
    fetch('/capture-lead', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            gclid: gclid,
            email: document.getElementById('email').value,
            timestamp: new Date().toISOString(),
            form_id: '{{Form ID}}'
        })
    });
}
</script>

Step 3: Set Up Server-Side Data Processing

You need a server endpoint that receives form submissions with GCLIDs and stores them for later conversion import. I typically deploy this on Google Cloud Run because the scaling is automatic and the Google Ads API calls work seamlessly.

Basic Cloud Run setup:

from flask import Flask, request, jsonify
import json
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException
import datetime
import os

app = Flask(__name__)

# Initialize Google Ads client
client = GoogleAdsClient.load_from_storage(path="google-ads.yaml")

@app.route('/capture-lead', methods=['POST'])
def capture_lead():
    data = request.get_json()
    
    # Store lead data (use your preferred database)
    lead_data = {
        'gclid': data.get('gclid'),
        'email': data.get('email'),
        'timestamp': data.get('timestamp'),
        'form_id': data.get('form_id'),
        'status': 'pending'
    }
    
    # Store in database/CRM
    store_lead(lead_data)
    
    return jsonify({'status': 'success'})

@app.route('/import-conversions', methods=['POST'])
def import_conversions():
    # This endpoint processes qualified leads and sends to Google
    qualified_leads = get_qualified_leads()  # Your CRM logic
    
    for lead in qualified_leads:
        upload_conversion_to_google(lead)
    
    return jsonify({'processed': len(qualified_leads)})

def upload_conversion_to_google(lead_data):
    customer_id = "YOUR_GOOGLE_ADS_CUSTOMER_ID"
    conversion_action_id = "YOUR_CONVERSION_ACTION_ID"
    
    conversion_upload_service = client.get_service("ConversionUploadService")
    
    # Create the conversion
    conversion = client.get_type("ClickConversion")
    conversion.gclid = lead_data['gclid']
    conversion.conversion_action = client.get_service("ConversionActionService").conversion_action_path(
        customer_id, conversion_action_id
    )
    conversion.conversion_date_time = lead_data['qualified_timestamp']
    conversion.conversion_value = lead_data.get('deal_value', 0)
    conversion.currency_code = "USD"
    
    request = client.get_type("UploadClickConversionsRequest")
    request.customer_id = customer_id
    request.conversions = [conversion]
    request.partial_failure = True
    
    try:
        response = conversion_upload_service.upload_click_conversions(request=request)
        print(f"Uploaded conversion for GCLID: {lead_data['gclid']}")
        return response
    except GoogleAdsException as ex:
        print(f"Upload failed: {ex}")
        return None

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))

Required google-ads.yaml configuration:

developer_token: "YOUR_DEVELOPER_TOKEN"
client_id: "YOUR_OAUTH2_CLIENT_ID"
client_secret: "YOUR_OAUTH2_CLIENT_SECRET"
refresh_token: "YOUR_REFRESH_TOKEN"
use_proto_plus: true

Step 4: Configure Data Formatting and Validation

Google’s offline conversion API is picky about data formats. Here are the requirements I’ve learned from processing thousands of conversions:

Required fields:

Data quality checks I always implement:

def validate_conversion_data(lead):
    errors = []
    
    # GCLID validation
    if not lead.get('gclid') or len(lead['gclid']) < 40:
        errors.append("Invalid or missing GCLID")
    
    # Timing validation (within 90 days)
    click_time = datetime.fromisoformat(lead['click_timestamp'])
    conversion_time = datetime.fromisoformat(lead['conversion_timestamp'])
    
    if (conversion_time - click_time).days > 90:
        errors.append("Conversion outside 90-day attribution window")
    
    # Value validation
    if lead.get('conversion_value') and lead['conversion_value'] < 0:
        errors.append("Negative conversion value not allowed")
    
    return errors

Step 5: Set Up Automated Import Scheduling

Most companies process offline conversions daily or weekly. Set up a scheduled job that queries your CRM for newly qualified leads and uploads them to Google.

Cloud Scheduler configuration (if using GCP):

gcloud scheduler jobs create http weekly-conversion-upload \
    --schedule="0 9 * * 1" \
    --uri="https://your-app.run.app/import-conversions" \
    --http-method=POST \
    --headers="Authorization=Bearer YOUR_SERVICE_ACCOUNT_TOKEN"

Batch processing logic:

def process_conversion_batch():
    # Get leads qualified in the last 7 days
    recent_conversions = get_recent_qualified_leads(days=7)
    
    # Process in batches of 100 (Google's recommended batch size)
    batch_size = 100
    for i in range(0, len(recent_conversions), batch_size):
        batch = recent_conversions[i:i + batch_size]
        
        conversions = []
        for lead in batch:
            if validate_conversion_data(lead):
                conversion = create_click_conversion(lead)
                conversions.append(conversion)
        
        # Upload batch
        if conversions:
            upload_conversion_batch(conversions)
            time.sleep(1)  # Rate limiting

Testing & Verification

1. Test GCLID capture:

2. Test API connectivity:

# Test authentication
try:
    customer_service = client.get_service("CustomerService")
    customers = customer_service.list_accessible_customers()
    print("API connection successful")
except Exception as e:
    print(f"API connection failed: {e}")

3. Test conversion upload with dummy data:

# Use a test GCLID from Google Ads preview
test_conversion = {
    'gclid': 'Cj0KCQjw...',  # Use actual test GCLID
    'conversion_value': 1.00,
    'conversion_timestamp': datetime.now().isoformat()
}
upload_conversion_to_google(test_conversion)

4. Verify in Google Ads:

Acceptable variance: 15-25% variance between your CRM qualified leads and Google Ads converted clicks is normal due to attribution windows and users clearing cookies.

Red flags:

Troubleshooting

Problem: Conversions uploading but showing 0 conversions attributed → Solution: Check your attribution window settings. If leads are qualifying more than 90 days after click, they won’t attribute. Also verify the conversion_date_time is the qualification date, not the original click date.

Problem: “Invalid GCLID” errors in upload diagnostics → Solution: GCLIDs expire after 90 days and must be exact matches. Check that you’re capturing the full GCLID string and not truncating it in your database. VARCHAR(255) minimum for GCLID storage.

Problem: API authentication errors (“Request is missing required authentication”) → Solution: Your refresh token likely expired. Generate a new one using the Google Ads API OAuth2 flow. The developer token itself doesn’t expire, but the refresh token does after 6 months of inactivity.

Problem: Conversions uploading but not showing in campaign reports → Solution: Check if “Include in Conversions” is enabled for your offline conversion action. Also verify the conversion action is properly assigned to your campaigns (not just account-level).

Problem: Large discrepancies between CRM qualified leads and attributed conversions → Solution: Users often click multiple ads or visit directly later. Use Google’s Conversion Path reports to understand the customer journey. Also check if you have duplicate conversion actions that might be splitting the attribution.

Problem: “Conversion already exists” errors for legitimate new conversions
Solution: Google deduplicates based on GCLID + conversion action + conversion time. If you’re re-importing the same lead or have multiple systems sending the same conversion, add seconds/milliseconds to the timestamp to differentiate them.

What To Do Next

This guide is part of the Offline Conversion Tracking Hub — your complete resource for connecting online ad clicks to offline sales and lead qualification.