Blog

How to send conversions to Google Ads the right way (with code)

A developer's guide to setting up conversion tracking in Google Ads using Google's Data Manager API

How to send conversions to Google Ads the right way (with code)

Are you trying to set up server-side conversion tracking in Google Ads?

If so, this guide is for you. We built an integration between Converly (our dedicated conversion tracking product) and the new Google Data Manager API and did a lot of probing and testing in the process, and found some quirks with the API that were genuinely surprising.

So in this article, we'll walk through the whole thing from start to finish. We'll show you what data Google accepts, which 2 fields do nearly all of the work, and exactly how to capture, hash and send a conversion so that Google can tie it back to a real ad click. There's example code at every step, and the warnings that matter are sitting in the steps where you'd actually hit them.

Why doing server-side conversion tracking is better

Google gives you a tag you can drop on your website, so why go to the trouble of sending conversions from your own server?

The main reason is deliverability. When a conversion fires through the Google tag, it's basically being sent to Google through the visitor's browser, which means ad blockers, iOS tracking restrictions, Safari's cookie limits, script errors, and a growing pile of privacy features in other browsers will stop your conversions from ever making it to Google.

Various studies show that client-side tracking will drop between 30 and 40%, which is huge. When that much data goes missing, the numbers in your reports come in lower than reality and Google's Smart Bidding struggles to learn who your ideal customer is (which means it will just bid the same for everybody, regardless of how good of a fit the searcher is for your business).

That's why sending the data server-side to Google Ads matters. If you can recover 30+% of your conversions, then Google is going to have the data it needs to understand who your ideal customer is, and it will be able to serve your ads more prominently to searchers it thinks will convert (which ultimately results in more leads at a lower cost per lead).

There's actually even data on all of this. Google's own research shows server-side tracking typically delivers a 23% lift in recorded conversions and a 10% drop in cost per conversion, so even if setting up server-side tracking is a bit of a pain, it's usually worth doing.

What data Google Ads accepts

A conversion is built out of 3 layers. The request wrapper says which account and which conversion action you're sending to, the event describes what happened, and the identifiers inside it are what Google uses to tie the conversion back to a real ad click. Here's the full list, where each field lives, whether it needs to be hashed, and how much it actually matters.

Data pointFieldWhereHashed?What it isImportance
Account IDoperatingAccount.accountIdDestinationNoYour Google Ads customer ID, digits onlyRequired
Conversion actionproductDestinationIdDestinationNoThe numeric conversion action IDRequired
Manager accountloginAccount.accountIdDestinationNoOnly when access is through an MCCConditional
Hash encodingencodingRequestNoHEX or BASE64, and it is requiredRequired
Dry runvalidateOnlyRequestNoValidates without recording anythingOptional
Transaction IDtransactionIdEventNoYour unique ID, used for deduplicationVery high
TimestampeventTimestampEventNoRFC 3339 with a Z, not an epoch numberRequired
Event sourceeventSourceEventNoWEB for a website conversionRequired
ValueconversionValueEventNoA plain decimal, so 129.99 means $129.99Optional
CurrencycurrencyEventNo3-letter ISO codeOptional
Consentconsent.adUserDataEventNoCONSENT_GRANTED or CONSENT_DENIEDRecommended
Click IDadIdentifiers.gclidEventNoFrom the gclid on the ad click URLVery high
iOS click IDadIdentifiers.gbraidEventNoApp to web iOS journeysHigh
iOS click IDadIdentifiers.wbraidEventNoWeb to web iOS journeysHigh
Landing IPlandingPageDeviceInfo.ipAddressEventNoThe visitor's IP, read on your serverMedium
Landing UAlandingPageDeviceInfo.userAgentEventNoThe browser's user agent stringMedium
Event IPeventDeviceInfo.ipAddressEventNoThe IP at the moment of conversionMedium
EmailuserIdentifiers[].emailAddressUser dataYesThe visitor's email addressVery high
PhoneuserIdentifiers[].phoneNumberUser dataYesE.164 format, so with a leading +Very high
First nameaddress.givenNameUser dataYesOnly counts as part of a full addressLow
Last nameaddress.familyNameUser dataYesOnly counts as part of a full addressLow
Countryaddress.regionCodeUser dataNo2-letter code, sent in plain textLow
Postcodeaddress.postalCodeUser dataNoSent in plain textLow

There are 4 things in that table worth committing to memory before you write a line of code, because each one is a way to build something that looks completely healthy and matches nobody.

  • Google checks the shape of your hashes and never the contents - Send a raw, unhashed email address and you get a clean 400 telling you the hex encoding is malformed, which is genuinely helpful. But if you send the SHA-256 of an empty string (which is a valid 64-character hex value) then Google accepts it without a word. That second one is far more damaging than the first, because it actually buckets every visitor whose email you failed to capture into the same imaginary person. We'll come back to this properly in Step 5.
  • Only 2 things will really get you a match - Those are the click ID (gclid, or one of the braid variants on iOS) and hashed contact details (email or phone). Everything else in that table is supporting signal. A conversion carrying neither of those will be accepted but never actually attributed to one of your campaigns.
  • Some fields are hashed and some must never be, and the split runs through the middle of one object - Email and phone are hashed. Click IDs, IP addresses and user agents go over in plain text. And inside a single address object, the first and last name are hashed while the country and postcode are sent in the clear. Get that backwards and Google tells you the hex encoding is malformed.
  • Names on their own are worthless here - Google requires all 4 of first name, last name, country and postcode together before an address counts as an identifier. Send 3 of them and you get a 400 naming the missing one. Since most lead forms don't ask for a postcode, in practice this means names are usually not worth capturing for Google Ads at all.

9 steps to send conversions to Google Ads the correct way

