Marketing runs a webinar. Three months later the attendee converts after clicking a re-engagement email. MarketingPlus credits the email. Marketing tells you the webinar didn’t work. They cut webinar budget. Pipeline drops next quarter. Six months later you trace it back: 40% of the deals had touched a webinar before converting. Nobody saw it because last-touch attribution erased the evidence.
This is the attribution problem in one paragraph. Defaults lie. You need a model.
Why single-touch attribution is wrong
- First-touch over-credits awareness, under-credits closing motion. Your CMO loves it. Your AEs hate it.
- Last-touch over-credits the closing channel, under-credits everything that built the relationship. Your AEs love it. Marketing hates it.
Both are convenient. Both are wrong. They optimize the wrong channels and starve the ones that matter.
A multi-touch model that survives
Run three views in parallel. Don’t pick one.
- Linear: every touch gets equal credit. Good for cohort analysis, bad for budget decisions.
- Position-weighted (40-20-40): first touch 40%, last touch 40%, middle touches split the remaining 20% equally. The default we use for budget.
- Time-decay: touches closer to the deal close get exponentially more credit. Good for short cycles.
Show all three. The truth is in the disagreement between them. If three models say “Channel X works,” it does. If only last-touch says so, it doesn’t.
The data model in CRM
Don’t try to store attribution as one field. You’ll regret it.
Lead.Touchesrelated list: timestamp, channel, campaign, touch type, sourceDeal.Attribution_Linear: JSON or text — per-channel share, calculatedDeal.Attribution_PositionWeighted: sameDeal.Attribution_TimeDecay: sameDeal.Attribution_Computed_At: when the model last ran
Compute on close-won. Don’t compute on every update; it’s expensive and pointless before close.
The Deluge that computes position-weighted
Runs on stage change to Closed Won. Pulls all touches in the deal’s history, weights by position, writes back.
// compute_position_weighted_attribution
// Trigger: Deal stage = Closed Won
deal_id = input.id;
deal = zoho.crm.getRecordById("Deals", deal_id);
contact_id = deal.get("Contact_Name").get("id");
// Get touches associated with the contact, before close date, ordered asc
close_date = deal.get("Closing_Date");
touches = zoho.crm.searchRecords(
"Marketing_Touches",
"(Contact:equals:" + contact_id + ")and(Touched_At:before:" + close_date.toString() + ")",
1, 200
);
if(touches.size() == 0) { return; }
// Sort by timestamp ascending (defensive — searchRecords may not guarantee)
sorted = touches.sortByColumn("Touched_At", "asc");
n = sorted.size();
// Position weights: first=0.4, last=0.4, middle split 0.2
weights = List();
if(n == 1) { weights.add(1.0); }
else if(n == 2) { weights.add(0.5); weights.add(0.5); }
else
{
weights.add(0.4);
middle_share = 0.2 / (n - 2);
for(i = 1; i < n - 1; i = i + 1) { weights.add(middle_share); }
weights.add(0.4);
}
// Aggregate by channel
channel_credit = Map();
for(i = 0; i < n; i = i + 1)
{
ch = sorted.get(i).get("Channel");
existing = ifnull(channel_credit.get(ch), 0).toDecimal();
channel_credit.put(ch, existing + weights.get(i).toDecimal());
}
// Write back as JSON
zoho.crm.updateRecord("Deals", deal_id, {
"Attribution_PositionWeighted": channel_credit.toString(),
"Attribution_Computed_At": zoho.currenttime,
"Total_Touches": n
});
Three things matter:
- Defensive sort. Don’t trust query order.
- Edge cases (1 touch, 2 touches) handled explicitly.
- JSON storage for flexibility. Analytics can parse it.
How touches get logged
A touch is anything you can attribute. Email open, link click, webinar registration, form submit, ad click, sales email reply. Each fires a workflow that creates a Marketing_Touches row.
Don’t try to log everything. Pick 5–7 high-signal events. Twenty event types and the data becomes noise.
The MarketingPlus → CRM sync writes touches into the related list automatically for native events. For non-native (ad clicks, third-party webinar tools), wire a webhook.
Reporting the model
In Analytics, blend the Deals table with the Marketing_Touches table. Parse the JSON. Build:
- Channel attribution by ARR (which channels brought the most weighted ARR in Q?)
- Channel attribution by deal count
- Average touches to close, by deal size band
- Time from first touch to close, by channel
That last one is the killer. If your “best” channel takes 14 months to close and your “worst” takes 3, your budget allocation is wrong even if attribution says otherwise.
Common mistakes
- Attributing only to the contact who closed. Multi-stakeholder deals have multi-stakeholder touches. Roll up at the account level for any deal over $50k.
- Mixing time zones in
Touched_At. Always store UTC. Display in user zone. - Counting bot opens. Apple Mail Privacy and corporate inbox bots inflate opens. Strip them or weight clicks more heavily than opens.
- Re-running attribution after close. Locks in the model. If you change methodology, write a new column, don’t overwrite the old one. You’ll want to compare.
When to add UTM-based attribution
If your campaigns push through paid ads, UTM parameters survive the click and land in CRM via form capture. Add a First_UTM_Source and Last_UTM_Source to Lead. Don’t overwrite once set — First_UTM_Source should never change after creation. Workflow rule: if First_UTM_Source is empty and UTM came in on a form fill, set it.
The honest disclosure
Attribution is directional, not exact. Show ranges. “Channel X drove $400k–$600k of pipeline last quarter, depending on model” is true. “Channel X drove $487,213” is theater.
Tell stakeholders this once. Tell them again. Then tell them a third time when someone screenshots a number out of context.
For the foundational reporting layer this builds on, see Zoho Analytics fundamentals. For when sync glitches corrupt your touch table, Zoho Campaigns CRM sync gotchas is required reading.
Key takeaways
Default attribution lies. Run linear, position-weighted, and time-decay in parallel. Store touches as a related list. Compute on close-won, not on every update. Store JSON for flexibility. Roll up at the account for big deals. Report ranges, not point estimates. The campaign that mattered was probably three months before the conversion you can see today.