Reklam tarafında "Custom Audience" ya da Google'daki karşılığıyla "Customer Match" denince çoğu ekibin aklına tek bir hareket gelir: CRM'den bir CSV dışa aktarmak, reklam paneline yüklemek ve kampanyayı başlatmak. İlk hafta işe yarar. Üçüncü ayda ise liste eskimiştir; satın almış müşteriler hâlâ "ilk konutunuzu bulun" reklamı görüyordur, iletişim iznini geri çeken bir kişi hâlâ listededir ve kimse listenin hangi tarihteki CRM durumunu yansıttığını bilmiyordur.

Ben Koray Yalçın; lead yönetimi, CRM ve dijital pazarlama altyapıları üzerinde, özellikle gayrimenkul ve PropTech projelerinde çalışıyorum. Bu yazıda Custom Audience'ı bir pazarlama paneli özelliği olarak değil, CRM'den reklam platformuna akan bir veri ürünü olarak ele alacağım: segment nasıl tanımlanır, kimlik bilgileri nasıl normalize edilip hash'lenir, liste her seferinde baştan yüklenmeden nasıl fark (diff) ile güncellenir, rızasını geri çeken kişi nasıl çıkarılır ve bütün bunlar nasıl izlenir. Örnekler TypeScript ve PostgreSQL ile, ama yaklaşım dil bağımsız.

Neden elle CSV yüklemek yetmez

Elle yüklemenin üç temel sorunu var. Birincisi tazelik: CRM her gün değişir, liste değişmez. İkincisi çıkarma: yeni kişileri eklemeyi herkes hatırlar, ama artık listede olmaması gereken kişileri çıkarmayı neredeyse kimse yapmaz. Üçüncüsü izlenebilirlik: "Bu kişi neden bu kitlede?" sorusuna cevap verecek bir kayıt yoktur.

Gayrimenkul özelinde çıkarma sorunu özellikle can sıkıcıdır. Sözleşme imzalamış bir alıcıya aynı projenin lansman reklamını göstermek bütçe israfıdır; ama daha önemlisi, müşteri deneyimi açısından "beni tanımıyorlar" hissi yaratır. Satış kapandığı anda kişinin "sıcak potansiyel" kitlesinden çıkıp, varsa "mevcut müşteri – yönlendirme" kitlesine geçmesi gerekir. Bunu elle yapan bir ekip, en iyi ihtimalle haftada bir yapar.

Segmenti kodda değil, veride tanımlayın

İlk tasarım kararı şu: kitle tanımları kodun içine gömülü sorgular olmamalı, sürümlenen bir tabloda yaşamalı. Böylece pazarlama ekibi "son 90 günde 2+1 talebi olan ama satın almamış kişiler" gibi bir tanımı değiştirdiğinde, değişikliğin ne zaman ve kim tarafından yapıldığı bellidir.

CREATE TABLE audience_definition ( id bigserial PRIMARY KEY, key text NOT NULL, -- 'warm_leads_project_a' version int NOT NULL, platform text NOT NULL, -- 'meta' | 'google' external_id text, -- platformdaki kitle kimliği purpose text NOT NULL, -- rıza amacı: 'ad_targeting' segment_sql text NOT NULL, -- yalnızca person_id döndüren, gözden geçirilmiş sorgu created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), active boolean NOT NULL DEFAULT true, UNIQUE (key, version) ); 

segment_sql alanında serbest SQL tutmak riskli görünebilir; pratikte bunu ya yalnızca okuma yetkisi olan bir rolle çalıştırıyorum ya da sorgu yerine önceden tanımlanmış filtre parçalarından oluşan bir JSON kuralı kullanıyorum. Önemli olan, tanımın tek bir yerde ve sürümlü olması.

purpose alanı ise küçük ama kritik bir ayrıntıdır: her kitle, hangi rıza amacına dayandığını açıkça söyler. Bunu birazdan senkron sırasında filtre olarak kullanacağız.

Normalizasyon: Eşleşme oranını belirleyen sessiz adım

Reklam platformları ham e-posta ya da telefon kabul etmez; değerlerin belirli kurallarla normalize edilip SHA-256 ile hash'lenmesini bekler. Hash'lemeden önceki normalizasyon yanlışsa, aynı kişi platform tarafında hiç eşleşmez ve bunu size kimse söylemez. Eşleşme oranınız düşük kalır, siz de "kitle küçük çıktı" diye düşünürsünüz.

