The Question I Couldn't Answer
Every month, ad spend went out the door. To Naver, to Google, to Meta. The number shrinking in the bank account was always crystal clear.
What was blurry was the other side. What that money turned into, coming back, was invisible.
Then one day, someone asked me a question.
"So, which ad actually made us money?"
Simple question. I couldn't answer it.
The number of form submissions was right there on the screen. But a form isn't revenue. Our revenue comes from "activation." We run a business that compares telecom and rental plans and helps people sign up. A request comes in, we consult, and only days later, once the line is actually activated, does the commission land.
Activation happens off-screen. And days later. There was a gap between the moment someone clicked an ad and the moment that click turned into money, and I had never really looked into that gap.
This post is the story of bridging that gap. Not a clean answer, but a record of wandering, overbuilding, stopping, and solving it again.
Why I Couldn't Answer: Conversion Splits in Two
If you run e-commerce, you don't have this problem. Click, add to cart, pay. Conversion and revenue are both captured in that instant, inside the browser. One pixel is all you need. A pixel is a tracking snippet that tells the ad platform "this person converted." The moment payment happens, the pixel fires straight to Meta, to Google. The platform learns from that signal: "bring me more people like this."
I assumed for a long time this was the standard case. It wasn't. E-commerce is the lucky, easy exception.
For us, conversion splits into two pieces. The form submission happens now, in the browser. Activation is confirmed days later, on a server. In between is a void. I call this void the "gap." Every bit of trouble that follows in this post traces back to that one word.
This is where it gets real. To make ads learn revenue, you have to send activation, from the far side of the gap, back to the ad platform. And to do that, you first have to prove that the person on both sides of the gap is the same person. Easy to say. In practice, this was the hardest part of all.
First Bridge: Connecting Activation Back to the Web
Before building anything myself, I checked what I could just buy. Surely something like this already existed as a product. GA4 has offline conversion import, and there's no shortage of call-tracking SaaS tools. But both only cover half of our gap. GA4's offline import just pushes activation data into GA4, it doesn't stitch phone calls, rental leads, and keywords into one person. Call-tracking SaaS sees the phone call, but can't connect it to an activation that gets confirmed on a server days later, and it costs a monthly fee on top. In the end, nobody was going to do the actual "tie this to one person" work for me. So I built it myself, on top of the BigQuery setup we already had running. Solo, over a few weeks.
The first problem to solve was clear. Connect the person who filled out a web form to the person who activated days later. What proves they're the same person?
The most reliable answer is a phone number. Same number, same person. But I hit a wall immediately. A phone number is personal information. You can't put it into GA4, or into an ad pixel. It's blocked by law and blocked by platform policy. The most reliable key was unusable in exactly the place I needed it most.
So I built two bridges instead.
The first is order_id. When a form is submitted, the server returns a lead number. That number gets embedded in the web conversion event. The activation record carries the same number. Later, the two get joined on that single number. It's just a number, not personal information, so it can go anywhere, into ads, into analytics, freely.
The second is a hashed phone number. The phone number gets run through a one-way SHA-256 transform. The original can't be recovered from the hash. This isn't "no longer personal information," though, it's pseudonymization: using the value for matching without exposing the raw number (strictly speaking it's still pseudonymized data, so safeguards and consent scope still apply separately). Same person, same hash, so this is what ties together the person who came from the web, the person who called, and the person who came through a rental signup, into one identity.
One more wrinkle. Not everyone has a phone number on file. Someone who just browsed the site without submitting a form has nothing to hash. So the key that represents a single person (person_id) follows a priority: hashed phone number if it exists, otherwise device_id (a browser identifier). If a phone number shows up later, whatever history had accumulated under that device_id gets merged into the same person. Get this priority order wrong, and one person splits into two, or two strangers merge into one. Counting people turns out to be, more than anything, a fight over the rules for deciding who counts as "the same person."
Once both bridges were in place, one principle became clear. The raw phone number stays on the server, period. Only the number (order_id) and the pseudonymized hash key flow out to ads and analytics. Personal information stays locked inside, and only the keys needed to connect things go out.
Say, for example, Mr. Kim came in through a Naver search ad. Keyword: "internet signup." He submitted a consultation form. The server returned order_id 84213, and the web conversion event carried db_id=84213. Three days later, his KT line activated. Commission: KRW 300,000. The CRM activation record still has order_id 84213 on it. BigQuery joined the two records on that number. And at that moment, a single sentence finally becomes possible: "This KRW 300,000 is revenue brought in by the Naver keyword 'internet signup.'" That sentence is exactly what the ad platform needs to learn from.
There was one more thing to fix before the join would actually work. If the same person submits a form twice, conversion gets counted twice. So identical events coming from the same order_id get collapsed into one (deduplication). It sounds trivial, but skip it and you end up with the absurd result of "more conversions than activations," and decisions made on that broken table go wrong all the way down the line. Data integrity isn't glamorous, but analysis built on wrong numbers collapses entirely.
Bridge Built, Next Problem Surfaces
The web form was solved. But once I actually looked at customers, a good chunk of them hadn't used the form at all. They just called.
A phone call happens outside the browser. GTM and GA4 only see inside the browser. There's no trace on screen of who saw what and then picked up the phone. The form was solved, and the phone was a complete blackout. Honestly, I was stuck for a while.
The fix turned out to be simple. Use a different phone number per channel. Someone who came from Naver sees a Naver number, someone from Google sees a Google number. Then the number that was called becomes the channel itself. Pull call center data into BigQuery and join it against a "number → channel" table. Suddenly "which channel drove this call" is visible. No fancy system needed. One lookup table did it.
But go one layer deeper and I hit another wall. Search ads need more than just the channel. To spend budget correctly, you need to know which keyword, not just "Naver", drove the revenue. But a phone call carries no keyword. And you can't assign a separate number per keyword either; there are thousands of keywords, and you can't buy thousands of phone numbers.
So instead, I connected them by time. There's a timestamp for when someone tapped the call button on the site. There's a timestamp for when the call landed at the call center. Match the two. Same number, within one or two minutes, count it as the same person. Our call volume runs around 300 calls a day, 80% mobile. On mobile, tapping the button goes straight into a call, so the gap between click and call is short. On top of that, there are only twenty numbers and calls are spread across nine hours, so two calls landing on the same number within the same one-to-two-minute window rarely overlap.
For example, at 14:32:05, a call comes in on the Naver number. Look back one to two minutes. There's exactly one record of the call button being tapped on that same number: 14:31:50, keyword "wifi install." Only one candidate, so there's nothing to second-guess. That keyword gets attached to the call. And if this person activates three days later? The first bridge (order_id and hashed phone) picks it up and connects it. Then "keyword 'wifi install' → call → KRW 300,000 activation" closes into a single line.
Of course, time-based matching isn't always clean. What if two people tap the same number within the same one-to-two-minute window? Then the server timestamp, down to the microsecond, gets matched to the nearest click. If it's still ambiguous, we leave it unmatched rather than guess. One wrongly joined record does more damage to analysis than ten unmatched ones. And this matching doesn't need to be real-time. Since activation is confirmed days later anyway, one batch join a day is plenty. Standing up a real-time matching server would have been overbuilding too.
With both bridges in place, what looked like scattered traces of one person came together into a single thread. Mr. Kim, who wandered over several days from Naver to Meta and back before finally calling. His anonymous web trail and his incoming call got tied together by a single hashed phone number, and that thread stretched all the way to an activation three days later. This was the "real picture" the ad needed to learn.
There's one more thing worth noticing here. Mr. Kim came through Naver, came back through Meta, and then called. So whose credit is this activation? Back in the pixel-only days, this wasn't even a question you could ask. Whichever channel got the last click took the entire credit (last-click). Now that the full path is visible as one line, we finally get to choose: credit the first touch, the last touch, or split it. When tracking was broken, the platform decided credit for us however it wanted. Now that tracking works, we decide. This is the real difference between a single pixel and tracking that actually crosses the gap.
The Overbuild I Almost Shipped
Here's where I almost let the scope balloon.
There's a far more precise way to track phone calls. It's called "full DNI" (dynamic number insertion). It assigns a different phone number to each individual visitor, dynamically, on the fly. You rent a pool of numbers, run a state machine so number reuse doesn't collide, and score confidence to filter out noise. I sketched out the whole design in my head. It looked pretty compelling.
Then I stopped myself.
To be clear, we run ads across Naver, Google, and Meta all at once. But I wasn't trying to wire up tracking for all of them simultaneously. The plan was to build Naver first and defer the rest. And at that first stage, was I really about to build a heavyweight system that assigns a dynamic number to every single visitor?
One question stopped my hand. "Do I actually need this right now?"
I didn't. Time-window matching was enough. Without an expensive number pool, just connecting the data by timestamp caught the vast majority of mobile calls. The same went for feeding activations back into the ad platform to auto-optimize bidding. It looked cool. But what we needed right now was "measurement," not "automation." I shelved that too.
I only realized it later. Deciding not to build something is harder than deciding to build it. Choosing not to build something you know how to build takes a different kind of restraint than building does. Recognizing what you don't need to do was half the job.
What Looked "Untrackable" Turned Out to Be Ours All Along
On the rental side, I made a real misstep. I'm writing it down anyway, embarrassing as it is, because it's the thing I learned the most from.
The rental consultation form was an external vendor's site embedded in an iframe. An iframe is another site, embedded inside our page. Under browser security rules, a parent page can't look inside an iframe from a different origin. So I wrote one line in the docs: "Rental can't be tracked."
A few days later, I opened that form again to double check. And looking closely, that page wasn't someone else's after all. It was a cafe24 page we had built ourselves. It was our own asset, one we could modify.
I laughed out loud at myself. The wall I'd assumed was blocking me, the one I'd stamped "give up" on, had been mine to begin with. I fixed it so that submitting the form sent a single signal to the parent page, and that was it, done.
What I learned that day wasn't technical. Double-check before you write "this can't be done." Sometimes the wall you assume is blocking you is actually a door you're holding the key to.
What Can't Be Done, I Wrote Down Honestly as Can't Be Done
I didn't solve everything. And I didn't pretend to solve what I couldn't. I set it aside explicitly.
One gap: people who only ever called, with no web trace at all. If they never tapped a button, there's no click to match against a timestamp. This person can't be tied to a keyword. We only see the channel. It's not that there's absolutely no way to do it, it's that the cost of doing it exceeds the value it returns. So for now, it's shelved.
One more thing I want to be honest about. Earlier I said "the vast majority of mobile calls get caught." That "vast majority" isn't a measured number. It's an expected value calculated from how many calls per day come in on a single number. Running it for real is the only way to get the actual figure. If I'd just waved it off with "it'll probably be fine," I'd have had nothing to say the day someone asked "why doesn't this number add up." Writing down "we don't know" beats papering over the unknown with an estimate.
So Where Do We Actually Stand?
After all that wandering, the shape of the difficulty became clear. It comes down to one thing: "where does the clean loop that closes inside the browser break?"
The hardest domains stack multiple difficulties at once. Take B2B enterprise. The deal closes offline, the journey drags on for months, and multiple decision-makers are involved in one deal. Three difficulties, carried at the same time. Healthcare is the same. Privacy regulation is extreme, and on top of that, conversion itself happens offline (hospital, prescription).
By comparison, what we have is one offline gap and a small amount of anonymous calling. It's genuinely hard to do properly, but it's not a spot where multiple difficulties pile up at once. Which also means: however much we struggled, the people further up this ladder are struggling more.
One honest caveat, though. The only domain I've actually lived through myself is lead-gen. B2B and healthcare are things I've heard about and reasoned through, not lived. To anyone actually standing in those spots, this map may look shallow.
If This Is Your Business
If you've read this far, there's one question that maps directly onto your own business.
Does conversion finish in the browser? Or does it get confirmed days later, off-screen?
If the former, congratulations. One pixel is all you need. If the latter, a pixel alone won't cut it. You need a bridge across the gap. Following the order we built things in: don't buy the expensive tool first, just work your way down from the top.
- Pick one non-PII key that connects the web conversion to the "outcome" (activation) (ours was order_id)
- Accumulate a hashed phone number on the server to tie together channels and people
- Split phone-call source by channel-specific number (the number called = the channel)
- If you need keyword-level detail, match calls to clicks by timestamp (no expensive number pool required)
- Whatever can't be done, write it down as "not yet done" instead of papering over it with an estimate
- Before building anything bigger, ask every time: "do we actually need this at our current scale?"
Now the Real "Learning" Begins
Everything up to this point has been "plumbing." Scattered touchpoints stitched into one person, threaded all the way through to activation. But honestly, the ad platform hasn't actually "learned" revenue yet. All I've done is gather the raw material it needs to learn from.
One last step remains. Send this connected activation data back to the ad platforms through Measurement Protocol (MP) and Conversions API (CAPI). Then Naver, Google, and Meta start optimizing bids based on "who actually activated," not "who filled out a form." That's the "learning" the title promised.
Success gets measured in numbers, not gut feeling. Activation ROAS by keyword. How much actual activation revenue did the keyword "internet signup" generate against the ad spend behind it? Once that number exists, budget flows toward keywords that make money, and underperforming keywords get cut. The before-and-after comparison is the real answer to whether all this plumbing was worth it.
That number doesn't exist yet. The raw material has only just been connected. But for the first time, we're standing in a place where that number can actually be measured.
Closing
If attribution feels hard, it's because the problem genuinely is hard. Attribution is the work of crediting which ad actually drove a result. E-commerce was easy not because of skill, but because its loop happened to close inside the browser. Most real businesses don't get that luck.
I can answer the question now. It's still not perfectly clean. Anonymous calls remain unconnected, and the "vast majority" figure still needs to be confirmed with real measurement. But the main thread of "which ad actually made us money" is now visible. The ads started learning revenue, not just forms. The road to get here is this post, in its entirety.
A build log of conversion tracking for Bigyowon's telecom and rental comparison signup service. Every decision, rationale, and limitation is recorded here so the next person doesn't wander the same road twice.
Found this useful? Share it
Latest posts
Kakao Share Thumbnail Not Showing? Here's the Fix (JavaScript SDK 2025)
Learn why Kakao Share thumbnails don't appear even with a valid imageUrl, and fix it using scrapImage to convert your URL into a KAGE CDN URL. Step-by-step guide.
How to Generate CRM User Manuals in 20 Minutes with Claude Code Sub-Agents
Learn how Claude Code sub-agents can analyze your CRM codebase and auto-generate user manuals and visual guidebooks in just 20 minutes. See the real-world results.
GEO Generative Engine Optimization: The Complete 2025 AI Marketing Strategy Guide
Learn what GEO (Generative Engine Optimization) is, how it differs from traditional SEO, and discover 5 proven AI marketing strategies to get cited by AI search engines in 2025.
Related projects
Get new posts by email
Insights on marketing, analytics, and dev, delivered to your inbox.