Beyond last-click: building an attribution engine people could trust
How we connected ad-platform data to downstream business outcomes, and why trust mattered more than the model
Connecting ad data and business outcomes for smarter decisions at Deriv
Someone in Lagos sees one of our display ads on a news site. They do not click.
Two days later, they search for Deriv on Google, land via PPC, and leave.
A week later, they come back through an affiliate review, sign up, deposit 50 USD, and place their first trade.
In a last-click world, the affiliate gets all the credit. The PPC visit gets none. The display impression that started the journey disappears completely.
That was the blind spot.
Our Performance Marketing team could already see spend, impressions, clicks, and platform-level performance. They could also see signups, first deposits, and funded accounts. What they could not see clearly was the bridge between the two.
And because that bridge was missing, upper-funnel media was always at risk of looking weaker than it really was. Every budget conversation drifted toward whichever channel happened to get the final click.
The real problem was not attribution. It was trust.
Yes, the modelling mattered. But the harder challenge was building a system people could rely on. We needed something that could pull data in on a schedule, normalise incompatible platform schemas, apply decision rules transparently, and return answers in a way the team could actually interrogate. That is what turned this from a reporting layer into a decision system.
The architecture
We built the engine in four layers. Each one solves a different trust problem.
To understand how data moves through the system, here is a detailed breakdown of what happens at each layer:
Layer 1 — Getting the data in reliably
The ad platforms we use do not export data in the same shape, and one of them still depends partly on a Google Sheets fallback when the API path is incomplete. That means the ingestion layer cannot just run quietly in the background.
The key engineering discipline here has been to make failure modes visible, so the team always knows what data the system is working from. If we are running on last week’s sheet because the API timed out, the system should say so.
Scheduling runs on a cron-based flow with per-source retry handling. The pipeline is designed to fail loudly so the team always knows what data it is working from.
Layer 2 — Schema normalisation and identity resolution
This step sounds boring. It is also where trust begins.
Different sources used different names for the same concepts, measured conversions differently, and reported costs inconsistently. Before we could ask better questions, we had to make sure publisher, impressions, clicks, conversions, and spend mean the same thing everywhere. That was where trust really began.
The UTM medium field in our downstream data goes through a similar bucketing pass — mapping values like ppc-paid_search, ppc-paid_social, and broker-listing into PPC, mapping affiliate into Affiliate, and grouping everything else under Others. That bucketing is what makes the funnel view coherent across campaigns.
Layer 3 — Dual functions
We were not building attribution for its own sake. We needed outputs that helped the team act. Which placements were wasting spend? Which creatives were underperforming? Which downstream channels were benefiting from users who had first encountered Deriv through display? The system had to support both operational decisions and analytical ones.
Operational: Stop paying for junk
The first question is operational: which placements and creatives are spending money without producing conversions?
We built kill-list logic that grouped performance by publisher and creative, applied configurable thresholds, and surfaced the worst offenders first. That gave the team a ranked list of placements to exclude and creatives to pause. What used to take manual reporting could now be seen and acted on immediately.
// Step 1 — roll up all activity per publisher domain
for each publisher in normalisedPlacementData:
totalImpressions = sum(impressions across all campaigns)
totalClicks = sum(clicks across all campaigns)
totalConversions = sum(conversions across all campaigns)
totalSpend = sum(cost across all campaigns)
// Step 2 — apply the exclusion rule
if totalImpressions >= trafficThreshold // enough data to judge
and totalClicks >= engagementThreshold // ad was seen and clicked
and totalConversions <= conversionCeiling: // but never converted
status = "Excluded"
wastedSpend = totalSpend
else:
status = "Active"
// Step 3 — surface the worst offenders first
return publishers where status == "Excluded"
sorted by wastedSpend descending
Thresholds are per-platform because treating Taboola with AdRoll’s CTR rule auto-kills the entire Taboola inventory on day one — native ads genuinely have lower CTR than retargeted display. That lesson cost us an uncomfortable conversation with the vendor before we moved the thresholds into config.Analytical: Post-view funnel
The second question is analytical: after a display impression, which channels are actually helping users finish the journey?
The funnel filter selects paths where:
The first touch is a display impression (AdRoll/M2O/Taboola post-view event), and
The conversion (first_deposit) happens within 30 days, and
At least one intermediate touchpoint exists (PPC, affiliate, organic, direct)
At a data level, the funnel is built like this:
// Step 1 — define the post-view cohort
journeys = allConversionPaths
.filter(journeyType == “post_view”) // exclude click-through paths
// Step 2 — map UTM medium to a readable channel
for each journey in journeys:
if utmMedium in [ppc-paid_search, ppc-paid_social, broker-listing]:
downstreamChannel = “PPC”
else if utmMedium == “affiliate”:
downstreamChannel = “Affiliate”
else:
downstreamChannel = “Others”
// Step 3 — compute funnel stages per channel
for each channel in [PPC, Affiliate, Others]:
visitors = count(users in channel)
newRealAccount = count(users where realAccountCreatedDate != null)
firstDeposit = count(users where firstDepositDate != null)
firstTrade = count(users where firstTradeDate != null)
// each stage is expressed as % of visitorsThe result helps the team ask a different question. Instead of “Did the display ad get the click?”, they can ask “Which channels did display-reached users convert through?” PPC, affiliate, and other channels still appeared in the funnel, but now as part of the full journey rather than as whichever touchpoint happened to come last.
That shift sounds small. It was not.
In one display-initiated cohort, last-touch attribution gave display just 7.2% of the credit. A Markov model gave it 47.1%. That is not a rounding difference. It is the difference between treating upper-funnel spend as weak and recognising that it is doing meaningful work earlier in the path to conversion.
This was where the system stopped being a dashboard and started changing decisions.
Layer 4 — Dashboard and NL queries
The dashboard had several jobs. It showed high-level KPIs, surfaced placement and creative waste, and exposed the post-view funnel as a readable view across channels.
But a dashboard alone does not create confidence. The numbers have to be explainable. Thresholds have to be visible. Recommendations have to show the logic behind them. People trust systems they can interrogate. So we built a natural-language query layer so the team could ask practical follow-up questions without waiting on manual analysis. Things like which websites were spending heavily without converting, which regions were driving more revenue, or which campaigns had strong CTR but weak deposit outcomes.
Rather than shipping a model that writes free-form queries against raw data with a hallucination risk, the chatbot routes each question through a fixed set of structured analytics tools. The model identifies what the user wants; a pre-validated executor handles the actual data retrieval. If the question doesn’t match a known tool, the system asks for clarification rather than guessing.
Under the hood, every query follows the same narrow path:
// Step 1 — interpret the natural-language question
userIntent = model.classify(userQuery)
→ “high spend, low conversions” → get_high_spend_low_conversion_sites
→ “best converting ad sizes” → get_highest_converting_ad_sizes
→ “CTR high but deposits low” → get_high_ctr_low_deposit_campaigns
→ no match → ask user to clarify
// Step 2 — execute against pre-validated campaign data
results = executor.run(userIntent, region=inferredRegion)
// Step 3 — format and return
return markdownTable(results) // structured output, no free-form hallucination
The real value was not speed. It was confidence.
Before this, budget conversations were built on last-click reporting and spreadsheet work. After this project, the team had a clearer view of which placements were wasting spend, where display was assisting other channels, and how downstream outcomes looked when you stopped giving all the credit to the final click. The finance conversation became more grounded in funded-account outcomes rather than surface-level engagement metrics.
What took longer than expected?
Three things.
Normalisation. The platforms disagreed on names, definitions, currencies, time zones, and even what counted as a conversion. Getting that alignment right turned out to be more important than it looked at the start.
Threshold calibration. A rule that made sense for one platform was too aggressive for another. Some environments naturally behaved differently, so global defaults created bad recommendations. We had to make the system more context-aware before it became useful.
Trust. Shipping the dashboard took a few weeks. Getting the team to trust it took months. That only happened once the system became transparent enough to explain itself.
That, more than anything, was the lesson.
The technical challenge was real. But the human challenge was bigger. It was not enough to produce smarter attribution. We had to produce attribution that people believed, questioned, and used.
What’s next
The current version already makes attribution easier to see and easier to act on. The next step is to tighten that loop further: broader query coverage, deeper analysis of user paths, and more automation in the way decisions flow back into campaign management.
The spreadsheet is gone
More importantly, the conversation changed.
We stopped asking which channel got the final click and started asking which touchpoints actually moved a user closer to becoming a funded account.
That shift mattered more than the dashboard itself.
Harsh Solanki is an AI Engineering Team Lead at Deriv.
Mohammed Haroon is an AI Engineer at Deriv.
Follow us on LinkedIn and X/Twitter for company updates.
Join our team to work on projects like this.












