If you upload offline conversions to Google Ads with uploadClickConversions and see HTTP 200 but nothing in your account, the upload probably failed. Google just didn't tell you in the status code.
Short answer: with partialFailure: true, rejected conversions come back inside partialFailureError in the response body, while the request still returns 200. Read that field on every call. Then fix the three issues we hit most: uploads with no Google click ID (skip them), ONE_PER_CLICK_CONVERSION_ACTION_NOT_PERMITTED_WITH_BRAID (set the action to count Every conversion), and retries that double count (use orderId).
This comes from a pipeline we built for a US salon using Boulevard: every booking is sent to Google Ads as an offline click conversion, plus Meta, TikTok, Snapchat and Nextdoor. The full build is in our complete server-side guide.
Last verified: November 2026. Error names come from the Google Ads API reference for the current version (v26.1 at the time of writing). Our pipeline ran on v24.
Important 2026 change: new builds should use Google's Data Manager API. Since June 15, 2026, the Google Ads API only accepts
uploadClickConversionsfrom developer tokens that already uploaded offline conversions between December 17, 2025 and June 15, 2026. Anyone else getsCUSTOMER_NOT_ALLOWLISTED_FOR_THIS_FEATURE, and Google points new offline-conversion workflows to the Data Manager API. Our pipeline was already uploading before the cutoff, so it kept working. If you're starting today, build the Google step on the Data Manager API. The guards below (skip when there's no click ID, dedup, one identifier, value only when you have one, read failures properly) apply either way. Check Google's deprecations page for the current rules.
What you need before the first upload
Click conversion uploads need more setup than Meta's Conversions API. Get these in place first:
- A developer token with at least Basic access. Explorer access is enough to start testing. Basic access is a separate application, and ours was approved within a few days.
- An OAuth client and a refresh token for a Google account with access to the ad account. Your server exchanges the refresh token for short-lived access tokens.
- The
login-customer-idheader set to your manager (MCC) account ID if you upload through one. Strip the dashes. - Conversion actions of the "import from clicks" type. We created two: Appointment Booked and Appointment Completed, so booking volume and revenue stay separate.
- Count set to "Every" on both actions. More on why below.
If the account already has import conversion actions from an earlier setup, leave them alone until you know whether live campaigns optimize on them.
The upload, with the guards we needed
Here's the shape of our upload function, with the guards called out:
let cachedToken = null, cachedExpiry = 0;
async function getAccessToken(env) {
if (cachedToken && Date.now() < cachedExpiry - 60_000) return cachedToken; // reuse until ~1 min before expiry
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: env.GOOGLE_CLIENT_ID, client_secret: env.GOOGLE_CLIENT_SECRET,
refresh_token: env.GOOGLE_REFRESH_TOKEN, grant_type: 'refresh_token',
}),
});
if (!res.ok) throw new Error('Token exchange failed: ' + res.status + ' ' + await res.text());
const json = await res.json();
cachedToken = json.access_token;
cachedExpiry = Date.now() + json.expires_in * 1000;
return cachedToken;
}
// Google wants "yyyy-mm-dd hh:mm:ss+hh:mm", with a timezone offset
function googleTime(unixSeconds) {
const d = new Date(unixSeconds * 1000), p = (n) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ` +
`${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}+00:00`;
}
export async function uploadGoogleConversion(c, env) {
// GUARD 1: no Google click ID → nothing for Google to attribute. Skip, don't error.
const a = c.attribution || {};
if (!a.gclid && !a.gbraid && !a.wbraid) return { skipped: 'no google click id' };
const customerId = env.GOOGLE_CUSTOMER_ID.replace(/-/g, '');
const actionId = c.event_type === 'APPOINTMENT_COMPLETED'
? env.GOOGLE_ACTION_COMPLETED : env.GOOGLE_ACTION_BOOKED;
const conversion = {
conversionAction: `customers/${customerId}/conversionActions/${actionId}`,
conversionDateTime: googleTime(c.event_time),
orderId: c.event_id, // GUARD 2: dedup on retries
};
// GUARD 3: exactly one click identifier, in order of preference
if (a.gclid) conversion.gclid = a.gclid;
else if (a.gbraid) conversion.gbraid = a.gbraid;
else conversion.wbraid = a.wbraid;
// GUARD 4: only send value when there is one (bookings are 0, completed visits carry revenue)
if (c.value > 0) { conversion.conversionValue = c.value; conversion.currencyCode = c.currency || 'USD'; }
const res = await fetch(
`https://googleads.googleapis.com/vNN/customers/${customerId}:uploadClickConversions`, { // pin a current version
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAccessToken(env)}`,
'developer-token': env.GOOGLE_DEVELOPER_TOKEN,
'login-customer-id': env.GOOGLE_LOGIN_CUSTOMER_ID.replace(/-/g, ''),
},
body: JSON.stringify({ conversions: [conversion], partialFailure: true }),
});
const body = await res.json();
// GUARD 5: 200 is not success. Read partialFailureError.
const pf = body.partialFailureError;
const failed = pf && (typeof pf === 'string' ? pf.length > 0 : Object.keys(pf).length > 0);
if (!res.ok || failed) {
return { ok: false, status: res.status, error: typeof pf === 'string' ? pf : pf?.message || JSON.stringify(body) };
}
return { ok: true, status: res.status };
}
Each guard exists because we hit the problem it prevents.
Guard 1: skip uploads with no Google click ID
A booking pipeline sends every booking through the same fan-out. Most of them come from Meta, TikTok or organic traffic and carry no gclid, gbraid or wbraid.
If you upload those anyway, each one fails, your logs fill with errors, and real Google problems get buried. A click conversion needs a click: without an identifier there's nothing for Google to attribute. Return early with a clear "skipped" reason, and count skips separately from failures in your dashboard.
Guard 2: dedup retries with orderId
Webhooks retry. Workers time out. You will send the same conversion twice at some point.
Set orderId to a stable per-conversion ID (we use the same event_id we send to Meta and TikTok). Google records one conversion per order ID per conversion action. A retry of something already recorded comes back with ORDER_ID_ALREADY_IN_USE. In your logs, treat that as "already counted", not as a failure to alert on.
Guard 3: one click identifier, never two
gclid is the normal Google click ID. On iOS traffic you often get gbraid or wbraid instead. Send exactly one:
- Prefer
gclidwhen you have it. - Otherwise
gbraid, thenwbraid. - Sending both
gbraidandwbraidreturnsGBRAID_WBRAID_BOTH_SET.
The BRAID error: ONE_PER_CLICK_CONVERSION_ACTION_NOT_PERMITTED_WITH_BRAID
This is the one that surprises people, because it only shows up on iOS traffic.
Cause: conversion actions set to count "One" conversion per click can't accept conversions uploaded with gbraid or wbraid. Those identifiers aren't tied to a single click the way a gclid is, so "one per click" can't be enforced.
Fix: in Google Ads, open Goals → Conversions, open the conversion action, edit settings → Count → Every, then save. Through the API, the counting type is "many per click."
Because orderId already prevents double counting from retries, "Every" doesn't inflate your numbers for a booking pipeline. Each booking has its own ID.
Guard 4: only attach value when you have one
Booking events happen before any money changes hands. In our Boulevard pipeline, the order doesn't exist yet when the appointment is created, so booked conversions carry no value. Completed appointments carry the real revenue.
We only add conversionValue and currencyCode when the value is above zero. Sending a value of 0 tells Google nothing useful.
Guard 5: HTTP 200 is not success
This one cost us the most time. With partialFailure: true (which you want, so one bad conversion doesn't sink a batch), Google:
- returns HTTP 200 for the request,
- puts the rejected conversions in
partialFailureError, - and returns an empty result entry for each failed conversion.
If your code only checks res.ok, every rejected conversion looks like a success. Check partialFailureError on every response, log the message, and alert on it.
Testing without real clicks
You can't create a valid gclid by hand, but you can still test the pipeline end to end:
- Book with a fake click ID in the landing URL, for example
?gclid=verify_test_1. - Google returns HTTP 200 with
UNPARSEABLE_GCLIDinpartialFailureError. - That's the expected result. It proves the token, developer token, customer IDs, endpoint and conversion action are all valid. Google just can't decode a made-up click ID.
Then wait for a real ad click to book. A new conversion action shows as Inactive until real conversions arrive. Ours switched to recording 3 to 24 hours after the first real one.
Timing errors to handle
Two errors are about timing, not data:
TOO_RECENT_CONVERSION_ACTION: the conversion action was just created. Google asks you to wait 6 hours before importing to it. Create your actions a day before launch.TOO_RECENT_EVENT: the click behind the conversion happened less than 6 hours ago. A real-time pipeline, which uploads seconds after a booking, can run into this. Put these conversions in a retry queue and try again after 6 hours instead of dropping them.
The conversion time must also be after the click and include a timezone offset. We send UTC with +00:00.
Keep the API version current
Google moved the Ads API to monthly releases in 2026, and each major version is supported for about a year. We built on v24; v26.1 is current as of November 2026. If calls start returning 404 on the version path, the version was sunset. Pin a version, keep it in one constant, and check Google's deprecation schedule each quarter.
Common errors
- HTTP 200 but no conversions: read
partialFailureError. ONE_PER_CLICK_CONVERSION_ACTION_NOT_PERMITTED_WITH_BRAID: set the action's count to Every.GBRAID_WBRAID_BOTH_SET: send only one identifier.UNPARSEABLE_GCLID: expected with fake test IDs. With real IDs, check the value wasn't modified or truncated.ORDER_ID_ALREADY_IN_USE: a retry of something already recorded. Not a failure.TOO_RECENT_CONVERSION_ACTION/TOO_RECENT_EVENT: wait 6 hours and retry.INVALID_CONVERSION_ACTION_TYPE: the action isn't set up for imports. Use an "import from clicks" action.CUSTOMER_NOT_ALLOWLISTED_FOR_THIS_FEATURE: your token wasn't uploading offline conversions before June 15, 2026. Use the Data Manager API.- 401 / permission errors: check
login-customer-id, the developer token's access level, and that the OAuth account can reach the ad account. - 404 on the URL: the API version was sunset.
Testing checklist
- Both conversion actions exist, are import-type, and count Every.
- A booking with no Google click ID is skipped, not sent.
- A test booking with
?gclid=verify_…returnsUNPARSEABLE_GCLID, logged as expected. - Your code flags any other
partialFailureErroras a failure. - Sending the same conversion twice doesn't create two conversions.
- A completed appointment sends the real value; a booking sends none.
- The first real ad booking shows up within 24 hours and the action starts recording.
Related reading: why "match by IP" attribution failed under real traffic (a correct upload with the wrong click ID is still wrong), and why your Facebook ads show 0 bookings when you use Boulevard.