Temel kurallar platform dokümantasyonunda yazar ve zaman zaman güncellenir, bu yüzden kendi kodunuzu bir kez yazıp unutmak yerine dokümantasyonla birlikte gözden geçirmenizi öneririm. Genel çerçeve şöyle:

  • E-posta: baştaki ve sondaki boşlukları kırp, küçük harfe çevir.
  • Telefon: yalnızca rakamları bırak, ülke koduyla birlikte uluslararası biçime getir. Türkiye'de 0532... ile kaydedilmiş bir numara 90532... olmalı; KKTC numaraları için ülke kodu farklıdır ve bunu ayrı ele almak gerekir.
  • Ad, soyad, şehir gibi alanlar: platformun kuralına göre küçük harf ve noktalama temizliği; Türkçe karakterlerde platformun beklediği biçimi dokümantasyondan doğrulayın.
import { createHash } from "node:crypto"; const sha256 = (v: string) => createHash("sha256").update(v, "utf8").digest("hex"); export function normalizeEmail(raw?: string | null): string | null { if (!raw) return null; const v = raw.trim().toLowerCase(); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? v : null; } // Basitleştirilmiş: gerçek projede libphonenumber gibi bir kütüphane kullanın. export function normalizePhoneTR(raw?: string | null): string | null { if (!raw) return null; let d = raw.replace(/\D/g, ""); if (d.startsWith("00")) d = d.slice(2); if (d.length === 11 && d.startsWith("0")) d = "90" + d.slice(1); if (d.length === 10 && d.startsWith("5")) d = "90" + d; return d.length >= 11 ? d : null; } export function identifiers(p: { email?: string; phone?: string }) { const e = normalizeEmail(p.email); const t = normalizePhoneTR(p.phone); return { emailHash: e ? sha256(e) : null, phoneHash: t ? sha256(t) : null, }; } 

Burada önemli bir nokta: normalize edilemeyen değeri "olduğu gibi" hash'lemeyin. Bozuk bir telefonu hash'leyip göndermek, eşleşmeyecek bir satır göndermekten başka bir işe yaramaz ve hata ayıklamayı zorlaştırır. null döndürün ve bunu sayın; normalize edilemeyen kayıt oranı, CRM veri kalitesinin iyi bir göstergesidir.

Üyelik tablosu: "Şu an kimler listede?" sorusunun cevabı

Fark senkronu yapabilmek için platforma en son neyi gönderdiğinizi bilmeniz gerekir. Platformlar listenin içeriğini size geri vermez (gizlilik gereği yalnızca yaklaşık boyut gösterirler), bu yüzden bu bilgiyi kendi tarafınızda tutmak zorundasınız.

CREATE TABLE audience_member ( audience_key text NOT NULL, person_id bigint NOT NULL, email_hash text, phone_hash text, state text NOT NULL, -- 'pending_add' | 'synced' | 'pending_remove' | 'removed' reason text, -- 'segment_match' | 'consent_withdrawn' | 'segment_exit' def_version int NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), synced_at timestamptz, PRIMARY KEY (audience_key, person_id) ); 

Hash'leri burada saklamamın sebebi şu: çıkarma işlemi için platforma, eklerken gönderdiğiniz hash'in aynısını göndermeniz gerekir. Kişi bu arada CRM'de e-postasını değiştirdiyse, yeni e-postanın hash'ini göndermek eski kaydı çıkarmaz. Eski hash'i tutmak, "gönderdiğimi geri alabilirim" garantisidir.

Fark senkronu: Her seferinde baştan yüklemeyin

Senkron işi üç adımdan oluşur: segmenti hesapla, mevcut üyelikle karşılaştır, farkı gönder.

-- 1) Güncel segment (rıza filtresiyle birlikte) WITH target AS ( SELECT s.person_id FROM run_segment(:audience_key) s JOIN consent_state c ON c.person_id = s.person_id AND c.purpose = :purpose AND c.status = 'granted' ), -- 2) Eklenecekler: hedefte olup listede olmayanlar to_add AS ( SELECT t.person_id FROM target t LEFT JOIN audience_member m ON m.audience_key = :audience_key AND m.person_id = t.person_id AND m.state IN ('synced','pending_add') WHERE m.person_id IS NULL ), -- 3) Çıkarılacaklar: listede olup artık hedefte olmayanlar to_remove AS ( SELECT m.person_id FROM audience_member m WHERE m.audience_key = :audience_key AND m.state = 'synced' AND NOT EXISTS (SELECT 1 FROM target t WHERE t.person_id = m.person_id) ) SELECT 'add' AS op, person_id FROM to_add UNION ALL SELECT 'remove', person_id FROM to_remove; 

