DEV Community

Cover image for The offline conversion importer you write today fails in the next ad account
Vinicius Pereira
Vinicius Pereira

Posted on

The offline conversion importer you write today fails in the next ad account

Google's guide for importing offline conversions opens with a warning box that is very easy to scroll past:

Starting June 15, 2026, UploadClickConversion requests will fail if the developer token hasn't previously sent requests to upload offline conversions or enhanced conversions for leads.

And then it says what to do instead: use the Data Manager API.

Read the condition again, because the condition is the whole story. It is not about your code and it is not about the ad account. It is about history on the developer token. A token that has been uploading offline conversions for a year keeps working. A token issued for a new integration has no history at all, so identical code that has run fine for a year fails the first time it runs somewhere new.

That is the worst failure shape on offer. It passes everywhere it was tested and breaks at the next install, and the person who finds out is the next advertiser, not you. The replacement endpoint is POST https://datamanager.googleapis.com/v1/events:ingest, and it is a different request body, a different scope, a different error vocabulary and a different failure model from the Google Ads API call most sample code still shows.

So I built the bridge against that endpoint, plus the Meta Conversions API /events edge on the other side, and wrote down every place where the documentation left room to guess.

The loop, in one sentence

A CRM knows which deals closed and for how much. The ad platforms know which clicks they served. Almost nobody owns the piece in between, which is why "we closed forty deals in March and Google shows thirty-one" is such a common question and such a hard one to answer. This pattern shows up in a lot of advertising work, and the shape is always the same.

The package is called revenue-loop and it exists to make one sentence true: a conversion is uploaded exactly once, attributed to the campaign the lead actually came from, or it is quarantined with the reason named. It never invents an attribution identifier. 734 tests, one runtime dependency, and the whole suite runs offline in under a second.

One won deal reaching Google and Meta exactly once: the same email hashed two different ways, a single key shared by both destinations, the CRM firing the same deal again and spending zero requests, and three conversions refused with the reason named

Every number in that animation comes from python examples/loop_demo.py.

Now the part that has to be said plainly, because everything else depends on it. There is no ad account and no pixel behind this repository. No token was issued and no live call has been made. Both platforms ship as deterministic fakes that model their documented semantics: Google's fast-fail request model and its 2,000 event ceiling, Meta's 1,000 event ceiling, its 48 hour deduplication on event_id plus event_name, its seven day event_time rule that fails an entire request. The live adapters exist and their URLs, headers, encodings and error mapping are unit tested, but a run against a real account has not happened, and there is a RUNBOOK whose job is to turn it on honestly the day one exists.

That limit is what makes the rest checkable. Everything below is reproducible from a clone with no key.

The same customer, two hashes, both correct

Here is the part that costs money quietly. This is verbatim from the offline demo, which a test pins byte for byte so the README cannot drift:

raw email:      cloudy.sanfrancisco+shopping@gmail.com
google form:    cloudysanfrancisco@gmail.com   (gmail rules: dots and +suffix removed)
meta form:      cloudy.sanfrancisco+shopping@gmail.com   (lowercase and trim, nothing else)
google sha256:  223ebda6f6889b1494551ba902d9d381daf2f642bae055888e96343d53e9f9c4
meta sha256:    8dd4a9bf850242873c14976125edabc35096192b784626a5641a7d2de56e3c1e
Enter fullscreen mode Exit fullscreen mode

That address is Google's own worked example. Its formatting guide requires, for gmail.com and googlemail.com only, removing every dot before the @ and the + with everything after it. Meta's customer information reference asks for whitespace removal and lowercasing and nothing else, and its own worked example is John_Smith@gmail.com becoming john_smith@gmail.com: a Google-operated domain keeping a local part the Google rules would have changed.

One shared normalise_email is therefore wrong for at least one destination on every gmail address you own. And the wrongness is silent. The upload succeeds, the event is received, the conversion is simply never matched to anybody. Google says it in as many words, warning that skipping those rules "will result in different hash values than Google expects for these domains, leading to missed matches". Nothing raises. The campaign just looks worse than it is.

The divergence keeps going in smaller places. Google keeps the hyphen in a family name, Meta strips punctuation. Google wants E.164 with the +, Meta wants digits only. So the package normalises twice, once per dialect, and six hashes in the suite are Meta's own published vectors, input and expected digest both, reproduced exactly. Google's guide publishes normalised forms rather than digests, so its three worked examples are pinned at that level. Two of those vectors are non-ASCII, which matters more than it looks: an implementation that strips non-ASCII "to be safe" produces a wrong hash for both and loses every non-English customer in the account.

