7 min read
Cross-Channel Attribution
Unified measurement across Google, Meta, LinkedIn, TikTok, and offline channels. Break down walled gardens.
The Walled Garden Problem
Every ad platform self-reports attribution, often claiming credit for the same conversion multiple times.
code
Platform Self-Reported Attribution:
Google Ads: "500 conversions"
Meta Ads: "450 conversions"
LinkedIn Ads: "200 conversions"
TikTok Ads: "150 conversions"
─────────────────
Total Claimed: 1,300 conversions
Actual: 600 conversions
Overcounting: 117%Why This Happens: Each platform uses its own attribution window and model, claiming full credit for any touchpoint—even when multiple platforms touched the same user.
Building Unified Identity
Client-Side Identity Collection
code
class CrossChannelIdentity {
constructor() {
this.identifiers = {};
this.collect();
}
collect() {
// First-party identifiers
this.identifiers.client_id = this.getOrCreateClientId();
this.identifiers.user_id = this.getAuthenticatedUserId();
// Platform click IDs
this.identifiers.gclid = this.getParam('gclid'); // Google Ads
this.identifiers.gbraid = this.getParam('gbraid'); // Google iOS
this.identifiers.fbclid = this.getParam('fbclid'); // Meta
this.identifiers.li_fat_id = this.getParam('li_fat_id'); // LinkedIn
this.identifiers.ttclid = this.getParam('ttclid'); // TikTok
this.identifiers.msclkid = this.getParam('msclkid'); // Microsoft
// Platform cookies
this.identifiers.ga_client_id = this.getGAClientId();
this.identifiers.fbp = this.getCookie('_fbp');
this.identifiers.fbc = this.getCookie('_fbc');
// Device fingerprint for cross-device
this.identifiers.device_id = this.getDeviceFingerprint();
this.persist();
}
getOrCreateClientId() {
let clientId = localStorage.getItem('unified_client_id');
if (!clientId) {
clientId = 'ucid_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('unified_client_id', clientId);
}
return clientId;
}
getGAClientId() {
const gaCookie = this.getCookie('_ga');
if (gaCookie) {
return gaCookie.split('.').slice(2).join('.');
}
return null;
}
getDeviceFingerprint() {
const components = [
navigator.userAgent,
navigator.language,
screen.width + 'x' + screen.height,
new Date().getTimezoneOffset()
];
return this.hash(components.join('|'));
}
hash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash = hash & hash;
}
return 'fp_' + Math.abs(hash).toString(36);
}
async syncToServer() {
await fetch('/api/identity/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifiers: this.identifiers,
timestamp: Date.now(),
url: window.location.href,
referrer: document.referrer
})
});
}
getParam(name) {
return new URLSearchParams(window.location.search).get(name);
}
getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
}
persist() {
localStorage.setItem('unified_identifiers', JSON.stringify(this.identifiers));
}
}
// Initialize
const identity = new CrossChannelIdentity();
identity.syncToServer();Server-Side Identity Resolution
code
import { createClient } from '@supabase/supabase-js';
interface IdentityRecord {
profile_id: string;
client_id: string;
device_id: string;
user_id?: string;
platform_ids: Record<string, string>;
email_hash?: string;
}
class IdentityResolutionService {
private supabase;
constructor() {
this.supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
}
async resolveIdentity(identifiers: Partial<IdentityRecord>): Promise<string> {
let profileId: string | null = null;
// Priority 1: Authenticated user_id
if (identifiers.user_id) {
const { data } = await this.supabase
.from('unified_profiles')
.select('profile_id')
.eq('user_id', identifiers.user_id)
.single();
if (data) profileId = data.profile_id;
}
// Priority 2: Email hash
if (!profileId && identifiers.email_hash) {
const { data } = await this.supabase
.from('identity_records')
.select('profile_id')
.eq('email_hash', identifiers.email_hash)
.single();
if (data) profileId = data.profile_id;
}
// Priority 3: Client ID
if (!profileId && identifiers.client_id) {
const { data } = await this.supabase
.from('identity_records')
.select('profile_id')
.eq('client_id', identifiers.client_id)
.single();
if (data) profileId = data.profile_id;
}
// Priority 4: Platform click IDs
if (!profileId && identifiers.platform_ids) {
for (const [key, value] of Object.entries(identifiers.platform_ids)) {
if (value) {
const { data } = await this.supabase
.from('identity_records')
.select('profile_id')
.contains('platform_ids', { [key]: value })
.single();
if (data) {
profileId = data.profile_id;
break;
}
}
}
}
// Create new profile if not found
if (!profileId) {
profileId = `prof_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
await this.supabase.from('unified_profiles').insert({
profile_id: profileId,
created_at: new Date().toISOString()
});
}
// Link identity to profile
await this.supabase.from('identity_records').upsert({
profile_id: profileId,
...identifiers,
last_seen: new Date().toISOString()
}, { onConflict: 'client_id' });
return profileId;
}
async getUnifiedJourney(profileId: string) {
const { data: identities } = await this.supabase
.from('identity_records')
.select('client_id')
.eq('profile_id', profileId);
const clientIds = identities?.map(i => i.client_id) || [];
const { data: touchpoints } = await this.supabase
.from('touchpoints')
.select('*')
.in('client_id', clientIds)
.order('timestamp', { ascending: true });
return touchpoints || [];
}
}
export const identityService = new IdentityResolutionService();Platform Data Integration
Google Ads API
code
import { google } from 'googleapis';
class GoogleAdsConnector {
private client;
constructor(credentials: any) {
this.client = new google.ads({
version: 'v15',
auth: new google.auth.GoogleAuth({
credentials,
scopes: ['https://www.googleapis.com/auth/adwords']
})
});
}
async getCampaignData(customerId: string, startDate: string, endDate: string) {
const query = `
SELECT
campaign.id,
campaign.name,
metrics.clicks,
metrics.impressions,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
segments.date
FROM campaign
WHERE segments.date BETWEEN '${startDate}' AND '${endDate}'
`;
const response = await this.client.customers.googleAds.search({
customerId,
query
});
return response.data.results?.map(row => ({
platform: 'google_ads',
campaign_id: row.campaign?.id,
campaign_name: row.campaign?.name,
date: row.segments?.date,
clicks: row.metrics?.clicks,
impressions: row.metrics?.impressions,
spend: (row.metrics?.costMicros || 0) / 1_000_000,
conversions: row.metrics?.conversions,
revenue: row.metrics?.conversionsValue
}));
}
}Meta Marketing API
code
class MetaAdsConnector {
private accessToken: string;
private adAccountId: string;
constructor(accessToken: string, adAccountId: string) {
this.accessToken = accessToken;
this.adAccountId = adAccountId;
}
async getCampaignData(startDate: string, endDate: string) {
const fields = 'campaign_id,campaign_name,clicks,impressions,spend,actions,action_values,date_start';
const response = await fetch(
`https://graph.facebook.com/v18.0/act_${this.adAccountId}/insights?` +
`fields=${fields}&` +
`time_range={"since":"${startDate}","until":"${endDate}"}&` +
`level=campaign&time_increment=1&` +
`access_token=${this.accessToken}`
);
const data = await response.json();
return data.data?.map((row: any) => {
const purchases = row.actions?.find((a: any) => a.action_type === 'purchase');
const purchaseValue = row.action_values?.find((a: any) => a.action_type === 'purchase');
return {
platform: 'meta_ads',
campaign_id: row.campaign_id,
campaign_name: row.campaign_name,
date: row.date_start,
clicks: parseInt(row.clicks || 0),
impressions: parseInt(row.impressions || 0),
spend: parseFloat(row.spend || 0),
conversions: parseFloat(purchases?.value || 0),
revenue: parseFloat(purchaseValue?.value || 0)
};
});
}
}LinkedIn Marketing API
code
class LinkedInAdsConnector {
private accessToken: string;
private adAccountId: string;
constructor(accessToken: string, adAccountId: string) {
this.accessToken = accessToken;
this.adAccountId = adAccountId;
}
async getCampaignData(startDate: string, endDate: string) {
const start = new Date(startDate);
const end = new Date(endDate);
const response = await fetch(
`https://api.linkedin.com/v2/adAnalyticsV2?` +
`q=analytics&pivot=CAMPAIGN&` +
`dateRange.start.day=${start.getDate()}&` +
`dateRange.start.month=${start.getMonth() + 1}&` +
`dateRange.start.year=${start.getFullYear()}&` +
`dateRange.end.day=${end.getDate()}&` +
`dateRange.end.month=${end.getMonth() + 1}&` +
`dateRange.end.year=${end.getFullYear()}&` +
`timeGranularity=DAILY&` +
`accounts=urn:li:sponsoredAccount:${this.adAccountId}&` +
`fields=clicks,impressions,costInLocalCurrency,externalWebsiteConversions`,
{
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'X-Restli-Protocol-Version': '2.0.0'
}
}
);
const data = await response.json();
return data.elements?.map((row: any) => ({
platform: 'linkedin_ads',
campaign_id: row.pivotValue,
date: `${row.dateRange.start.year}-${row.dateRange.start.month}-${row.dateRange.start.day}`,
clicks: row.clicks || 0,
impressions: row.impressions || 0,
spend: row.costInLocalCurrency?.amount || 0,
conversions: row.externalWebsiteConversions || 0
}));
}
}Unified Attribution Engine
code
class UnifiedAttributionEngine {
private identityService: IdentityResolutionService;
private model: string;
private lookbackDays: number;
constructor(config: { model: string; lookbackDays: number }) {
this.identityService = new IdentityResolutionService();
this.model = config.model;
this.lookbackDays = config.lookbackDays;
}
async processConversion(conversion: {
profile_id: string;
timestamp: Date;
value: number;
}) {
// Get unified journey
const touchpoints = await this.identityService.getUnifiedJourney(
conversion.profile_id
);
// Filter to lookback window
const lookbackStart = new Date(conversion.timestamp);
lookbackStart.setDate(lookbackStart.getDate() - this.lookbackDays);
const relevantTouchpoints = touchpoints.filter(t =>
new Date(t.timestamp) >= lookbackStart &&
new Date(t.timestamp) <= conversion.timestamp
);
if (relevantTouchpoints.length === 0) {
return [{ platform: 'direct', channel: 'direct', credit: 1.0, value: conversion.value }];
}
// Apply attribution model
return this.applyModel(relevantTouchpoints, conversion.value);
}
applyModel(touchpoints: any[], value: number) {
const n = touchpoints.length;
switch (this.model) {
case 'linear':
return touchpoints.map(tp => ({
platform: tp.platform,
channel: tp.channel,
campaign_id: tp.campaign_id,
credit: 1 / n,
value: value / n
}));
case 'position_based':
return touchpoints.map((tp, i) => {
let credit;
if (i === 0) credit = 0.4;
else if (i === n - 1) credit = 0.4;
else credit = 0.2 / (n - 2);
return {
platform: tp.platform,
channel: tp.channel,
campaign_id: tp.campaign_id,
credit,
value: value * credit
};
});
case 'time_decay':
const convTime = new Date(touchpoints[n - 1].timestamp).getTime();
const halfLife = 7 * 24 * 60 * 60 * 1000;
let totalWeight = 0;
const weights = touchpoints.map(tp => {
const diff = convTime - new Date(tp.timestamp).getTime();
const weight = Math.pow(2, -diff / halfLife);
totalWeight += weight;
return { tp, weight };
});
return weights.map(({ tp, weight }) => ({
platform: tp.platform,
channel: tp.channel,
campaign_id: tp.campaign_id,
credit: weight / totalWeight,
value: value * (weight / totalWeight)
}));
default:
return this.applyModel(touchpoints, value);
}
}
}Cross-Channel Reporting
code
-- Unified attribution report
WITH platform_spend AS (
SELECT platform, campaign_id, SUM(spend) as total_spend
FROM marketing_spend
WHERE date BETWEEN :start_date AND :end_date
GROUP BY platform, campaign_id
),
unified_attribution AS (
SELECT
ua.platform,
ua.campaign_id,
SUM(ua.credit) as attributed_conversions,
SUM(ua.value) as attributed_revenue
FROM unified_attribution_results ua
JOIN conversions c ON ua.conversion_id = c.id
WHERE c.timestamp BETWEEN :start_date AND :end_date
GROUP BY ua.platform, ua.campaign_id
)
SELECT
ps.platform,
ps.campaign_id,
ps.total_spend,
COALESCE(ua.attributed_conversions, 0) as conversions,
COALESCE(ua.attributed_revenue, 0) as revenue,
ROUND(COALESCE(ua.attributed_revenue, 0) / NULLIF(ps.total_spend, 0), 2) as roas,
ROUND(ps.total_spend / NULLIF(ua.attributed_conversions, 0), 2) as cpa
FROM platform_spend ps
LEFT JOIN unified_attribution ua USING (platform, campaign_id)
ORDER BY revenue DESC;Best Practices
code
## Implementation Checklist
□ Capture all platform click IDs (gclid, fbclid, etc.)
□ Implement first-party identity tracking
□ Set up server-side identity resolution
□ Connect platform APIs for spend data
□ Build unified touchpoint database
□ Implement deduplication logic
□ Configure attribution model
□ Create comparison dashboards
□ Validate with incrementality testsNext: See Attribution Implementation for step-by-step technical setup.