Pazarlama toplantılarında en sık duyduğum cümlelerden biri şudur: "Reklam paneli 400 lead diyor, CRM 310 diyor, satış ekibi ise kampanyanın hiç satış getirmediğini söylüyor." Bu üç rakam genellikle aynı gerçeği farklı kayıplarla anlatır. Kayıpların büyük kısmı da reklamdaki tıklama ile CRM'deki fırsat kartı arasındaki o kısa yolda, yani UTM parametrelerinin taşınmasında yaşanır. Ben Koray Yalçın; lead yönetimi, CRM mimarisi, dijital pazarlama ve yapay zekâ destekli otomasyon üzerine, çoğunlukla gayrimenkul ve PropTech projelerinde çalışıyorum. Konut projelerinde kampanya başına bütçeler yüksek, satış döngüleri uzun olduğu için "bu satış hangi reklamdan geldi" sorusunun cevabı doğrudan bütçe kararına dönüşüyor.
UTM mapping, en basit tanımıyla, reklam ve kampanya bağlantılarındaki UTM parametrelerinin CRM'deki lead ve fırsat kayıtlarına otomatik ve tutarlı biçimde yazılmasıdır. Bu yazıda konuyu pazarlama tarafından değil, geliştirici tarafından ele alıyorum: parametreleri sayfaya ilk girişte nasıl yakalayacağınız, form ve oturum boyunca nasıl koruyacağınız, ham değeri ve normalize değeri neden ayrı tutmanız gerektiği, kanal eşlemesini neden kod yerine veri olarak saklamanız gerektiği ve eşleme kuralları değiştiğinde geçmişi nasıl yeniden hesaplayacağınız. Örnekler JavaScript ve PostgreSQL üzerinden.
Sorun nerede başlıyor: UTM bir kez görünür, sonra kaybolur
UTM parametreleri (utm_source, utm_medium, utm_campaign, utm_content, utm_term) yalnızca kullanıcının geldiği ilk URL'de bulunur. Kullanıcı sitede ikinci sayfaya geçtiğinde, galeriye baktığında ya da form sayfasına gittiğinde adres çubuğunda artık yoktur. Formu gönderen sayfa çoğu zaman UTM'yi hiç görmemiştir. Bu yüzden "form gönderilirken URL'deki parametreleri okuyalım" yaklaşımı, tek sayfalık kampanya açılış sayfaları dışında neredeyse her zaman veri kaybettirir.
İkinci sorun, aynı ziyaretçinin birden fazla kez gelmesidir. Konut alıcısı bir hafta önce Instagram reklamından gelip bakar, üç gün sonra Google'da proje adını arayıp tekrar gelir, sonunda bir e-posta bülteninden gelip formu doldurur. Hangi kaynağı yazacağınız bir tercih meselesidir ve bu tercihi kodun içine gömülü bir varsayım olarak değil, açık bir veri modeli olarak yapmanız gerekir.
Üçüncü sorun ise dağınıklıktır. Aynı kaynak için facebook, Facebook, fb, meta, ig ve instagram değerlerini aynı hafta içinde görmek olağandır. Ajans değiştiğinde ya da bir ekip arkadaşı kampanya bağlantısını elle yazdığında yeni varyantlar eklenir. Normalize edilmemiş UTM verisiyle kurulan rapor, birkaç ay içinde güvenilmez hale gelir.
Yakalama katmanı: İlk temas ve son temas ayrı saklanır
Benim önerdiğim temel kural şudur: tarayıcı tarafında hem ilk temas hem son temas bilgisi tutulur ve form gönderiminde ikisi birden sunucuya iletilir. İlk temas, ziyaretçinin bu cihazda UTM ile geldiği ilk ziyarettir ve bir kez yazıldıktan sonra değişmez. Son temas, UTM taşıyan en son ziyarettir ve her yeni kampanyalı girişte güncellenir. UTM'siz bir dönüş (doğrudan giriş ya da organik arama) son temas bilgisini silmez; aksi halde kullanıcı yer imine kaydettiği sayfadan döndüğü anda tüm kampanya bilgisi kaybolur.
const KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"]; const CLICK_IDS = ["gclid", "fbclid", "wbraid", "gbraid", "ttclid"]; const STORE = "kt_attr_v1"; const MAX_AGE_DAYS = 90; function readTouch() { const p = new URLSearchParams(window.location.search); const touch = {}; for (const k of [...KEYS, ...CLICK_IDS]) { const v = p.get(k); if (v) touch[k] = v.slice(0, 200); } if (Object.keys(touch).length === 0) return null; touch.landing_path = window.location.pathname; touch.referrer_host = document.referrer ? new URL(document.referrer).hostname : null; touch.ts = new Date().toISOString(); return touch; } function captureAttribution() { const now = Date.now(); let state = {}; try { state = JSON.parse(localStorage.getItem(STORE)) || {}; } catch (e) { state = {}; } if (state.first && now - Date.parse(state.first.ts) > MAX_AGE_DAYS * 864e5) state = {}; const touch = readTouch(); if (touch) { if (!state.first) state.first = touch; state.last = touch; state.touch_count = (state.touch_count || 0) + 1; } localStorage.setItem(STORE, JSON.stringify(state)); return state; } Burada birkaç tasarım kararı var. Değerleri 200 karakterle kısaltıyorum, çünkü bazen reklam platformları ya da hatalı şablonlar çok uzun değerler üretir ve bunlar hem depolamayı hem raporları bozar. Tıklama kimliklerini (gclid, fbclid gibi) UTM ile aynı yerde ama ayrı alanlarda tutuyorum; çünkü daha sonra kapanan satışı reklam platformuna geri bildirmek istediğinizde UTM değil tıklama kimliği işe yarar. Süre sınırı koyuyorum, çünkü bir yıl önceki bir reklam tıklamasını bugünkü forma bağlamak çoğu işletme için anlamlı değildir. Doksan gün bir örnek değerdir; kendi satış döngünüze göre seçmelisiniz.
Çerez onayı konusunu da baştan çözmek gerekir. Bu bilgileri yerel depolamaya yazmak, kullandığınız onay yönetimi yapısına ve hukuki değerlendirmenize göre onay gerektirebilir. Pratikte iki yol görüyorum: onay verilene kadar parametreleri yalnızca oturum boyunca bellekte tutup form gönderiminde iletmek ya da onay sonrasında kalıcı depolamaya yazmak. Hangisini seçerseniz seçin, CRM'e gelen kayıtta "atıf verisi hangi onay durumunda toplandı" bilgisini de saklamanızı öneririm.
Form ve sunucu tarafı: Tarayıcıya güvenmeyin, ama ondan gelen veriyi kaybetmeyin
Form gönderiminde atıf durumu gizli alanlar ya da JSON gövdesinin bir parçası olarak sunucuya gider. Sunucu bu veriyi doğrulamadan CRM'e yazmamalıdır; çünkü sonuçta kullanıcının değiştirebileceği bir veridir. Doğrulamanın amacı sahteciliği tamamen engellemek değil, bozuk ve tehlikeli değerleri ayıklamaktır: izin verilen anahtarlar dışındakileri atmak, uzunluğu sınırlamak, kontrol karakterlerini temizlemek ve zaman damgasının makul bir aralıkta olduğunu kontrol etmek.
Sunucu tarafında ikinci bir güvence katmanı da kurulabilir. Açılış sayfası isteği sunucunuza geldiğinde, URL'deki UTM'leri birinci taraf bir çerezle eşleşen bir ziyaret kaydına yazarsanız, tarayıcı tarafındaki kod bir reklam engelleyiciye takıldığında bile atıf verisini kurtarabilirsiniz. Form gönderiminde tarayıcıdan gelen değer ile sunucudaki ziyaret kaydı karşılaştırılır; ikisi çelişirse sunucu kaydı tercih edilir ve çelişki not edilir.
Veri modeli: Ham değer, normalize değer ve temas tablosu
En sık gördüğüm hata, UTM değerlerini doğrudan lead kartındaki birkaç metin alanına yazmaktır. Bu, hem tek temas varsayımını kalıcı hale getirir hem de normalizasyon kuralları değiştiğinde geçmişi düzeltmeyi imkânsızlaştırır. Benim tercih ettiğim yapı, her teması ayrı bir satır olarak saklamak ve normalize sonucu ayrı alanlarda tutmaktır.
CREATE TABLE attribution_touch ( id bigserial PRIMARY KEY, lead_id bigint NOT NULL REFERENCES lead(id), touch_role text NOT NULL CHECK (touch_role IN ('first', 'last', 'conversion')), occurred_at timestamptz NOT NULL, -- ham değerler: asla değiştirilmez raw_source text, raw_medium text, raw_campaign text, raw_content text, raw_term text, click_id_type text, click_id text, landing_path text, referrer_host text, consent_state text NOT NULL DEFAULT 'unknown', -- normalize sonuç: yeniden hesaplanabilir source_norm text, medium_norm text, channel text, campaign_key text, mapping_version int, UNIQUE (lead_id, touch_role) ); Ham alanlar bir kez yazılır ve hiç güncellenmez. Normalize alanlar ise hangi eşleme sürümüyle üretildiklerini mapping_version üzerinden belli eder. Bu ayrım, yazının ilerleyen bölümündeki geriye dönük yeniden hesaplamayı mümkün kılan şeydir. conversion rolü, formun gönderildiği oturumu temsil eder; ilk ve son temas ile aynı olabilir ama ayrı tutmak, "form gönderen ziyaret organikti ama kullanıcıyı ilk getiren reklamdı" gibi durumları görünür kılar.
Normalize sözlük ve kanal eşleme tablosu: Kural veri olarak
Normalizasyonu iki aşamada yapıyorum. Birinci aşama mekanik temizliktir: küçük harfe çevirme, baştaki ve sondaki boşlukları silme, boşluk ve tireleri alt çizgiye dönüştürme. İkinci aşama anlamsal eşlemedir: fb, facebook, meta ve ig değerlerinin hangi kanonik kaynağa karşılık geldiğine karar vermek. Bu ikinci aşamayı kodun içinde if zinciriyle yapmak, pazarlama ekibinin her yeni varyant için geliştiriciye iş açmasına yol açar. Bu yüzden eşlemeyi bir tabloya koyuyorum.
CREATE TABLE utm_alias ( field text NOT NULL CHECK (field IN ('source', 'medium')), raw_clean text NOT NULL, canonical text NOT NULL, PRIMARY KEY (field, raw_clean) ); CREATE TABLE channel_rule ( id serial PRIMARY KEY, priority int NOT NULL, source_norm text, -- NULL: herhangi biri medium_norm text, -- NULL: herhangi biri has_click_id text, -- 'gclid', 'fbclid' ya da NULL channel text NOT NULL, -- 'paid_social', 'paid_search', 'email', 'referral' ... version int NOT NULL ); CREATE INDEX ON channel_rule (version, priority); Kanal kuralları öncelik sırasına göre değerlendirilir ve ilk eşleşen kural kazanır. Tıklama kimliğini bir koşul olarak eklemek, UTM'si eksik ama gclid taşıyan ziyaretleri ücretli arama olarak sınıflandırmanızı sağlar; bu durum Google Ads'in otomatik etiketlemesi açıkken ama manuel UTM eklenmemişken sık görülür.
import re def clean(value): if not value: return None v = value.strip().lower() v = re.sub(r"[\s\-]+", "_", v) v = re.sub(r"[^a-z0-9_\.]", "", v) return v or None def normalize_touch(touch, aliases, rules): src = clean(touch.get("raw_source")) med = clean(touch.get("raw_medium")) src = aliases.get(("source", src), src) med = aliases.get(("medium", med), med) channel = "unmapped" for r in rules: # priority sıralı gelir if r.source_norm and r.source_norm != src: continue if r.medium_norm and r.medium_norm != med: continue if r.has_click_id and r.has_click_id != touch.get("click_id_type"): continue channel = r.channel break if channel == "unmapped" and not src and not med: channel = "referral" if touch.get("referrer_host") else "direct" return {"source_norm": src, "medium_norm": med, "channel": channel} unmapped değerini bilerek ayrı tutuyorum. Eşleşmeyen bir değeri "diğer" kategorisine gömmek, sorunu gizler. unmapped oranı ise pazarlama ekibi için çok değerli bir sinyaldir: yeni bir ajans ya da yeni bir kampanya şablonu devreye girdiğinde bu oran yükselir ve sözlüğe eklenmesi gereken değerler hemen görünür.
Kampanya anahtarı: İsimlendirme kuralı olmadan eşleme yarım kalır
Kaynak ve ortam normalize edilse bile utm_campaign alanı genellikle en dağınık olanıdır. Konut projelerinde benim önerdiğim yaklaşım, kampanya adına sabit bir yapı vermektir: proje kodu, hedef kitle ya da pazar, teklif türü ve dönem. Örneğin prj12_uk_lansman_2026q4 gibi bir değer, alt çizgiyle ayrılmış dört parçaya bölünebilir. Bu yapıya uyan değerlerden bir campaign_key türetilir ve fırsat kartında projeyle ilişkilendirilir. Uymayan değerler yine ham haliyle saklanır, ama raporda "isimlendirme dışı" olarak işaretlenir.
Bu kuralı bağlantı üretim aşamasında uygulamak, sonradan temizlemekten çok daha ucuzdur. Pazarlama ekibine serbest metin alanı yerine, açılır listelerden kampanya bağlantısı üreten küçük bir iç araç vermek, birkaç hafta içinde veri kalitesini belirgin biçimde değiştirir. Bu aracın ürettiği değerler doğrudan utm_alias ve proje tablosu ile uyumlu olur.
Lead'den fırsata: Atıf kopyalanmaz, bağlanır
Lead bir fırsata dönüştüğünde UTM alanlarının fırsat kartına kopyalanması yaygın bir uygulamadır. Kopyalama, iki kayıt arasında zamanla ayrışan iki gerçek yaratır. Ben fırsat kaydında atıf verisini kopyalamak yerine, kaynak lead'in temas satırlarına bağlanmayı tercih ediyorum. Raporlama tarafında bir görünüm, fırsatı ilk ve son temasla birleştirir.
CREATE VIEW deal_attribution AS SELECT d.id AS deal_id, d.project_id, d.stage, d.amount_gbp, f.channel AS first_channel, f.campaign_key AS first_campaign, l.channel AS last_channel, l.campaign_key AS last_campaign FROM deal d JOIN lead ld ON ld.id = d.origin_lead_id LEFT JOIN attribution_touch f ON f.lead_id = ld.id AND f.touch_role = 'first' LEFT JOIN attribution_touch l ON l.lead_id = ld.id AND l.touch_role = 'last'; Bir kişinin birden fazla lead kaydı olduğunda (farklı formlar, farklı projeler) hangi lead'in fırsatın kaynağı olduğu, tekilleştirme ve birleştirme kurallarıyla belirlenir. Birleştirme sırasında temas satırları silinmemeli, birleşen kayda taşınmalıdır; böylece kişinin gerçek ilk teması korunur.
UTM'siz kanallar: Telefon, WhatsApp ve satış ofisi
Gayrimenkulde lead'lerin önemli bir kısmı forma hiç uğramaz. Kullanıcı reklamı görür, numarayı arar ya da WhatsApp'tan yazar, bazen de doğrudan satış ofisine gelir. Bu kanallarda UTM yoktur, ama atıf ihtiyacı devam eder. Uyguladığım üç yöntem şunlardır. Birincisi, kampanya bazlı takip numaraları kullanmak ve gelen aramanın numarasını bir kampanya anahtarına eşlemektir. İkincisi, WhatsApp'a yönlendiren bağlantılara önceden doldurulmuş bir mesaj içinde kısa bir referans kodu eklemek ve gelen ilk mesajda bu kodu ayrıştırmaktır. Üçüncüsü, satış ofisine gelen ziyaretçiye danışmanın "bizi nereden duydunuz" sorusunu sabit seçeneklerle sormasıdır. Bu üçünün sonucu da attribution_touch tablosuna yazılır; ham değer olarak takip numarası, referans kodu ya da beyan edilen seçenek saklanır ve aynı normalizasyon hattından geçer.
Beyan edilen kaynağın dijital atıfla çeliştiği durumlar sık görülür. Bunları birbirini ezmesine izin vermeden iki ayrı temas olarak saklamak, hem pazarlama hem satış ekibine daha dürüst bir tablo sunar.
Geriye dönük yeniden hesaplama: Eşleme değiştiğinde geçmiş de değişir
Sözlüğe yeni bir takma ad eklendiğinde ya da bir kanal kuralı değiştiğinde, geçmiş kayıtların normalize alanları eski kurallarla kalır. Ham değerleri sakladığınız için bu sorun çözülebilir: yeni kural sürümüyle tüm temasları yeniden hesaplarsınız. Bunu büyük tablolarda tek bir dev güncellemeyle değil, parçalar halinde yapmak gerekir.
def reproject(conn, new_version, batch=5000): aliases = load_aliases(conn) rules = load_rules(conn, new_version) last_id = 0 while True: rows = conn.execute( """SELECT id, raw_source, raw_medium, click_id_type, referrer_host FROM attribution_touch WHERE id > %s AND (mapping_version IS NULL OR mapping_version < %s) ORDER BY id LIMIT %s""", (last_id, new_version, batch), ).fetchall() if not rows: break for r in rows: n = normalize_touch(r._asdict(), aliases, rules) conn.execute( """UPDATE attribution_touch SET source_norm=%s, medium_norm=%s, channel=%s, mapping_version=%s WHERE id=%s""", (n["source_norm"], n["medium_norm"], n["channel"], new_version, r.id), ) conn.commit() last_id = rows[-1].id Yeniden hesaplama sonrası en önemli adım, değişen kayıtların özetini çıkarmaktır: kaç temasın kanalı değişti, hangi kanaldan hangisine geçti. Bu özet, "geçen ayın raporu neden değişti" sorusunun cevabıdır ve pazarlama ekibiyle güveni koruyan şey budur. Kapanmış dönem raporlarını dondurmak isteyen ekipler için rapor anlık görüntüsünü ayrıca saklamak da iyi bir pratiktir.
İzleme: Eşlemenin sağlığını haftalık ölçün
Kurulumdan sonra izlenmesi gereken birkaç basit gösterge var. Atıf verisi hiç olmayan dijital lead oranı, yakalama katmanında bir kırılma olduğunu gösterir; örneğin bir site güncellemesi script'i devre dışı bırakmış olabilir. unmapped kanal oranı sözlüğün güncel olup olmadığını gösterir. İsimlendirme dışı kampanya oranı, bağlantı üretim aracının kullanılıp kullanılmadığını gösterir. Reklam panelindeki dönüşüm sayısı ile CRM'deki aynı kampanyaya ait lead sayısı arasındaki fark ise tek başına kesin bir sonuç vermez ama ani sıçramalar çoğu zaman bir entegrasyon sorununa işaret eder.
Bu göstergeleri bir panoya koymak yeterli değildir; bir eşik belirleyip aşıldığında ilgili kişiye bildirim gitmesini sağlamak gerekir. Atıf hatları sessizce bozulur ve fark edildiklerinde genellikle birkaç haftalık veri çoktan kaybolmuştur.
Sık sorulan sorular
İlk temas mı son temas mı kullanmalıyım?
İkisini de saklayın, raporlama sırasında seçin. Lansman dönemindeki bilinirlik kampanyalarının etkisini görmek için ilk temas, kısa vadeli performans kampanyalarını optimize etmek için son temas daha anlamlıdır. Tek bir alan seçip diğerini atmak, sonradan geri dönülemeyen bir karardır.
UTM verisini sadece reklam platformunun panelinden takip etsem yetmez mi?
Panel, tıklama ve form doldurma düzeyinde bilgi verir. Hangi lead'in randevuya geldiği, hangisinin satışa dönüştüğü ve satış tutarı ise CRM'dedir. Bütçe kararını satış verisine göre vermek istiyorsanız atıfın CRM'de doğru yaşaması gerekir.
Ham değerleri saklamak veri tabanını şişirmez mi?
Temas başına birkaç kısa metin alanı, lead kaydının geri kalanıyla karşılaştırıldığında küçük bir yüktür. Karşılığında kural değiştiğinde geçmişi düzeltme imkânı kazanırsınız. Ham değer olmadan bu düzeltme mümkün değildir.
Reklam engelleyiciler UTM yakalamayı bozar mı?
Kendi alan adınızdan sunulan birinci taraf script'ler genellikle daha az etkilenir, ama risk tamamen ortadan kalkmaz. Açılış sayfası isteğini sunucu tarafında kaydetmek, tarayıcı tarafı başarısız olduğunda bir yedek sağlar.
Tıklama kimliklerini (gclid, fbclid) neden ayrıca saklamalıyım?
UTM size kampanyayı söyler, tıklama kimliği ise reklam platformunun kendi kaydına bağlanmanızı sağlar. Kapanan satışları reklam platformuna offline dönüşüm olarak geri bildirmek istediğinizde ihtiyaç duyacağınız bilgi budur.
Kanal eşleme kurallarını kim yönetmeli?
Kuralların kendisi pazarlama operasyonunun sorumluluğunda olmalı, ama değişiklikler sürümlü ve kayıtlı yapılmalı. Geliştirici, sürüm geçişinde yeniden hesaplamanın çalışmasını ve değişiklik özetinin üretilmesini garanti eder.
Kapanış
UTM mapping, ilk bakışta birkaç gizli form alanından ibaret görünür. Gerçekte ise yakalama, saklama, normalizasyon, eşleme ve yeniden hesaplamadan oluşan küçük bir veri hattıdır. İlk ve son teması ayrı tutmak, ham değeri hiç değiştirmemek, eşlemeyi kod yerine sürümlü veri olarak saklamak, UTM'siz kanalları aynı hatta dahil etmek ve hattın sağlığını düzenli ölçmek, "reklam ne kadar satış getirdi" sorusuna savunulabilir bir cevap vermenin temelidir. Benim deneyimimde bütçe toplantılarındaki tartışmaların önemli bir kısmı, rakamların kendisinden değil, bu hattın hiç kurulmamış olmasından kaynaklanır.
Koray Yalçın — lead yönetimi, CRM mimarisi ve PropTech sistemleri
