Five hundred rows went up. The response reported 437 successes, 421 conversions ever appeared, and Google Ads has no screen for either of the first two numbers. Offline conversion import errors split three ways: fatal errors Google names and subtracts, cosmetic losses Google counts as successes and never mentions, and silent losses nothing explains at all. The last two are where budget goes, and the only way to see them is to count rows on your own side of the wire.
Which import errors are fatal, which are cosmetic, and which are silent?
The split is not about severity but about where the row is visible, which decides what you can do about it.
Fatal errors are named. On a gclid upload the row comes back in the failure list with a reason and is excluded from successful_event_count: click id not found, expired click, conversion time earlier than the click, conversion action not active. Most pipelines still throw this class away, because the reasons arrive inside partial_failure_error rather than as an HTTP error. A job that checks only the status code sees a clean 200 on a batch that rejected 63 rows.
Cosmetic losses are counted as successes and named nowhere. The row was accepted, no alert came back, no new conversion exists. Three things produce it: the click was already counted once on an action whose counting setting is One, the row repeated a click id, action and conversion time already on file so Google deduplicated it, or the action is set to secondary and never enters the Conversions column. None of that shows in the response, and all of it is diagnosable afterwards from your own table and the action's settings.
Silent losses are counted as successes and cannot be explained at all: accepted, unalerted, absent, with no setting and no row of yours behind them. From Google's side nothing went wrong, so Google never mentions it.
Two things drive most of the fatal rejections. The horizon is not one number: 90 days from the last click on a gclid upload, 63 on the enhanced conversions for leads path. And conversion times are read against the account time zone, so an export that quietly switches to UTC pushes an evening slice of rows back a day, and the oldest of them past it.
For the named failures there is one cheap test that splits them into the two causes worth acting on. Re-send the same rows the next day. If they pass, the problem was timing and the click was simply too recent. If they fail again, the problem is capture and the identifier itself is wrong, which is a fix on the site rather than in the upload.
The join in the middle is the point: two branches that look nothing alike converge on one question the upload response cannot answer.
Why is the success count higher than the number of conversions that landed?
Here is one batch, job ocu-2026-09-02-a, 500 rows submitted at 05:15 UTC, read as the response returned it.
| What the response said | Rows | Class |
|---|---|---|
| Accepted | 437 | counted in successful_event_count |
| Failed, click id not found | 46 | fatal |
| Failed, click older than the 90-day horizon | 8 | fatal |
| Failed, conversion time precedes click time | 4 | fatal |
| Failed, conversion action not active | 5 | fatal |
| Submitted | 500 |
Synthetic figures, proportions typical.
That is where the response stops. The rest becomes readable ten days later, against a table you kept.
| What the 437 accepted rows became | Rows | Class |
|---|---|---|
| Appeared in the Conversions column by T+10 | 421 | landed |
| Click already counted once, action counting set to One | 5 | cosmetic |
| Repeat of a click id, action and conversion time on file | 4 | cosmetic |
| Accepted, unalerted, never appeared, nothing accounts for it | 7 | silent |
| successful_event_count | 437 |
Landed over sent is 84.2 percent. Landed over successful is 96.3 percent. Neither ratio exists in the Google Ads interface, and neither survives the HTTP call unless your job wrote it down. This is the reconciliation layer under the offline conversion loop I build for practices, and it has to exist before a bid strategy can be trusted with any of it.
Two API behaviors get mis-stated constantly, and both are true of one upload shape only. debug_enabled on the request controls whether click-not-found detail comes back on an enhanced conversions for leads import, where that reason is otherwise suppressed; on a gclid upload it is in the partial failure response without it. And CLICK_NOT_FOUND itself is a failed operation on a gclid upload, while on an enhanced conversions for leads import Google treats it as a warning and keeps the row inside successful_event_count. One error string, two meanings, and one of them quietly inflates the number most dashboards trust.
Where does Google show what happened to one batch?
In aggregate, briefly, and without a reason. The diagnostics resource is worth querying, and it is a smoke alarm rather than a ledger.
SELECT
customer.id,
offline_conversion_upload_client_summary.client,
offline_conversion_upload_client_summary.status,
offline_conversion_upload_client_summary.total_event_count,
offline_conversion_upload_client_summary.successful_event_count,
offline_conversion_upload_client_summary.pending_event_count,
offline_conversion_upload_client_summary.success_rate,
offline_conversion_upload_client_summary.last_upload_date_time,
offline_conversion_upload_client_summary.job_summaries,
offline_conversion_upload_client_summary.daily_summaries,
offline_conversion_upload_client_summary.alerts
FROM offline_conversion_upload_client_summaryjob_summaries returns the seven most recent import requests, each as a job id with a successful, a failed and a pending count. daily_summaries returns those three counts for the last seven days, and alerts carries an error enum and the share of events it touched. That is the whole surface: three integers per job, no per-row reason, no booking id, and a rolling window. Upload daily and last Monday's batch is gone by midweek.
One join is available and almost nobody takes it. The upload response returns a job id, the only key tying your batch to Google's summary. Store it on the batch row and the two line up for as long as the batch stays inside the seven. After that, your table is the only place the batch ever existed.
Run it against the same customer_id you imported to. Diagnostics come back only for the customer that received the import, so a query through the manager account returns empty and reads like an outage. I have watched that empty result start an incident that had no incident in it.
Why does Google say the click for this conversion cannot be found?
Because the identifier you sent did not match a click in Google's log. That is all it means: not a malformed row, not your credentials, not your conversion action. On a gclid upload there are two causes and one cheap test separates them. Either the click was too recent when the job ran and has not settled into the log, or the identifier is wrong. Re-send the failed set once the following day, into the same batch. Rows that were merely early will match. Rows carrying a bad identifier fail again, forever, and a re-send that drains nothing is itself the finding.
In the batch above, not-found rows went from 14 to 18 a week to 46 in one run, and none of the 46 matched on the re-send. Nothing about the upload had changed. A site update had started writing whichever click parameter was on the URL into one column, so iOS app-to-web sessions began putting a wbraid value into the field the job reads as a gclid. Those are separate fields in the API and one never matches as the other. The fix was upstream in how the click id gets captured, and the only place it surfaced was a ratio nobody was computing.
How do I tell an unmatched click from a click that was never captured?
Not on one row, and you do not need to. You tell them apart on two ratios, and most pipelines build only the first.
Landed over sent is import health, and on a gclid pipeline it can only ever look good: a booking with no click id is not a row you can send. So keep rows sent over bookings created beside it. That one is capture health, it collapses when a template stops writing the click id, and watching only the first is how a practice gets a flawless import of a shrinking slice of its own demand.
With both in place the thresholds are usable. On a gclid upload, landed above 90 percent of rows sent is healthy, and under 85 percent is a capture problem wearing an import problem's clothes: truncated identifiers, a gbraid or wbraid in the gclid field, or click ids from another ad account. An enhanced conversions for leads import behaves nothing like that: every booking goes up, matching runs on hashed identifiers, 25 to 45 percent lands as a matter of course, and the level carries no information. Both shapes are healthy. Only one looks healthy.
How do I reconcile one batch: sent, counted successful, actually landed?
Six steps, one scheduled query inside the job that already uploads.
- On upload, write one row per submitted event into
ocu_sent_rowswithbatch_id,row_key,conversion_time_local, and the account time zone you converted to. Store the job id the response returns on the batch row. - Store the response class per row: accepted, or failed with the code.
- At T+1, re-send the failed not-found set once into the same
batch_id, deduplicated onrow_key, and record how many drained. - At T+4, read the batch's conversion dates and keep it as the trend read.
- At T+10, read them again and keep that as the ledger read.
- Compute landed over sent and landed over successful per batch, against your trailing four-batch median rather than against 100 percent.
Read both conversion columns, not one. An action set to secondary lands in all conversions and never enters the Conversions column, so a ledger built on all_conversions_by_conversion_date alone reports a perfect batch the bidder never saw. That is the cosmetic class taking a whole batch at once, and it survives for months because every number anyone checks is green.
SELECT
segments.date,
conversion_action.name,
metrics.all_conversions_by_conversion_date,
metrics.conversions_by_conversion_date
FROM conversion_action
WHERE conversion_action.id = <your conversion action id>
AND segments.date BETWEEN '2026-09-02' AND '2026-09-02'Both are read by conversion time. The default conversions metric reports on the click date, which is why your upload never appears on the day you sent it and why working imports get declared broken every week. A dry run shortcuts none of this: validate-only checks the shape of your payload, while matching runs later and asynchronously against Google's click log. The only proof a row landed is a read back on a fixed lag.
Then the reconciliation runs over a table you own.
-- row_key is deterministic and written at insert, so a re-send is idempotent
select
batch_id,
count(*) as rows_sent,
countif(response_class = 'accepted') as rows_accepted,
min(google_successful_count) as google_called_successful,
min(landed_by_conv_time_t10) as landed,
round(min(landed_by_conv_time_t10) / count(*), 3) as landed_ratio
from ocu_sent_rows
group by batch_id
order by batch_id descThat key has one job: survive a re-run, because a re-run is what a partial failure produces. Retry forty rows with ids the export regenerates each time and you have forty new rows, not forty retries. I have run this out of a warehouse and out of a flat scheduled job, and the discipline is identical either way. It is pipeline integrity rather than an ads chore, which is why it belongs to the marketing analytics work that ties ad spend to revenue as much as to the ad account, inside the offline loop as I ship it.
Worth saying out loud for a practice: the reconciliation adds nothing to what leaves the building. On a gclid upload only an anonymous click id, a value and a timestamp ever leave. The row key sits against a booking id Google never receives, and the other two counts come back from the ad account in aggregate. The privacy consequence nobody connects: chasing a higher match rate by moving to enhanced conversions for leads sends hashed email or phone out of the building, a different posture for a practice even though the hash is one-way, and it cuts the horizon from 90 days to 63. A HIPAA-conscious design decision, not a legal opinion.
What does the ledger look like once four batches are in it?
| Batch | Sent | Successful | Not found after re-send | Landed T+10 | Landed / sent |
|---|---|---|---|---|---|
| ocu-2026-08-12-a | 512 | 483 | 15 | 471 | 92.0% |
| ocu-2026-08-19-a | 476 | 449 | 14 | 438 | 92.0% |
| ocu-2026-08-26-a | 503 | 474 | 18 | 463 | 92.0% |
| ocu-2026-09-02-a | 500 | 437 | 46 | 421 | 84.2% |
Synthetic figures, proportions typical. The success rate fell 6.8 points that week, the landed rate fell 7.8, and landed over successful slipped from about 97.6 percent to 96.3: the cosmetic and silent tax widening while the fatal class takes the headline. Google Ads has a screen for none of those four numbers.
Put money against it. Synthetic figures, proportions typical: $58,000 of monthly search spend against a $32 target cost per acquisition. Those four batches sent 1,991 rows, so the 92.0 percent band lands 1,832 conversions at $31.66 each. Hold the broken week's 84.2 percent for a month and the same rows land 1,676, at $34.61: a 9.3 percent overstatement of cost per conversion, 156 bookings the bidder never sees, and a campaign crossing from under target to over it with nothing changed in the market. The dashboard reports a slightly worse month.
Which of these deserve an alert, and which deserve a monthly count?
Most of this list should never wake anyone up. Alerting on every failed row teaches a team to ignore the channel.
| Signal | Read it | Treatment |
|---|---|---|
| Landed over sent falls more than 8 points below the trailing four-batch median | per batch | Alert |
| Hard failures above 5 percent of rows sent | per batch | Alert |
| A not-found re-send that drains nothing | per batch | Alert |
| A batch reports zero rows sent | per batch | Alert |
| Rows sent over bookings created, moving inside the band | monthly | Count |
| Cosmetic rows: deduplicated, or counted once already | monthly | Count |
| The absolute level of the match rate | never | Context only |
Two rules govern it. Below roughly 150 uploaded rows a month a weekly ratio is noise, so read it monthly and alert only on hard failures and zero-row runs. And read the landed number twice, at T+4 for the trend and T+10 for the ledger, never a T+2 read against a T+10 baseline, which manufactures a shortfall that does not exist. Once a few months are in it, hand the check to a monitoring agent on top of the measurement. The ledger is the cheapest moving part in patient conversion tracking and the only one that tells you the rest still works, and it is a separate question from how the upload itself is made in 2026.
Write the numbers into your runbook before you need them: rows you sent, rows Google counted as successful, rows the settings explain, and conversions that appeared by conversion time on a fixed lag. A batch that reports success and lands nothing looks identical to a batch that works, right up until the bidder has spent a quarter learning from a population that was never there. Your first month of counts is not a diagnosis. It is a baseline, and the baseline is the only thing that makes the second month readable.
Tags
Frequently asked questions
Why does Google Ads say the click for this conversion can't be found?
On a gclid upload it means the click id you sent did not match a click in Google's log, and the row comes back as a failed operation inside the partial failure response instead of counting as a success. Enhanced conversions for leads behaves differently: on that path Google treats the same condition as a warning and still counts the operation as successful. Re-send the failed set once the next day, because a click that was merely too recent will match on the second attempt and a wrong identifier never will.
Where does Google Ads show what happened to one offline conversion batch?
Nowhere in the account interface, which has no per-import screen at all. Through the API, the offline conversion upload diagnostics resource returns the last seven import jobs and the last seven days as three counts each, with no per-row reason and no key you recognize unless you stored the job id the upload response handed back. Everything older than that window is gone, so the only durable per-batch record is the one you write yourself.
What does successful_event_count actually count?
It counts operations Google accepted at the request level, which is not the same as conversions that appeared in the account. On a gclid upload it excludes unmatched click ids, because those come back as failures, but it still includes rows that were deduplicated, rows sent to a conversion action set to secondary, and rows that were dropped later in processing. On an enhanced conversions for leads import it also includes unmatched rows, which Google counts as successes and reports only as warnings.
What match rate should I expect on a GCLID offline conversion upload?
If every row you send already carries a click id, expect landed conversions above 90 percent of rows sent and treat anything under 85 percent as a capture problem rather than an import problem. An enhanced conversions for leads import that sends every booking and matches on hashed identifiers routinely lands 25 to 45 percent, because most of those bookings were never Google clicks. In both shapes the useful signal is the move away from your own established band, not the absolute level.
Does a time zone difference between my upload and my Google Ads account break the import?
It does not reject the batch, which is why it is dangerous. Timestamps are interpreted against the account time zone, so an export that switches to UTC will move an evening slice of conversions to the previous day, push the oldest rows past the click horizon, and shift volume across month boundaries. Convert to the account time zone before upload and store which zone you converted to.
Which Google Ads column shows whether an offline import actually landed?
Read conversions by conversion time, and read all conversions by conversion time beside it. The default conversions column reports against the click date, so your upload will never appear on the day you sent it, which is the single most common reason a working import gets declared broken.
Need something like this built?
Free 15-min discovery call. I'll listen, ask honest questions, and tell you if I can help.