Methodology

The Match Key Is the Product

A privacy decision removed the only identifier a CRM loopback could match on. Every won job since is structurally invisible to the ad platform - and four dashboards call it healthy.

A decision to keep customer data off a marketing event made that event impossible to match. Not degraded — impossible.

For ProComfortSolutions, a Chicago HVAC contractor, the leg of the measurement system that carries won jobs back to Google Ads has never matched a real job, and as built it never can. The webhook handler hardcodes an empty contact object, a deliberate privacy choice that keeps customer identity off the loopback event, and that choice leaves a recovered click identifier as the only possible match key. Every won job so far arrived as a phone call — a Local Services lead or a tracking-number lead — and a phone call carries no click identifier. The arithmetic ends there. The result is a growing backlog of won jobs that cannot reach ad-platform attribution without a rebuild.

What gets written off is ad-platform attribution, not revenue. Every dollar remains recorded in the CRM, in the committed export, and in the operational database. The bidding algorithm is blind to those jobs. The business is not.

One emission, ever, and it was synthetic

The system has produced exactly one successful non-validation upload in its lifetime: 2026-07-14, on a synthetic dry-run click identifier generated by the arm test itself. At the signed stage — the stage that carries won-job value — there have been zero rows, ever. Both facts were confirmed and pinned by the standing tripwire's positive control, so this is a verified read rather than an absence someone inferred from a quiet dashboard.

A system that has emitted once, to itself, on an identifier it manufactured, has not been proven to work. It has proven that its own test harness works.

The mechanism, stated plainly

Google's offline conversion import joins an uploaded conversion back to a prior ad click through a click identifier captured at the time of that click — that identifier is the join, and the alternative joins are identity-based, matching on hashed customer contact data instead.

The handler removes the second option by design. An empty contact object is hardcoded so no customer identity rides the loopback event. That is a defensible privacy posture and it was chosen on purpose. Its downstream consequence was never re-derived: with identity matching off the table, a recovered click identifier becomes the only key the leg can use.

Then the lead mix decides the outcome. The dominant source of business is inbound calls, and a call lead carries no click identifier — there is no click to attach one to. So the leg cannot match a call-sourced won job. Not usually, not most of the time. Structurally, on every row, forever, until the join key changes.

The failure is not the privacy decision. The failure is that a decision made in the handler silently redefined what the entire leg was capable of, and nothing downstream was re-checked against it.

A loopback that structurally cannot match A flow shows a CRM won job passing through a webhook that hardcodes an empty contact object for privacy, leaving a recovered click identifier as the only possible match key. A broken link marks the break: call-sourced leads carry no click identifier, so the leg can never match a call-sourced won job. An evidence strip shows dashboards reading healthy in four places, zero signed-stage uploads ever, and a single lifetime emission — a synthetic self-test. THE ONLY JOIN THE LOOPBACK CAN MAKECRM won jobWebhook:contact = {} (privacy)Match key: recoveredclick identifier — ONLYCall-sourced lead (LSA ortracking number): NO click identifierThis leg can never matcha call-sourced won jobWHAT THE INSTRUMENTS SAY MEANWHILEDashboards:healthy, in 4 placesSigned-stage uploads,lifetime: 0 rowsOne emission ever:a synthetic self-test
Code path read first, then the emission and skip ledgers; live arithmetic 2026-08-17. The write-off is ad-platform attribution, not revenue — the dollars remain in the CRM.

The Match Key Is the Product

Step 1 — Name the identifier the CRM leg will join on, before building it. Worked: this leg joins on a recovered click identifier, and that was never written down as a decision. It emerged as a residue of the empty contact object. An unwritten join key is still a join key; it just never gets reviewed.

Step 2 — Check whether your dominant lead source actually carries that identifier. Worked: the dominant source here is phone calls, and calls carry none. That check is a query against last quarter's lead sources, answerable in minutes, and it invalidates the design before a line of code exists.

Step 3 — Make every skip recoverable, or accept that the backlog is permanent. Worked: on this account a recovered lead exits the handler with no miss recorded anywhere, so the skip that matters most leaves no row to replay. The section after this one takes that apart, because it is the finding that decides how a repair has to be scoped.

Step 4 — Verify that a skipped conversion is visible somewhere, because four green dashboards is the default. Worked: a dropped won-job conversion on this account is classified healthy in four independent places. Four surfaces, four honest greens, one dropped conversion. The corpus names this failure class silent-by-construction, and the name is doing real work: nothing broke, nothing errored, nothing lied. Each surface reported accurately on the question it was built to answer, and no surface was built to answer this one. Those four green surfaces appear again in our Instruments That Lie series as the standing illustration of silent success.

The framework is not specific to Google Ads. Any integration that carries an outcome from one system into another joins on something, and that something is the product — the reporting, the bidding and the dashboards are all downstream of whether two rows can be tied together. Build the join first, then test it against the lead population the business actually has rather than the one the integration's documentation assumes.

The skips are unrecoverable as built