Rıza kontrolünü segment sorgusunun içine değil, onun dışına, senkron katmanına koyuyorum. Sebebi basit: pazarlama ekibi segment tanımını değiştirirken rıza koşulunu unutabilir. Rıza filtresi senkron işinin sabit bir parçası olduğunda, hiçbir segment tanımı onu atlayamaz.

Bu sorgunun sonucu doğrudan API'ye gitmez; önce audience_member tablosunda pending_add ve pending_remove durumlarına yazılır. Gönderim ayrı bir işçi tarafından yapılır. Bu, daha önce lead entegrasyonlarında kullandığım outbox desenine benzer: önce niyetinizi kalıcı yazarsınız, sonra gönderirsiniz. API çağrısı yarıda kesilirse, hangi satırların gönderilmediği tabloda bellidir.

Gönderim işçisi: Parçalı gönderim, idempotency ve hız sınırı

Platform API'leri tek istekte gönderebileceğiniz satır sayısını sınırlar ve hız sınırı uygular. Kesin sayılar platforma ve zamana göre değiştiği için kodda sabit olarak değil, yapılandırmada tutun.

type Op = { personId: number; emailHash: string | null; phoneHash: string | null }; async function flush(audienceKey: string, op: "add" | "remove", batchSize: number) { const rows: Op[] = await db.claimPending(audienceKey, op, batchSize); // FOR UPDATE SKIP LOCKED if (rows.length === 0) return 0; const payload = rows .filter(r => r.emailHash || r.phoneHash) .map(r => [r.emailHash ?? "", r.phoneHash ?? ""]); try { const res = await audienceClient[op](audienceKey, { schema: ["EMAIL_SHA256", "PHONE_SHA256"], data: payload, }); await db.markSynced(audienceKey, rows.map(r => r.personId), op, res.requestId); } catch (err) { if (isRetryable(err)) { await db.releaseForRetry(audienceKey, rows.map(r => r.personId), backoff(err)); } else { await db.moveToDeadLetter(audienceKey, rows, err); } } return rows.length; } 

Üyelik ekleme ve çıkarma işlemleri doğası gereği büyük ölçüde idempotenttir: aynı hash'i iki kez eklemek kitlede iki kişi oluşturmaz. Bu, yeniden denemeyi rahatlatır. Ama yine de her gönderimin bir istek kimliğiyle kaydını tutun; "bu kişi ne zaman, hangi tanım sürümüyle eklendi" sorusunun cevabı bu kayıttır.

Bir önemli uyarı: ekleme ve çıkarma sırasını karıştırmayın. Aynı kişi için önce bir remove, ardından bir add kuyrukta bekliyorsa ve işçiler paralel çalışıyorsa, sıra tersine dönebilir. Ben bunu kişi başına tek bir durum satırı tutarak çözüyorum: tabloda bir kişinin aynı anda yalnızca bir bekleyen işlemi olabilir ve yeni bir hesaplama eski bekleyen işlemi ezer.

Rıza geri çekme: Bir sonraki gece işini beklemeyin

Fark senkronu genelde zamanlanmış çalışır, örneğin saatte bir ya da gecede bir. Ama bir kişinin reklam amaçlı veri kullanımına itirazı ya da iznini geri çekmesi, bir sonraki toplu işi beklememelidir. Bunu olay güdümlü bir kısa yolla çözüyorum:

// İzin defterine "withdrawn" olayı yazıldığında tetiklenir async function onConsentWithdrawn(personId: number, purpose: string) { const audiences = await db.audiencesByPurpose(purpose); for (const a of audiences) { await db.query( `UPDATE audience_member SET state = 'pending_remove', reason = 'consent_withdrawn', updated_at = now() WHERE audience_key = $1 AND person_id = $2 AND state IN ('synced','pending_add')`, [a.key, personId] ); } await queue.wake("audience-flush"); // işçiyi hemen uyandır } 

pending_add durumundaki kaydı da pending_remove'a çevirmek önemli: kişi henüz gönderilmemişse bile, gönderilmeden önce iptal edilmiş olur. Bu ayrıntı, "izin geri çekildi ama beş dakika sonra kitleye eklendi" gibi utandırıcı bir yarış durumunu engeller.

Bir de şunu belirtmeliyim: bu yazı teknik bir tasarım notudur, hukuki görüş değildir. Hangi rıza amacının hangi kitle kullanımı için yeterli olduğu, KVKK ve GDPR gibi düzenlemeler açısından hukuk ekibinizle birlikte belirlenmelidir. Teknik tarafın görevi, verilen kararı tutarlı ve denetlenebilir biçimde uygulamaktır.

Gayrimenkul senaryosu: Aşamaya bağlı kitle geçişleri

Somut bir örnek üzerinden gideyim. Bir konut projesi için üç kitle düşünelim:

  1. Sıcak potansiyel: Son 60 gün içinde form doldurmuş, en az bir anlamlı temas kurulmuş, satış aşaması "teklif" öncesinde olan kişiler.
  2. Hariç tutma kitlesi: Sözleşme imzalamış alıcılar ve "ilgilenmiyor" olarak kapatılmış kayıtlar. Bu kitle reklam setlerinde hedefleme için değil, hariç tutma için kullanılır.
  3. Benzer kitle kaynağı: Satın alma ile sonuçlanmış kişiler; platformun benzer kitle (lookalike) özelliğine kaynak olarak verilir.

Bir kişinin CRM'deki aşaması "sözleşme"ye geçtiği anda, bir sonraki senkronda 1 numaralı kitleden segment_exit gerekçesiyle çıkar, 2 ve 3 numaralı kitlelere eklenir. Hiçbir pazarlama uzmanının elle bir şey yapmasına gerek kalmaz. Kampanya tarafında ise sıcak potansiyel reklam setleri, hariç tutma kitlesini otomatik olarak dışarıda bırakır.

Buradaki tasarım dersi şu: hariç tutma kitleleri, hedefleme kitleleri kadar önemlidir ve genellikle daha çok ihmal edilir. Senkron altyapısını kurarken ilk iki kitleden biri mutlaka hariç tutma kitlesi olmalı.

İzleme: Sessiz bozulmayı nasıl yakalarsınız

Custom Audience senkronunun bozulması nadiren hata mesajıyla kendini gösterir. Genelde şunlar olur: token süresi dolar ve iş sessizce başarısız olur, normalizasyon kodundaki bir değişiklik eşleşmeyi düşürür, ya da segment sorgusu bir şema değişikliği yüzünden boş döner ve senkron işi bunu "herkesi çıkar" olarak yorumlar.

Son durum en tehlikelisidir. Bu yüzden her senkron çalışmasında şu korumaları uyguluyorum:

  • Çıkarma eşiği: Tek bir çalışmada mevcut üyelerin belirli bir oranından fazlası çıkarılacaksa iş durur ve uyarı üretir. Gerçek bir segment değişikliği ise bir insan onaylar.
  • Boş segment koruması: Segment sorgusu sıfır satır döndürürse ve önceki çalışmada yüzlerce kişi varsa, bu bir veri hatası olarak kabul edilir.
  • Normalizasyon oranı: Her çalışmada "e-posta veya telefonu normalize edilemeyen kişi oranı" kaydedilir; ani bir artış, kaynak sistemdeki bir değişikliğin işaretidir.
  • Platform tarafı boyutu: Platformun gösterdiği yaklaşık kitle boyutunu periyodik olarak okuyup kendi synced sayınızla karşılaştırın. Birebir eşleşme beklemeyin (eşleşmeyen kişiler sayılmaz ve boyut yuvarlanır), ama oranın zaman içinde nasıl değiştiğini izleyin.
SELECT audience_key, count(*) FILTER (WHERE state = 'synced') AS synced, count(*) FILTER (WHERE state LIKE 'pending_%') AS pending, count(*) FILTER (WHERE email_hash IS NULL AND phone_hash IS NULL) AS no_identifier, max(synced_at) AS last_sync FROM audience_member GROUP BY audience_key; 

Bu sorguyu bir gösterge paneline koyup last_sync için bir eşik belirlemek, "liste en son ne zaman güncellendi?" sorusunu kalıcı olarak cevaplar.

Sık yapılan hatalar

Sahada en sık karşılaştığım hataları kısa bir liste olarak toplayayım:

  • Hash'lemeden önce normalize etmemek ya da e-postayı küçük harfe çevirmeyi unutmak.
  • Türkiye numaralarını başındaki 0 ile ya da ülke kodu olmadan göndermek.
  • Yalnızca ekleme yapıp çıkarmayı hiç uygulamamak.
  • Rıza kontrolünü her segment tanımına ayrı ayrı yazmaya çalışmak ve bir tanesinde unutmak.
  • Kişinin eski hash'ini saklamamak ve bu yüzden onu listeden çıkaramamak.
  • Boş dönen bir sorguyu "herkesi çıkar" diye yorumlayan, korumasız bir senkron işi.

SSS

Custom Audience için verileri hash'lemek kişisel veriyi anonim hale getirir mi?
Hayır. SHA-256 ile hash'lenmiş bir e-posta, aynı e-postayı bilen biri tarafından kolayca eşleştirilebilir; bu yüzden hash'lenmiş veri hâlâ kişisel veri olarak ele alınmalıdır. Hash'leme, aktarım sırasında ham değeri göstermemeye yarar, rıza ya da hukuki dayanak ihtiyacını ortadan kaldırmaz.

Listeyi her gece baştan yüklemek daha basit değil mi?
Daha basit, ama iki sorunu var: çıkarmayı garanti etmez (bazı platformlarda "değiştir" işlemi ayrı bir uç noktadır ve kendi kısıtları vardır) ve büyük listelerde her seferinde tüm veriyi göndermek gereksiz yük ve hata yüzeyi yaratır. Fark senkronu, ne değiştiğini de kayıt altına aldığı için denetlenebilirlik açısından da daha iyidir.

Eşleşme oranım düşük çıkıyor, ilk neye bakmalıyım?
Önce normalizasyona. Rastgele birkaç kaydın normalize edilmiş halini elle kontrol edin: e-postalar küçük harf mi, telefonlar ülke koduyla mı? Ardından, CRM'deki e-postaların reklam platformunda kullanılan hesaplarla aynı olup olmadığını düşünün; iş e-postası ile kişisel hesap farklıysa, telefonu ikinci bir tanımlayıcı olarak eklemek eşleşmeyi artırabilir.

Senkron sıklığı ne olmalı?
İş ihtiyacına bağlı. Benim varsayılanım, olağan ekleme ve çıkarmalar için saatlik ya da günlük bir fark senkronu, rıza geri çekme gibi olaylar için ise olay güdümlü anında çıkarmadır. Çok sık senkron, hız sınırlarına takılmaktan başka bir şey kazandırmaz; çünkü platform tarafında kitlenin güncellenmesi zaten bir süre alır.

Küçük bir ekip için bu kadar altyapı gerekli mi?
Hepsini ilk günden kurmak gerekmez. Öncelik sırası olarak şunu öneririm: önce doğru normalizasyon ve hash'leme, sonra gönderilen hash'leri saklayan üyelik tablosu, ardından rıza filtresi ve çıkarma, en son izleme ve korumalar. İlk üç adım bile elle CSV yüklemenin en büyük risklerini kapatır.

Kapanış

Custom Audience, reklam panelinde birkaç tıklamayla oluşturulan bir liste gibi görünse de aslında CRM'inizin reklam platformuna açılan bir penceresidir. O pencereden neyin geçtiğini, ne zaman geçtiğini ve neyin artık geçmemesi gerektiğini bilmek, hem bütçe verimliliği hem de müşteri güveni açısından belirleyicidir. Segmenti sürümlü bir tanım olarak tutmak, normalizasyonu titizlikle yapmak, gönderdiğiniz hash'leri saklamak, fark senkronu ile ekleyip çıkarmak ve rıza geri çekmeyi anında uygulamak; bu adımlar birlikte, tek seferlik bir CSV yüklemesini güvenilir bir veri akışına dönüştürür.

Lead yönetimi, CRM ve reklam entegrasyonları üzerine bu tür uygulama notlarını paylaşmaya devam edeceğim. Kendi kitle senkronunuzda en çok hangi adımda zorlandığınızı yorumlarda yazarsanız, sonraki yazılarda o konuyu daha ayrıntılı ele alabilirim.

— Koray Yalçın