Step 0: Things to do before you start (the bits that aren't code)

Before any of the code below will actually work, a handful of things need to exist across your Google Cloud project and your Google Ads account. None of them involve writing code, but several of them can take days to be approved, and they block everything that follows. So it's worth getting these sorted first and then writing the code while you wait for the approvals to come through.

  1. Enable the Data Manager API in your Google Cloud project. Head to APIs and Services, then Library, search for "Data Manager API" and enable it. It has to be enabled in the same project that owns the OAuth client you'll be making calls with.
  2. Add the datamanager OAuth scope to your consent screen. The full scope string is https://www.googleapis.com/auth/datamanager. This is a sensitive scope, so Google requires your app to go through OAuth verification before real users can grant it (which requires putting together a video and full information pack on how you are using it). If your app is already verified, adding this scope sends you back for another review, so be careful not to break anything else that may be relying on it.
  3. Accept the customer data terms in Google Ads. Enhanced conversions are gated until you accept the terms in Goals, then Settings, inside your Google Ads account. This one deserves special attention because when it hasn't been accepted, your events are still accepted with a perfectly happy 200, and will then fail later during the processing stage (and there is no way to know it happened unless you're running some very specific checks).
  4. Create your conversion action, and know which type it is. This is the one people skip, and it matters far more than it looks (there's a lot more on this just below).
  5. Decide whether your conversion carries a value. If a lead is worth a known dollar amount to your business, work that number out now. Unlike some other conversion APIs, Google takes a plain decimal, so $129.99 goes over as 129.99 and you don't need to do anything clever with cents.

Picking your conversion action type (and why you probably can't change it later)

Google has 2 different doors into Data Manager, and the door you go through is decided by the type of the conversion action sitting in your Google Ads account, not by anything in your request.

  • UPLOAD_CLICKS is for offline conversions and enhanced conversions for leads. Your uploaded event is the conversion.
  • WEBPAGE is for multi-source conversions, where the Google tag on your website fires the conversion and your server upload adds to it. This is the setup we're building, because it's the redundant one that survives the browser being blocked.

Here's the trap. If you're troubleshooting and someone suggests switching your conversion action to UPLOAD_CLICKS to "make it a proper server-side conversion", that change will silently break your browser leg. An UPLOAD_CLICKS action has no tag snippet, which means there's no conversion label for the Google tag to fire at, so the browser half of your setup simply stops existing. You'll have traded a redundant setup for a fragile one while believing you did the opposite.

We probed this to be sure, and the API will not help you here either. A multi-source shaped payload sent at an UPLOAD_CLICKS action is accepted without comment. Google enforces the type in its processing, never in its response.

One thing the API does enforce is the transaction ID, and it does it differently per type:

Conversion action typeTransaction ID sent?What happens
WEBPAGEPresentAccepted
WEBPAGEMissing or emptyRejected, REQUIRED_FIELD_MISSING
UPLOAD_CLICKSPresentAccepted
UPLOAD_CLICKSMissingAccepted

So on the multi-source path we're building, forgetting the transaction ID fails loudly, which is the good outcome.

The wait nobody documents

This is the single most useful thing in this article, so it gets its own heading.

When you create a brand new conversion action, it does not work immediately. Google Ads creates it instantly and hands you back an ID, and you can see it in the Google Ads interface straight away, but the Data Manager API cannot see it yet. Send a conversion to it in that window and it bounces.

We measured this properly. We created a conversion action and then pinged the API with a validation-only request every 15 seconds until it started passing.

RunConversion action createdFirst accepted
108:47:38 UTC10 min 13 s later
208:49:36 UTC11 min 39 s later
309:00:40 UTC10 min 57 s later

Every rejection in between was identical, and every request was byte for byte the same apart from its transaction ID. Nothing changed except the clock. On an earlier occasion we watched the same thing happen to a real customer conversion, which failed at 5 minutes 16 seconds and then went through at 14 minutes 2 seconds.

So the answer is roughly 10 to 12 minutes, and you should budget 15. Treat that as a typical case rather than a ceiling, because 3 runs on one account is not a large sample and Google could reasonably be slower under load or in a different region.

There is no documentation for any of this. Google's error vocabulary for Data Manager has no "too recent" reason at all, so the condition comes out as a generic failure. The closest thing to guidance is the troubleshooting page for the older Google Ads API, which tells you to wait at least 6 hours after creating a conversion action before uploading to it. That page is still current, it has no equivalent for Data Manager, and 6 hours is a lot more conservative than anything we measured.

What makes it genuinely nasty is the timing. A person creating conversion tracking for the first time creates their conversion action and then immediately submits their own form to check the setup works. That test conversion is the one that gets thrown away, and the error they get sends them off to check whether the conversion action exists, which it does.

The good news is that the error is distinguishable if you know what to look for. We probed all 3 failure modes:

What's wrongWhat Google returns
The conversion action was just created400 INVALID_ARGUMENT, with a field violation on destination_references and a reason of NOT_FOUND
The conversion action ID doesn't exist404 NOT_FOUND, message "Conversion action ID is not valid.", reason INVALID_CONVERSION_ACTION_ID
You don't have access to the account403 PERMISSION_DENIED

So a 400 pointing at destination_references means wait, and a 404 means go and fix your configuration. Nothing in the documentation tells you that, and the practical upshot is that your code should treat the first one as temporary and retry it later rather than marking the conversion as permanently failed.

2 more things to know before you start clicking

Multi-source conversions don't feed bidding for the first 14 days. Google runs a trial period on each conversion action when you start sending data from a second source, and during that window the data shows up in your reporting but does not flow into Smart Bidding, and value overrides are switched off. This is not mentioned in any customer-facing copy that we can find, and it means somebody who switches this on and expects their campaigns to start performing better next week is going to be disappointed for reasons that have nothing to do with their code.

Don't put both the old and the new conversion action in the same campaign goals. If you create a fresh conversion action for this rather than reusing your existing one, Google's own help documentation warns that having both counted in the same campaign will double count your conversions. It's an easy mistake to make because creating a clean new conversion action feels like the tidy thing to do.

Step 1: Capture the click ID the moment someone lands

With the account side sorted, it's time to start collecting data. The most valuable thing you will ever send back to Google Ads is the Google Click ID (GCLID), because it links a conversion directly to the ad click that produced it.

So where does this ID come from? Well, when somebody clicks one of your Google ads, they land on your site with a gclid parameter in the URL (so yoursite.com/?gclid=ABC123). On iOS, where Google can't always use a regular click ID, you get gbraid or wbraid instead.

Like all URL parameters, they vanish from the URL as soon as the visitor clicks through to a second page, so you have to grab them on landing and store them somewhere that survives until they eventually convert.

Here's a small function that reads whichever one is present and writes it into a first-party cookie:

// Google's click IDs are URL-safe tokens. Anything else is somebody // messing with your query string. const CLICK_ID_RE = /^[A-Za-z0-9._~-]{1,512}$/; function captureGoogleClickId() { const params = new URLSearchParams(window.location.search); // gclid wins when more than one is present. The braid IDs only turn up // on iOS journeys where a normal gclid isn't available. for (const name of ['gclid', 'gbraid', 'wbraid']) { const value = params.get(name); if (!value || !CLICK_ID_RE.test(value)) continue; document.cookie = '_conv_' + name + '=' + encodeURIComponent(value) + '; max-age=' + 60 * 60 * 24 * 90 + '; path=/; domain=.yoursite.com; SameSite=Lax; Secure'; return; } }

The code above looks through the query string for each of Google's 3 click ID parameters, checks that the value actually looks like a click ID rather than junk somebody appended to your URL, and writes the first one it finds into a cookie that's readable across all of your subdomains. That subdomain part is worth doing, because it means a click that landed on your marketing site (i.e. converly.io) is still attached to the visitor when they convert over on a subdomain (i.e. app.converly.io) a week later.

Run this on every single page load, not just on the pages you think your ads point at. Your marketing team will eventually launch a campaign pointing somewhere you didn't expect, and if this only runs on selected pages then those conversions arrive with nothing to match on.

There's one storage detail worth knowing though. If you write that cookie with JavaScript, Safari's Intelligent Tracking Prevention caps how long it lives at 7 days, and in some situations as little as 1 day. Somebody who clicks an ad today and converts next week will arrive with nothing. The only durable fix is for your site's own origin server to set the cookie in an HTTP response header, because a cookie set that way doesn't get capped in the same manner. And it genuinely has to be your own origin too, because Safari also caps cookies at 7 days when it detects CNAME cloaking (which is where a subdomain like track.yoursite.com is really an alias pointing at somebody else's tracking host).

Step 2: Generate a transaction ID for each conversion

If you haven't set up conversion tracking before, this next bit is going to sound wrong, but you're deliberately going to send each conversion twice. Once from the browser through the Google tag (that's Step 3) and once from your server through the Data Manager API (that's Step 7).

Redundancy is the goal here. If the browser event gets blocked by an ad blocker, the server event still lands. But if both of them arrive successfully, Google needs a way to recognise that the 2 events it just received describe the same conversion, and that's what the transaction ID is for. When the tag conversion and the API conversion carry the same transaction ID against the same conversion action, Google collapses them into one.

To set this up, you simply generate one ID when the conversion happens and use it in both places:

const transactionId = crypto.randomUUID(); // e.g. "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"

crypto.randomUUID() is built into modern browsers and into Node, so there's nothing extra to install. Any unique string works, as long as it's identical across both the server-side and client-side conversion. Google is relaxed about the format, and in our testing we confirmed it will accept a 4096-character string containing spaces and unicode, so a readable ID from your own database is perfectly fine.

If your form redirects off to a thank-you page and you fire the browser event from there rather than in place, that variable is long gone by the time the new page loads. So park the ID in sessionStorage before the redirect and read it back on the other side, which keeps both halves of the conversion carrying the same one.

// before the redirect sessionStorage.setItem('conv_transaction_id', transactionId); // then on the thank-you page const transactionId = sessionStorage.getItem('conv_transaction_id');

Step 3: Fire the browser leg through the Google tag

With the ID generated, the next step is to fire the browser half of the conversion.

gtag('event', 'conversion', { send_to: 'AW-123456789/AbC-D_efGh12ijKLmno', // from your conversion action value: 129.99, currency: 'USD', transaction_id: transactionId // the same ID from Step 2 });

The code above is a standard Google Ads conversion snippet that tells the Google tag that a conversion happened, points it at the specific conversion action using the send_to label, and attaches the transaction ID that makes deduplication work.

If you've never seen it before, that send_to value is the conversion label. You'll find it in Google Ads under the tag setup instructions for the specific conversion you are trying to fire to. This is also where the WEBPAGE versus UPLOAD_CLICKS decision from Step 0 comes in, because if the conversion is set up as an Offline Conversion (i.e. UPLOAD_CLICKS) then it won't have any of these available. So if you're looking at the tag instructions and there's no snippet there, you have the wrong type of conversion action for this setup.

It's also important that you only fire this on a confirmed, successful conversion. Many tracking setups we have seen in the past fire the conversion on a button click (i.e. clicking the submit button on a form) or a thank you page visit, and there are all sorts of reasons why this is problematic. For instance, if you're firing it on a button click then you are essentially counting the conversion before it has been validated. The user might have entered their email address in the wrong format, left a required field empty, or their payment method might be declined. If you fire on button click, you've recorded a conversion that never happened, and when they fix the problem and submit the form again, you end up firing a second conversion for the same lead.

So wait for the real success signal instead. Depending on your form tool and how it's configured, that usually means waiting for the success message to appear or for the form tool to fire its own "submitted" event back to the browser.

Step 4: Send everything to your server

Now that the browser side is done, the next step is to send all the information from the browser over to your server.

The problem is, the information you need to send is scattered around in lots of different places. There's the transaction ID you generated in Step 2, the click ID sitting in the cookie from Step 1, the page URL where the conversion happened, and whatever details you were able to get from the form (like the lead's name and email).

All of that has to arrive at your server together as one record, so as soon as the form is successfully submitted, bundle it up and post it to your own endpoint:

function sendToServer(transactionId, email, phone) { const payload = JSON.stringify({ transactionId, sourceUrl: window.location.href, gclid: readCookie('_conv_gclid'), gbraid: readCookie('_conv_gbraid'), wbraid: readCookie('_conv_wbraid'), email, phone, }); // keepalive is what lets this survive the page navigating away, which a // form submission almost always does. fetch('/track/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, keepalive: true, }); } function readCookie(name) { const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); return match ? decodeURIComponent(match[2]) : null; }

The code above reads each of the click ID cookies you set in Step 1, adds the transaction ID, the page URL and whatever data the form collected, then posts the lot off to your own tracking endpoint. The readCookie helper underneath is just a small utility for pulling a cookie value out by name.

That keepalive: true is the part to pay attention to, because leaving it off causes a bug that is genuinely horrible to track down. Submitting a form often navigates the page somewhere else, and when a page unloads the browser cancels any requests still in flight, so your conversion gets thrown away before it ever leaves the browser (what makes it so unpleasant to debug is that it doesn't fail consistently. On a fast connection, the request usually finishes before the navigation happens and everything looks perfectly healthy, and then on a slow phone connection it disappears).

If you'd rather use navigator.sendBeacon, that works too, but you have to wrap the payload in a Blob with an explicit type. A raw string gets sent as text/plain, which a JSON endpoint won't parse:

const blob = new Blob([payload], { type: 'application/json' }); navigator.sendBeacon('/track/event', blob);

The 2 things only your server can see

If you look at the data table at the top of this article (the one that shows everything Google Ads accepts), you'll notice there are 2 fields we still haven't accounted for, and that's because you can't reliably get either of them in the browser.

The first is the IP address. A browser can't tell you its own public IP, so you read it off the incoming request instead. If you're sitting behind a proxy, a load balancer or a CDN, which you almost certainly are, it'll be in the X-Forwarded-For header rather than on the connection itself.

The second is the user agent. You can read navigator.userAgent in the browser and send it along with everything else, but the cleaner source is the User-Agent header on the request, because it's one less thing a client can lie to you about.

So your endpoint reads both off the request and stores them alongside everything the browser sent:

app.post('/track/event', (req, res) => { const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip; const userAgent = req.headers['user-agent']; // Stamp the time HERE, on your own server, rather than trusting a // timestamp generated in the visitor's browser. Step 6 explains why. const occurredAt = new Date().toISOString(); // Write it down BEFORE trying to forward it to Google. If the forward // fails, you still have the conversion and can retry from the stored copy. saveEvent({ ...req.body, ip, userAgent, occurredAt }); res.sendStatus(202); });

The code above pulls the IP out of the forwarding header, falls back to the connection IP if there's no proxy in front of it, reads the user agent off the request, stamps the time the conversion arrived, and saves the whole lot down as one record.

It's worth noting that the ordering in there is deliberate and worth copying. Write the event down first, acknowledge it, and only then try to forward it on to Google. If you send first and store second, any forwarding failure loses the conversion entirely, with nothing left to retry. But if you store it first then you can resend and the worst case is a conversion that arrives a bit late.

Step 5: Normalise and hash the contact details

With everything sitting on your server, the next job is turning the visitor's email address and phone number into the hashes Google expects.

Google matches a hashed value by comparing it against its own hashed copy of the data. So for that comparison to work, you and Google have to hash the value in exactly the same way, which means normalising it identically before you hash it. If you get the normalisation wrong then the hashes never line up, and you may as well not send the field at all.

Normalising Gmail addresses

Google's Data Manager API asks for something no other ad platform does. For gmail.com and googlemail.com addresses only, you have to strip every dot out of the part before the @, and drop any +suffix. So Cloudy.SanFrancisco+shopping@gmail.com normalises to cloudysanfrancisco@gmail.com. This only applies to Gmail and googlemail.com addresses.

This is worth noting because most other ad platforms (like Meta) explicitly tell you not to do this. Even the old Google Ads API told you not to do this. So if you've built a Meta integration before or are migrating a Google Ads API setup to a Data Manager API one, and you try to use the same hashing helper, you will produce a hash that Google can never match.

Here's what that normalisation looks like:

import { createHash } from 'node:crypto'; function normaliseEmailForGoogle(raw) { if (typeof raw !== 'string') return null; const compact = raw.trim().toLowerCase().replace(/\s+/g, ''); // Check the structure BEFORE hashing. A malformed address produces a // perfectly valid-looking hash that Google will accept and never match. const at = compact.indexOf('@'); if (at === -1 || at !== compact.lastIndexOf('@')) return null; let local = compact.slice(0, at); const domain = compact.slice(at + 1); if (!local || !domain) return null; // Google's gmail-only rules. Everything else keeps its dots and plusses. if (domain === 'gmail.com' || domain === 'googlemail.com') { const plus = local.indexOf('+'); if (plus !== -1) local = local.slice(0, plus); local = local.replace(/\./g, ''); if (!local) return null; } return local + '@' + domain; } function hashEmailForGoogle(raw) { const normalised = normaliseEmailForGoogle(raw); if (!normalised) return null; return createHash('sha256').update(normalised).digest('hex'); }

The code above trims and lowercases the address, checks that it genuinely has one @ with something on either side of it, applies Google's dot and plus stripping when the domain is Gmail, and only then hashes the result. When the address is unusable, it returns null rather than a hash.

To make the divergence concrete, here's a table with multiple different email addresses hashed both ways:

AddressGoogle (Data Manager)Meta (CAPI)
Cloudy.SanFrancisco+shopping@gmail.com223ebda6f6889b14…8dd4a9bf85024287…
jane.doe@gmail.comd6117306485ed0e5…831f6494ad6be4fc…
jane.doe@outlook.comfbe03d125defcc07…fbe03d125defcc07…
jane+tag@fastmail.com9acede022693d323…9acede022693d323…

As you can see above, the same email address can have 2 completely different hashes depending on where you're sending it and subsequently how much normalisation you do.

Formatting the phone number

Google wants the phone number in E.164, which means a leading + followed by the country code and the digits, so +14155552671.

function hashPhoneForGoogle(raw) { if (typeof raw !== 'string') return null; const digits = raw.replace(/\D/g, ''); if (!digits) return null; // Google wants E.164, so the '+' goes back on before hashing. return createHash('sha256').update('+' + digits).digest('hex'); }

The code above strips every non-digit character out of whatever the visitor typed, then puts a + back on the front before hashing.

There's a real limitation hiding in that function though, and it's worth being honest about it rather than papering over it. Stripping non-digits only preserves a country code if the visitor typed one, and cannot invent one if they didn't. So if somebody is in the US and enters their phone number as (415) 555-2671 then the above code would produce +4155552671 (which is missing the +1 country code). That's technically not their phone number in E.164 so Google Ads wouldn't be able to use it to match the conversion to a real person and the original ad they clicked. Fixing that properly means bringing in a phone number parsing library and configuring a default country per site, which is outside the scope of this article.

Validating the data

Here's where the Data Manager API gets interesting, and it's the thing that shapes how you should build and test everything.

We fired a series of deliberately broken identifiers at the live API to see what it would catch. These are the results.

What we sent as the hashed emailWhat Google said
A correct SHA-256 hex digestAccepted
The hash truncated to 63 characters400, INVALID_HEX_ENCODING
The hash with 2 non-hex characters in it400, INVALID_HEX_ENCODING
A raw, unhashed email address400, INVALID_HEX_ENCODING
Base64 of the hash, with encoding: HEX set400, INVALID_HEX_ENCODING
An empty string400, REQUIRED_FIELD_MISSING
The SHA-256 of an empty stringAccepted
A phone number hash, in the email fieldAccepted
A phone hashed in Meta's format, without the +Accepted
The hash in uppercase hexAccepted

As you can see from the table above, Google validates the encoding of what you send with real care, but doesn't validate the actual data at all. It is essentially a spellcheck, not a correctness check.

And notice which way round the failures fall. Sending a raw email address is a mistake you'd catch in about 4 seconds anyway, and Google stops you with a clear error. Sending the SHA-256 of an empty string is catastrophic, because it means every visitor whose email you failed to capture gets bucketed into one imaginary person, and Google waves it straight through with a 200. The more damaging mistake is the silent one.

This is exactly why the hashing functions above return null instead of a hash when the input is unusable. Never hash an empty string, and never send a field you don't genuinely have a value for.

The identifier check that isn't really a check

Google does verify that an event carries at least one identifier, and rejects it with NO_IDENTIFIERS_PROVIDED when it doesn't. That sounds like a useful safety net, but it actually isn't, because Google Ads isn't really checking the validity of the identifier. See the table below from our testing:

The only identifier on the eventWhat Google said
A hashed emailAccepted
A real IP addressAccepted
ipAddress set to 0.0.0.0Accepted
ipAddress set to not.an.ip.addressAccepted
ipAddress set to the word helloAccepted
A gclid of xAccepted
A user agent and nothing else400, NO_IDENTIFIERS_PROVIDED
Nothing at all400, NO_IDENTIFIERS_PROVIDED

An event whose only identifier is the string "hello" in the IP address field passes Google's identifier check, so realistically you need to validate every identifier in your own code before it goes out the door, with a test covering the accept case and the reject case, because Google will not do it for you and will not tell you it hasn't (and any test you write that asserts "the API returned a 200" is testing almost nothing).

Step 6: Build the payload

With the contact details normalised and hashed and the identifiers gathered up, you can assemble the actual request. Here's a function that builds the whole thing:

function buildIngestBody({ customerId, conversionActionId, loginCustomerId, transactionId, occurredAt, gclid, gbraid, wbraid, email, phone, ip, userAgent, value, currency, }) { const destination = { operatingAccount: { accountType: 'GOOGLE_ADS', accountId: String(customerId).replace(/-/g, ''), // digits only }, productDestinationId: String(conversionActionId), }; // Only when you reach the account through a manager (MCC) account. if (loginCustomerId) { destination.loginAccount = { accountType: 'GOOGLE_ADS', accountId: String(loginCustomerId).replace(/-/g, ''), }; } const userIdentifiers = []; const emailHash = hashEmailForGoogle(email); if (emailHash) userIdentifiers.push({ emailAddress: emailHash }); const phoneHash = hashPhoneForGoogle(phone); if (phoneHash) userIdentifiers.push({ phoneNumber: phoneHash }); const event = { transactionId, // required for WEBPAGE actions eventTimestamp: occurredAt, // RFC 3339 with a Z, see below eventSource: 'WEB', consent: { adUserData: 'CONSENT_GRANTED', // NOT 'GRANTED', see below adPersonalization: 'CONSENT_GRANTED', }, }; if (typeof value === 'number') { event.conversionValue = value; // a plain decimal, 129.99 event.currency = currency || 'USD'; } const adIdentifiers = {}; if (gclid) { adIdentifiers.gclid = gclid; if (userIdentifiers.length) event.userData = { userIdentifiers }; } else if (gbraid || wbraid) { // Braid IDs must travel WITHOUT contact details. Attaching them marks // the upload as enhanced conversions for leads, which Google does not // support for braid IDs. The API accepts the combination anyway and // discards it later, so this guard has to live in your code. if (gbraid) adIdentifiers.gbraid = gbraid; else adIdentifiers.wbraid = wbraid; } else { if (userIdentifiers.length) event.userData = { userIdentifiers }; } // IP and user agent are never hashed. Omit them rather than sending // empty strings. const deviceInfo = {}; if (ip) deviceInfo.ipAddress = ip; if (userAgent) deviceInfo.userAgent = userAgent; if (Object.keys(deviceInfo).length) { adIdentifiers.landingPageDeviceInfo = deviceInfo; } if (Object.keys(adIdentifiers).length) event.adIdentifiers = adIdentifiers; // No click ID and no contact details means nothing Google can match on. if (!gclid && !gbraid && !wbraid && !userIdentifiers.length) return null; return { destinations: [destination], encoding: 'HEX', events: [event] }; }

The code above assembles a destination block that tells Google which account and conversion action you're sending to, hashes whatever contact details you have, applies Google's click ID rules, attaches the IP and user agent in plain text, and returns the finished request body. When there's nothing Google could possibly match on, it returns null instead so the caller can skip the request entirely.

The first 3 arguments come from your configuration rather than from the conversion. The customer ID and conversion action ID are the ones you noted down in Step 0, and loginCustomerId is only set when you reach the account through a manager account. Everything else comes from the record your endpoint saved in Step 4 (apart from the value and the currency, which usually come from your configuration as well, since what a lead is worth to you is a business decision rather than something the browser could ever know).

There are 5 deliberate choices baked into that which are worth calling out.

  • Every field is added conditionally - Nothing gets set unless there's a real value for it, because an empty string is still a value as far as this API is concerned. If you haven't got something, leave the key out entirely rather than sending it empty.
  • The braid rule has to be enforced by you - Google's platform rule is that attaching contact details to an upload marks it as enhanced conversions for leads, and that mode does not support the iOS braid click IDs. We sent that exact combination to the live API and it was accepted with a 200 both times. So the rule is real, the enforcement happens silently during processing hours later, and the only place it can be caught is your own code.
  • encoding is required and lives at the request level - Leave it off and the whole request is rejected with REQUIRED_FIELD_MISSING. It's easy to miss because it doesn't sit with the identifiers it describes. And if you declare BASE64 while sending hex, you get a different error again (INVALID_SHA256_FORMAT).
  • The consent enums are different in the Data Manager API - Data Manager wants CONSENT_GRANTED and CONSENT_DENIED. The older Google Ads API used GRANTED and DENIED, and sending those gets you a 400 that names the invalid enum value. Interestingly, leaving the consent block out entirely is accepted, so this is really only an issue for people migrating from the old API.
  • The value is a plain decimal - 129.99 means $129.99. There's no minor-unit conversion to do here, which is a pleasant change if you've just come from an API that wanted cents. Google will also accept the value as a string and will accept zero, but a negative value is rejected with a slightly confusing INVALID_NUMBER_FORMAT error that talks about strings.

A note on the timestamp

The eventTimestamp field takes RFC 3339, so 2026-08-27T08:44:39.118Z. An epoch number gets you a protobuf-level type error, and a timestamp without the trailing Z or an explicit timezone offset is rejected with a message telling you exactly that. Those are the easy failures.

The interesting one is the window. Google rejects out-of-range timestamps with an error called EVENT_TIME_INVALID, which is described in their docs as "Event did not occur within the acceptable time window". This certainly makes it sound like a window with 2 edges, but it turns out there is only one. Here are the results of our testing on this:

TimestampResult
1 hour agoAccepted
30 days agoAccepted
1 year agoAccepted
5 years agoAccepted
20 years agoAccepted
50 years ago (1976)Accepted
Now, exactlyAccepted
Now plus 1 secondRejected
Now plus 30 secondsRejected
Now plus 5 minutesRejected

As you can see in the table above, a conversion timestamped 1976 is accepted without any issues, while a conversion timestamped one second from now is rejected.

The practical consequence of this is that, if the clock on the server sending your conversions runs even slightly fast, every single conversion you send fails (and the error message points at the age of the event rather than at your clock). So make sure your servers run NTP, and if you're generating the timestamp in the browser and trusting it on the server, be aware that you're now depending on the visitor's clock being correct, which it may not be (generating the timestamp on your server when the event arrives is the safer choice).

Similarly, there is also the case where certain details arrive later. Some form tools will only give you the lead's name, email, phone, etc if you call them from the API, so you essentially have to detect the form submission client-side and then make an API call to get the lead's details (which means it may not be until a few minutes after the conversion that you actually have assembled all the data you need). In that case, the timestamp still has to be when the conversion actually happened, not when your code finally got around to sending it.

Step 7: Send it to the Data Manager API

With a valid body built, there are only 2 things left. You need an access token, and then you need to post the body.

1. Getting an access token

Every call to Data Manager carries an OAuth access token in the Authorization header, and those tokens are short-lived, so this isn't something you generate once and paste into your config. The flow has 3 parts, and only the middle one runs on every conversion.

The one-off part is the consent screen. Whoever owns the Google Ads account goes through Google's OAuth flow and grants the datamanager scope you set up in Step 0, and you get a refresh token back. That refresh token is the long-lived credential, and it's the thing that belongs in your secret manager.

The repeated part is trading that refresh token for an access token, which is a single form-encoded POST:

async function getAccessToken(refreshToken) { const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: process.env.GOOGLE_OAUTH_CLIENT_ID, client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET, refresh_token: refreshToken, grant_type: 'refresh_token', }), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error('Token exchange returned ' + res.status + ': ' + text.slice(0, 200)); } const json = await res.json(); if (!json.access_token) throw new Error('OAuth response missing access_token'); return json.access_token; }

The code above posts your client credentials along with the refresh token to Google's token endpoint, and hands back an access token you can use for the next hour.

Now the part that matters once you're running any real volume. Google's access tokens last 3600 seconds, so if you mint a fresh one for every conversion you'll pay a round trip to Google's OAuth endpoint before every single upload, and you'll eventually get rate limited for it.

To get around it, we recommend caching it. In our conversion tracking product, we cache for 50 minutes rather than the full 60, which leaves a 10 minute margin for clock skew and for however long the ingest call itself takes, so a token can't quietly expire mid-flight.

There's a subtlety in how you key that cache which is worth copying though, because getting it wrong produces a genuinely baffling bug. Key it on a fingerprint of the refresh token, not just on the account. If somebody reconnects their Google account they get a brand new refresh token, and a cache keyed only on the account would carry on serving an access token minted from the old one until the TTL ran out. Folding the refresh token into the key means a reconnect is automatically a cache miss, so a fresh token gets minted immediately.

Speaking of failures, there are 2 common ones here worth handling separately:

  • invalid_grant from the token endpoint - The refresh token is dead, usually because the user revoked access or changed their password. This is permanent. Retrying fails identically forever, so clear any cached token, mark the connection as broken, and tell somebody rather than burning through retries.
  • ACCESS_TOKEN_SCOPE_INSUFFICIENT from the ingest call - The token is perfectly valid, it just doesn't carry the datamanager scope. Refreshing will never fix this, because a refresh token only ever mints tokens for the scopes it was originally granted. The account owner has to go back through the consent screen and approve the new permission.

That second one deserves a second thought if you added the Data Manager scope to an integration that already exists, because every connection made before you added the scope will keep refreshing perfectly happily and then fail at the ingest call. The error talks about authentication, which sends you off checking your credentials, when what actually changed is the scope list underneath you.

2. Sending the event

With a token in hand, sending the event is the easy part. The endpoint takes no account parameters in the URL, because everything lives in the body:

async function sendToGoogle(body, accessToken) { const res = await fetch( 'https://datamanager.googleapis.com/v1/events:ingest', { method: 'POST', headers: { Authorization: 'Bearer ' + accessToken, 'Content-Type': 'application/json', }, body: JSON.stringify(body), } ); return res; } // 200 → {"requestId": "3f4902f4-f8d3-4f4a-91ce-238331857362"}

The code above posts your assembled event to Data Manager with an OAuth access token, and hands the response back to the caller.

A few things about this call are different from what you might be used to though:

There is no developer token. The old Google Ads API required one, and getting it approved was its own small ordeal. Data Manager doesn't use one at all, so authentication is just an OAuth access token carrying the datamanager scope. If you're also listing conversion actions or accounts through the Google Ads API, you'll still need the adwords scope and the developer token for those calls, so most real integrations end up holding both scopes.

Manager accounts go in the body, not a header. If you access a client's Google Ads account through a manager (MCC) account, the old API wanted a login-customer-id header. Data Manager wants a loginAccount object inside the destination instead, which is what the buildIngestBody function above sets when you pass it a loginCustomerId.

The success response tells you almost nothing. A 200 comes back carrying a single requestId and nothing else. There are no rate limit headers, no retry-after, and no separate request ID header. That requestId in the body is the only handle you get, so log it against the conversion.

And critically, a 200 means Google accepted the event and will process it. It does not mean the event was valid, or that it was matched to a real ad click, or that it counted as a conversion in your Google Ads account. Everything that decides whether the conversion actually gets counted happens in the hours after you get the success response.

Which errors are worth retrying

Network blips and Google's own 5xx errors are temporary, so those are worth retrying with increasing delays, whereas most 4xx errors mean you sent something wrong and therefore retrying will just fail over and over again.

There is one important exception to this rule though, and it's the propagation problem from Step 0. A conversion action that was created a few minutes ago produces a 400 (which every sensible retry policy treats as permanent), but it will start working on its own in a few minutes. So that particular 400 needs to be retried, and it needs to be retried on a much longer timescale than an ordinary transient failure.

function classifyFailure(status, body) { // A brand new conversion action: Google can't see it yet. This resolves // itself in several minutes, so retry it later rather than giving up. const violations = body?.error?.details ?.flatMap((d) => d.fieldViolations || []) || []; const propagating = violations.some( (v) => v.field?.includes('destination_references') && v.reason === 'NOT_FOUND' ); if (propagating) return 'retry_later'; // Genuinely transient: back off and retry within the same job. if (status >= 500 || status === 429 || status === 408 || status === 409) { return 'retry_soon'; } // 404 here means the conversion action ID is wrong, which retrying // cannot fix. 403 means an access or terms problem. return 'permanent'; }

The code above looks inside the error body for the specific field violation that means "this conversion action is too new", and separates it from ordinary transient failures and from genuinely permanent ones. Matching on the field violation rather than just looking for the words "not found" matters, because Google uses that phrase in several unrelated errors.

The retry itself is safe from a double-counting point of view, because every attempt carries the same transaction ID and Google deduplicates on it.

Step 8: Test that it works

Before you point any of this at real campaigns, you want proof that Google is receiving your events and reading every field correctly. There are 3 mechanisms available and they tell you very different things.

The first is validateOnly. Setting it to true at the request level runs the full validation path, auth and destination lookup included, without recording anything.

const body = { ...buildIngestBody(args), validateOnly: true };

This is genuinely useful, and it's what we used for every probe in this article, because it means you can hammer the live API without polluting a real account. We confirmed it does resolve the destination, so a wrong conversion action ID still fails the way it would on a real send.

It has one important limitation though. A validateOnly request returns a request ID prefixed with v-, and that ID cannot be looked up afterwards. Google's own error message spells it out when you try:

"Request ID provided is not a valid ID. Please note that the status can only be retrieved for requests that succeed and don't have validate_only=true."

The second is requestStatus.retrieve. This is the endpoint that reports what happened to an event after Google finished processing it, and it's a plain GET with the request ID as the only parameter:

curl -H "Authorization: Bearer $ACCESS_TOKEN" \ "https://datamanager.googleapis.com/v1/requestStatus:retrieve?requestId=$REQUEST_ID"

The command above asks Google what became of a specific ingestion request, using the request ID that came back in the 200 response.

Google says to check 30 minutes after sending and to keep checking with backoff for up to 24 hours. We tested how literally to take that, using a real conversion sent to a live account.

At 25 seconds after the send, the endpoint returned a bare 404:

{ "error": { "code": 404, "message": "Resource not found.", "status": "NOT_FOUND" } }

That's the whole response. There's no "not ready yet" in there, so the obvious debugging loop, send and then immediately check, hands you something that looks exactly like a broken request ID.

At 29 minutes, the same request ID returned a 200:

{ "requestStatusPerDestination": [ { "destination": { "reference": "22024cd4-bb27-428f-9ccc-15af5eab9a34", "operatingAccount": { "accountId": "9467847029", "accountType": "GOOGLE_ADS" }, "productDestinationId": "7716477758" }, "requestStatus": "PROCESSING" } ] }

So half an hour in, the answer is still "we're working on it". We kept polling, and it was still reporting PROCESSING an hour and a half after the send. It finally resolved somewhere between 90 minutes and 2 hours:

{ "requestStatusPerDestination": [ { "destination": { "reference": "22024cd4-bb27-428f-9ccc-15af5eab9a34", "operatingAccount": { "accountId": "9467847029", "accountType": "GOOGLE_ADS" }, "productDestinationId": "7716477758" }, "requestStatus": "SUCCESS", "eventsIngestionStatus": { "recordCount": "1" } } ] }

So the whole journey for one conversion looks like this:

Time after sendingWhat the endpoint says
26 seconds404, "Resource not found."
29 minutes200, PROCESSING
90 minutes200, PROCESSING
About 2 hours200, SUCCESS, recordCount: "1"

There are 2 lessons in there. The first is that Google's 30 minute figure is when the record starts existing, not when it has a verdict for you. Their documentation says to allow up to 24 hours for processing, so none of this breaks a promise, but the practical advice most people take from "check after 30 minutes" is wrong. Build this as a job that polls with backoff over several hours, not as something you check once and move on from.

The second is what the finished answer actually contains, which is a record count and nothing else. It confirms that one record was ingested. There is no match rate in there, no attribution information, and nothing whatsoever about whether the hashed email you sent corresponded to a real Google user. Audience uploads through this same API do get a match rate. Conversion events don't.

That makes this endpoint genuinely worth wiring up, because a processing failure like unaccepted terms is invisible to you otherwise, and this is the only channel that will ever surface it. Just be clear about what a SUCCESS here means. It means Google took your record, not that your conversion matched anybody.

The third is the Google Ads interface itself. Open your conversion action and look at the Diagnostics tab. This is where the asynchronous failures surface, and it's the only place you'll find out about problems like the customer data terms not being accepted on the account. It's also where you'll see whether your deduplication is working, because a broken transaction ID shows up as double the conversions you expect.

One caveat while you're testing. Reporting lags ingestion by hours, so don't expect a conversion you just fired to appear in campaign reporting the same afternoon. If you're debugging, that lag will absolutely convince you something is broken when it isn't. And remember the 14-day bidding trial from Step 0, which means even a perfectly working setup won't change your campaign performance for a fortnight.

Things to think about

If you've followed the steps above correctly, you should have been able to successfully fire a conversion to Google Ads and have it accepted. Good job, you're about halfway there.

The next step is turning this into a system that you can run in production and not get burned by, and unfortunately this brings a whole other set of considerations:

Deduplication

You're sending every conversion twice on purpose (once from the browser and once from your server) and Google collapses those into one conversion using the transaction ID you generated and sent through, which means that ID has to match exactly across both legs. The way we do it in Converly is to generate the ID once (when the form is submitted) and then send that same ID with both the Google tag fire and the server call, so the 2 are guaranteed to line up.

If you get it wrong, the consequences are severe because every conversion gets counted twice. This massively inflates your reported numbers and would probably lead to your marketing team spending boatloads of money on Google Ads and then wondering why they aren't getting a significant increase in leads and customers.

Idempotency

This one is separate from deduplication, and it's your own protection rather than Google's. It stops you processing the same conversion twice on your own side, which happens far more easily than you'd think. A client retries a dropped request, a beacon fires twice, a webhook gets delivered more than once. If you don't guard against it, you'll end up creating 2 events with 2 different transaction IDs from a single conversion, and Google cannot deduplicate those for you because the IDs don't match.

Where possible, the best approach is actually to derive your transaction ID deterministically from the conversion itself (using the form submission ID, for instance) as opposed to generating a fresh random one on every attempt. You can then reject anything you've already seen at your own endpoint before you do anything else with it.

Clock skew on your servers

Because a timestamp one second in the future is rejected outright, treat this as an operational concern rather than a code one. Run NTP on anything that generates conversion timestamps, and trust the timestamp on your own server rather than one generated in the visitor's browser (because you can't rely on the visitor's browser to have the correct time set).

Auditability

When a conversion doesn't show up, or the marketing team asks whether their lead from Tuesday actually got tracked, you need to be able to give them a real answer. That means storing what data you received with each conversion (like the lead's name, email, GCLID, etc), the exact payload you sent with the contact details already hashed, and what came back (including the request ID). Keep all 3 together and timestamped, and ideally show them somewhere readable so that someone who isn't a developer can follow a single conversion all the way through.

On this API in particular, it's the only way to prove anything, because the synchronous response tells you so little and the asynchronous failures never reach you at all.

Security

Your OAuth credentials have to live on your server and only on your server. Store the refresh token in a secret manager rather than hardcoding it, and be careful about logging full requests while you're debugging, because it's very easy to accidentally write a token or a raw email address into your logs forever.

Your own tracking endpoint needs looking after too. It's public to the whole internet, so without an origin check and a sanity check on the payload, anybody can POST fake conversions straight into your Google Ads account. It's also a good idea to rate limit it per site and per IP as well, so a flood of junk can't run up costs or pollute the data.

Privacy and consent

You're handling people's personal data, which comes with legal obligations that vary depending on where you and your visitors are.

Hashing is part of this, and it's why you never send a raw email address to Google in the first place. Beyond that, the consent block on each event is how you tell Google what the visitor agreed to, and in the EU and UK you'll need to wire that into your consent banner and set CONSENT_DENIED when consent hasn't been given. It's also worth knowing that Google ignores IP-based matching for visitors in the EEA, the UK and Switzerland, so the IP address you're sending does nothing for those conversions regardless.

Some browsers send a Global Privacy Control signal, which is a request not to sell or share the person's data. If that's the case, you'll need to respect it and skip the conversion entirely when it's set.

You'll also need to decide how long you're keeping raw personal data on your servers before you delete it (and then make sure you actually delete it), because holding onto it forever is a liability rather than an asset. With Converly, we hold the PII so it can be shown in the conversion log for 7 days, after that we purge all PII to reduce blast radius.

Error handling

Even though I'm sure you are a great developer, things are going to fail. Google will have a bad minute, a network call will time out, a token will expire at an inconvenient moment, and you need to be able to handle it.

At Converly, we write the event to the database as soon as it arrives (before trying to forward it) and we retry the transient failures with a backoff (and don't retry the permanent ones). We also run a background job that sweeps for events which got stuck (i.e. received but never successfully sent) because without it any event that burns through its retries is silently lost. We also show any failures in the conversion log so a human knows why a conversion didn't go through, and we give users the ability to set up email notifications so they know about an issue as soon as it happens.

Bot and spam filtering

If your forms get spam, and most forms do, then every fake submission turns into a fake conversion. That's worse than just inflating your numbers, because fake conversions actively mislead Smart Bidding and teach it to go chasing exactly the wrong people.

So you need to filter the obvious junk out before you send anything, and one of the best ways to do this is to actually rely on the form tools' built-in spam prevention. If you set up honeypot fields or Captchas on the form then those will generally block spam submissions before the success event is fired (or before the success message is shown on the page, if you are listening for that). So as long as you have it set up to fire the conversion only on a successful form submission, then this should be largely covered.

Alternatively, you can use Converly

Everything above is buildable, and if you've read this far you've got most of the code you'd need. But it's a lot of small details to get exactly right, and the ways it fails will mostly do so without telling you anything.

Converly is a SaaS tool that makes it easy. You select a trigger (like a form being submitted on your site) and then select the actions you want to take (like sending a conversion to Google Ads, sending a conversion to Meta, sending an event to GA4, and so on).

Converly then gives you a snippet of code to place on your website, and it takes care of everything else for you. Specifically, it:

  • Captures the gclid, gbraid and wbraid click IDs automatically, along with the IP address and user agent, and persists them in first-party cookies that stay readable across your subdomains. This is the browser-side advantage that pure server-to-server tools just can't match, because they never saw the visitor's browser in the first place.
  • Normalises and hashes every identifier the way Google specifically wants it, including the Gmail dot and plus-suffix rules that differ from every other platform, so the same lead ends up matchable at Google and at Meta despite the 2 platforms disagreeing about how to hash them.
  • Validates every identifier before it goes out. Since Google checks the encoding and never the contents, this is genuinely the difference between an integration that works and one that returns successful responses forever while your conversion count sits flat.
  • Sets up the conversion action for you with the right type for a dual-fire setup, so you never end up with an UPLOAD_CLICKS action that silently killed your browser leg and no tag snippet to fire at.
  • Handles deduplication, idempotency and retries, so nothing gets double counted and nothing gets lost when Google has a bad minute.
  • Gives you a readable log of what was received, what was sent and what Google sent back, for every single conversion, including the request ID you'd need to chase anything up.
  • Fires the same conversion to Google Ads as well as Meta, GA4, ChatGPT Ads and other platforms from one setup, so the work you'd do for Google Ads on its own ends up covering everything.
  • Comes with real support. Google doesn't help you set conversion tracking up. We do.

Converly can detect conversions in 100+ form tools, scheduling widgets and chatbots out of the box (as well as custom HTML forms), and there's also an API integration method which captures the browser-side information and then waits for your own backend to confirm the signup succeeded (which is useful for something like a SaaS signup flow that offers both a signup form and SSO options like Google, Microsoft and GitHub).

And if you'd rather not click through a UI at all, there's a CLI (npm install -g @converly/cli) built for use with Claude Code, Codex or whatever agent you're using, and a hosted MCP server at app.converly.io/mcp that works with Claude and ChatGPT with nothing to install. With either one you can just say "set up conversion tracking on my form and send it to Google Ads and Meta" and have it built, published and tested for you.

Wrap Up

Successfully sending conversions to Google Ads is a multistep process with a lot of gotchas. There's hash validation that doesn't really check whether the data is correct, a timestamp window with only one edge, a conversion action that doesn't work for its first few minutes of life, and an entire category of failures that only ever surface in a diagnostics tab you'd have no reason to open.

And on top of that, you've then got deduplication, idempotency, auditability, security, consent, and retries to think about before any of it is safe to run in production.

It's definitely doable, but it may just be better to use a tool like Converly, which handles all of this for you. It can be set up in a few minutes (particularly if you use the MCP or CLI) and runs on proven infrastructure with full conversion logging and support.

It also comes with a 14-day free trial, so you can try it for free (and if it doesn't work for you, you can fall back to building it manually anyway)!

About the author

Aaron Beashel
Aaron Beashel

Aaron is the founder of Converly. With over 15 years of experience in digital marketing and SaaS, he's passionate about helping businesses track and optimise their ad conversions.

Start your free trial

Easily send conversions to your ad platforms and analytics tools.
No code required.