Where the documentation is silent, the silence is named instead of filled. The biggest one is which Unicode case operation "lowercase" means. Both vendors write "convert to lowercase", and str.lower and str.casefold disagree on real input, since casefold maps ß onto ss, which is a transliteration. This uses str.lower and a test pins it, because a well-meaning refactor to casefold would change every hash in the system without turning anything red. I have paid for the casefold version of this lesson in production once already.

The bug that lied about the reason

This one was found in a skeptical review pass before publishing, and it is the best thing in the repository.

The upload store is written only after a destination confirms. That asymmetry is deliberate: recording an upload that did not happen loses a conversion silently and forever, while recording nothing for an upload that did happen costs one duplicate request that the deterministic key makes harmless.

But a store that is written after the send is not consulted usefully during the send. A CRM that fires its webhook on every field change delivers the same won deal twice in one run. Both copies asked the store whether this key had already gone up, both got no, and both travelled in the same request. The batch landed. Two rows for one conversion.

Nothing errors. The damage arrives later, through the restatement path:

uploaded_total = _total_uploaded(history)        # 4820 + 4820 = 9640
delta = restatement.new_value - uploaded_total   # 5200 - 9640 = -4440
Enter fullscreen mode Exit fullscreen mode

A salesperson upsells the deal from 4,820 to 5,200. The history says 9,640 was uploaded. So an honest increase reads as a drop of 4,440, and a drop has no documented path on either API, so the loop quarantines it with negative_adjustment_unsupported and a message explaining that the value went down.

The real revenue never reaches the platform, and the reason in the audit trail is false. A person reading that quarantine goes looking for a refund that does not exist. It gets worse under the durable store the RUNBOOK tells you to use, where a unique index on (account_ref, destination, key) turns the second write into an IntegrityError that takes the run down.

The fix is one line of doctrine: the key has to hold in two places, the store and the batch.

batch = pending.setdefault((account.account_ref, destination), [])
if any(item.key == ready.key for item in batch):
    # already in this batch: record it, send nothing
    ...
    continue
batch.append(ready)
Enter fullscreen mode Exit fullscreen mode

Plus dict.fromkeys(outcome.accepted) on the way back, so one accepted key writes one row whatever the destination echoes. Two tests pin it, and I checked both by stashing the fix and watching them fail: test_the_same_deal_twice_in_one_run_uploads_once and test_an_upsell_after_a_duplicated_delivery_still_adds_the_difference.

The general shape is worth carrying out of this repo. A uniqueness rule enforced at the point of persistence is not enforced at all while there is an unpersisted buffer in front of it. Queues, batchers, in-flight maps and request bodies are all that buffer.

What it refuses to guess

The short version of the doctrine, each line an executable test:

  • {{gclid}}, undefined and N/A are not identifiers. A template that never rendered uploads a conversion that counts, attributes to nothing, and leaves the diagnostics report looking healthy.
  • Geography alone is not an identifier either. sha256("us") is the same value for every customer in the country. Meta accepts it. It matches nobody, so the gate stops at person-level signals.
  • A short count from Meta is ambiguous, not partial success. The documented body is a count with no list of which events it refers to, so nothing is recorded as uploaded and the next run re-sends under the same event_id.
  • One stale event takes a Meta batch with it. Past seven days the whole request errors, so 999 good conversions die for one bad row, which is why the window check happens locally before a request is spent.
  • A refund is quarantined naming ConversionAdjustmentUploadService, the Google Ads API service that documents restatements and retractions, which this package does not speak. No negative conversion value invented to approximate one.
  • Google's request reference and limits page both say 2,000 events per request while its TOO_MANY_EVENTS error reason says 10,000. This batches at 2,000 and writes the contradiction down rather than averaging it away.

What it does not tell you

The fakes are my model of the documentation, and a model is not a platform. Step 8 of the RUNBOOK exists because of the biggest gap: Meta documents deduplication on event_id plus event_name for 48 hours, and Google documents deduplication between a tag and the API, not between two ingestions of the same API. On the Google side the key is a hedge whose behaviour has to be confirmed once against a live account, deliberately, by sending the same conversion twice.

Google names an EVENT_TIME_INVALID error without publishing the window it applies, so the window here is configurable and unset by default rather than guessed. The default upload store is process-local memory, which is correct for the suite and gone the moment the process dies. And none of this reports anything: the platform's own reporting stays the only honest source for what the numbers are, because a second number is a second argument.

The code, the two dialects, the demo and both regression tests are in github.com/vinimabreu/revenue-loop, MIT, no network in the suite.

An uploaded conversion nobody can attribute is not measurement. It is a report that agrees with you.


Vinicius Pereira
vinimabreu.dev · github.com/vinimabreu

Top comments (0)