A skipped upload leaves a record only if the branch that skipped it writes one, and the miss-recorder here fires only on pre-dispatch branches. A lead that is recovered travels further down the path and exits without a miss being written — no row, no reason, no trace. The reprocessor, meanwhile, filters on a different skip reason entirely and never replays this one.

That changes how any repair has to be scoped. Fixing the match key changes the future and nothing else. Every job that has already passed through stays outside the ad platform unless a deliberate backfill goes and retrieves it, and a backfill can only retrieve what was written down in the first place. Skips that were never recorded are not a queue waiting to be drained. They are absent from the instrument entirely, and the CRM is the only place they still exist — which is also the reason the loss is an attribution loss rather than a revenue one.

The reprocessor reads the same page every run

The reprocessor is the component meant to catch up on skipped rows, and its own source comments the trap: a 90-day horizon, a 100-row default limit, and an oldest-first ordering. Those three together mean it reads the same oldest-100 page on every run, indefinitely.

Live arithmetic on 2026-08-17: the queue held 135 rows — rows in an upload queue, not a count of jobs — of which 100 sit inside the page the reprocessor reads. 35 sit outside the instrument's reach, and that number grows every day new rows arrive.

A backlog processor with a stable sort and a fixed page size is not a backlog processor. It is a dependable way to reprocess the same hundred rows while the tail lengthens behind it.

The cutover is proven; the rollback names a version that does not exist

The match-key cutover was proven by evidence rather than asserted. The old skip reason stops appearing at 2026-08-11 20:43:39Z. The new skip reason starts at 2026-08-12 22:48:04Z and is still firing. Two log signatures, two timestamps, one clean handoff — that is what a deploy proof looks like when nobody is asked to take a claim on faith.

And the same change carries a defect that was logged rather than quietly patched: the deploy version id was never recorded. The documented rollback procedure therefore names a version that cannot be resolved. It reads like a procedure and would fail as one at the moment it was needed, which is the only moment anyone would find out.

A rollback that has never been resolved to a real version is a paragraph, not a procedure. It is on the defect list under its own heading.

The same failure is pre-loaded on the next lever

The obvious-looking next move on an account like this is value-based bidding — feed the platform revenue instead of lead counts and let it optimize toward money. On this account that would take a signal holding one synthetic test row and hand it to an optimizer, which would throttle spend toward zero. It is the same failure shape as the target-CPA throttle already documented on this estate: a bidder acting rationally on a signal nobody validated.

So value-based bidding stays off until real revenue-carrying conversions accrue. It is not the next step, it is the step after the one that has not been proven yet, and presenting it as available would repeat the mistake this post is about.

The uploader nobody was watching

There was already a second uploader. A separate service had been sending won-invoice revenue with hashed identifiers into a conversion action that reads REMOVED and non-biddable, with four events sent in its entire history. Accepted by the platform, ingested nowhere useful, running for roughly twelve months.

Accepted is not ingested. Ingested is not counted. Counted is not biddable. Each of those transitions is a place a measurement can die while every status code involved stays in the 200s.

The refusal

There is a way to make this number look better, and it will not be used. Won jobs that originated from organic phone calls could be matched into the ad platform through identity-based matching — the customer identity exists on both sides of that join, and the mechanism is documented and available. Doing it would attribute organically-sourced revenue to paid media. Return on ad spend would rise, the attributed revenue would be real revenue, and the attribution would be false. Organic-call won-job revenue is therefore permanently out of ad-platform scope on this account, by policy rather than by capability. The temptation to do otherwise is exactly proportional to how thin the honest number currently looks, which is precisely why the rule is written down in advance instead of decided in the moment.

What would prove this wrong

The central claim is that this leg cannot match a call-sourced won job as built, and that the only emission it has ever produced was its own test.

  • One successful upload at the signed stage, matched on a real customer-originated click identifier. That single row would falsify "cannot match as built," and it is the cleanest available test.
  • A won job that originated on a web form with its click identifier intact, surviving the pipeline end to end. The claim is about the dominant call-sourced population, not about every conceivable path, and this is the path that could contradict it.
  • Source evidence that the reprocessor's ordering or row limit differs from what its own comments say. The oldest-100 finding rests on reading the code and on one dated arithmetic check; a second, later read is the right way to attack it.
  • A rollback executed successfully against a resolved version. That would close the deploy-version defect and remove it from this account's list.

No remediation outcome is claimed anywhere above. The match-key repair is specified and partially deployed; it is not verified, and the backfill that would recover the existing backlog has not run. When it does, the read will carry its date and its method, and it will be published whichever way it comes back.


If a CRM-to-ad-platform integration is running in your account, the useful question is not whether it is green. It is: name the field the join happens on, then check what share of last quarter's leads actually carried it.

  • #blog
  • #offline-conversions
  • #attribution
  • #crm
  • #match-key
  • #google-ads
Share X LinkedIn
Build it yourself?

Get the kit, not just the theory.

We'll send the build checklist behind this post — and the next pillar when it ships. One email, no drip sequence. Unsubscribe in one click.

Want this built for you?

Book a discovery call. We'll walk your numbers.

20 minutes. Tell us what's broken, hear what we'd ship in the next 90 days. No pitch deck.