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
- Offline conversion actions properly configured in Google Ads with correct attribution windows
- Server-side infrastructure capturing leads with Google Click ID (GCLID) or Customer Match data
- Automated API calls sending conversion data to Google within their 90-day window
- Proper conversion values and metadata flowing through for bid optimization
- Testing workflow to verify conversions are being accepted and attributed correctly
Prerequisites
- Google Ads account with conversion tracking enabled
- Google Ads API access (or Google Ads Developer Token)
- Server-side environment (Cloud Run, AWS Lambda, or dedicated server)
- Access to your CRM/sales system where offline conversions occur
- Admin access to Google Tag Manager (for GCLID capture)
- Basic understanding of REST APIs and JSON formatting
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:
- Conversion name: Use something descriptive like “CRM-Lead-Qualified” or “Sales-Closed-Won”
- Category: Choose the most accurate option (Lead, Purchase, etc.)
- Value: Select “Use different values for each conversion” if your deals vary in size
- Count: “One” for most B2B setups, “Every” if multiple purchases per customer matter
- Click-through attribution window: 90 days maximum (Google’s limit for offline imports)
- View-through attribution window: 1 day (offline conversions rarely attribute to views)
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:
- Type: “1st Party Cookie”
- Cookie Name:
_gcl_aw
Create a trigger for form submissions:
- Type: “Form Submission”
- Configure for your specific forms
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:
gclid: Must be the exact GCLID from the original click (48+ character string)conversion_action: Resource name format (customers/{customer_id}/conversionActions/{conversion_action_id})conversion_date_time: ISO 8601 format in account’s timezoneconversion_value: Numeric (can be 0)currency_code: 3-letter ISO code
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:
- Visit your site with
?gclid=test123in the URL - Submit a form and verify the GCLID is captured in your database
- Check that the GCLID persists across page views (cookie working)
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:
- Go to Tools & Settings → Conversions
- Click on your offline conversion action
- Check “Recent conversion activity” for successful imports
- Look for the “Diagnostic” column showing import status
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:
- 0 conversions showing up after 48 hours
- Error rates above 10% in import diagnostics
- All conversions showing “Conversion already exists” errors
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
- Set up Enhanced Conversions for your Google Ads forms to complement offline tracking
- Configure Customer Match uploads for your existing customer database
- Implement server-side tracking for better data quality
- Get a free audit: Contact me for a free review of your current offline conversion setup
This guide is part of the Offline Conversion Tracking Hub — your complete resource for connecting online ad clicks to offline sales and lead qualification.