Google Ads Said the Upload Worked. It Didn't.

By Nicolas Garfinkel · 2026-08-29

Google Ads said the upload worked.

It didn't.

A phone lead can be uploaded through the wrong conversion path, receive an HTTP 200 response, and still record nothing. No error. No warning. Just zero offline conversions showing up in the account months later.

Nicolas (solernicolas041, he/him) built an open-source Python tool specifically to stop that failure: gads-offline-conversions.

Quick clarification: we didn't build this. This is Nicolas's work. We found it useful and wanted more people to know about it.

Most lead-gen accounts optimize toward the wrong thing

Most lead-generation accounts tell Google that a form submission is a conversion.

That form could become a €4,800 customer.

It could also be fake, unqualified, or someone who never answers the phone.

Google sees the same conversion either way. Then Smart Bidding does exactly what you asked it to do: find more people who submit forms.

Not more customers.

Not more revenue.

More forms.

Sending closed revenue back to Google Ads closes that loop. Your CRM already knows which leads became customers and what they were worth. The missing step is getting that information back into the advertising account without corrupting it along the way.

The mistake this tool refuses to let you make

Google Ads has different upload paths depending on how the lead arrived. Calls and click-based conversions do not use the same action type.

According to the project's documentation, sending a click conversion to an UPLOAD_CALLS action can be accepted by the API and record nothing. The tool reads the conversion action before uploading. If the selected mode and action type disagree, it stops.

The difference between “accepted” and recorded

A successful API request is not the same thing as a successful measurement system.

Wrong path
Phone lead sent as a click conversion Destination action is UPLOAD_CALLS API returns HTTP 200 Nothing is recorded
Guarded path
Tool reads the action from Google Ads Mode and action type are compared A mismatch stops the run Fix it before revenue disappears
This is a conceptual comparison based on the failure mode documented in the repository. The tool also refuses to upload to an action that is not enabled.

That check sounds small.

It's also the difference between fixing a configuration problem today and discovering months from now that your bidding system never received the revenue data.

Three modes for three lead paths

The tool supports the three common ways a lead gets connected back to an ad interaction:

calls For a lead who called a Google forwarding number. It matches the caller ID and exact call start time to an UPLOAD_CALLS action.
clicks For a stored gclid, wbraid, or gbraid. It writes to an UPLOAD_CLICKS action.
enhanced For CRM exports without a click ID. It hashes normalized email and phone identifiers locally for Enhanced Conversions for Leads.

The call mode has one particularly unforgiving requirement: the call start time needs to match Google's record to the second. If your CRM stores only the minute, the repository recommends querying call_view for the exact timestamp. Otherwise Google can reject the row with CALL_NOT_FOUND.

It assumes your CSV is messy

Because it probably is.

Spreadsheets add .0 to phone numbers. Leading zeros disappear. One system exports 1,234.56; another exports 1 234,56 €. CRM exports repeat the same deal once per interaction. Timestamps arrive without the account's UTC offset.

Nicolas built around those problems:

  • Phone numbers are normalized to E.164, including common spreadsheet damage.
  • Emails are trimmed and normalized before being SHA-256 hashed locally.
  • Both comma and period decimal formats are accepted.
  • The account's UTC offset comes from the API instead of being guessed.
  • Rows with the same identifier and timestamp are deduplicated unless you explicitly keep them.
  • Bad rows are skipped and reported with their original CSV line number instead of stopping the good rows.

Partial failure is enabled too. If Google rejects two rows out of nineteen, the other seventeen can still land, and the rejected results are mapped back to the rows you supplied.

That's the unglamorous work that makes an uploader trustworthy.

My favorite part: nothing uploads by default

The first run is a dry run:

python gads_offline.py \
  --customer-id 123-456-7890 \
  --conversion-action 987654321 \
  --mode calls \
  --csv won_jobs.csv

You get a report showing the action, mode, conversions, value, duplicates, and skipped rows.

Nothing leaves the machine until you add --live.

That is exactly how a tool touching real conversion and revenue data should behave.

Open source, without the SaaS layer

The project is written in Python, released under the MIT License, and depends on Google's Ads client library. It is not a hosted product. There is no SaaS signup, and the repository says the only account write path is the conversion upload itself.

You still need Google Ads API access, OAuth credentials, a developer token, and a correctly configured conversion action. You should read the code, begin with a controlled CSV, inspect the dry run, and confirm the results in Google Ads before scheduling anything.

This is excellent work by Nicolas (solernicolas041).

We did not create it. We are not contributors, and we are not presenting it as ours. We are sharing it because it solves a subtle, expensive problem with sensible guardrails.

Take a look at the original project, read the documentation, and open an issue if you find something broken:

github.com/solernicolas041/gads-offline-conversions

google ads offline conversions smart bidding open source