Bir gayrimenkul projesinin lansman haftasında pazarlama ekibi yeni bir SMS kampanyası hazırlıyor. Liste CRM'den çekiliyor, filtre "izinli = evet" olarak ayarlanıyor ve gönderim başlıyor. İki gün sonra bir müşteri adayı şikâyet ediyor: üç hafta önce "artık mesaj istemiyorum" diye yanıt vermiş, çağrı merkezi temsilcisi de bunu nota yazmış. Ama "izinli" alanı hiç değişmemiş, çünkü o alanı yalnız web formu dolduruyordu. Ben Koray Yalçın olarak lead yönetimi ve CRM kurulumlarında bu tabloyu defalarca gördüm: izin, bir onay kutusunun sonucu olarak tek bir boolean alanda tutulur ve sistemin geri kalanı o alanın doğru olduğunu varsayar.
İzin yönetimi (consent management), bir kişinin hangi kanal üzerinden, hangi amaçla, ne zaman ve hangi metni görerek izin verdiğini ya da geri çektiğini kayıt altına alan ve bu kaydı her gönderimde uygulayan yapıdır. Bu yazıda kavramı bir geliştiricinin gözünden ele alıyorum: izni neden bir alan değil bir olay dizisi olarak tutmalıyız, kanal ve amaç ayrımını şemaya nasıl yansıtırız, kontrolü neden gönderimin son anında yaparız ve "beni listeden çıkarın" talebi tüm sistemlere nasıl yayılır.
Bir not: Bu yazı teknik bir tasarım yazısıdır, hukuki görüş değildir. KVKK, Elektronik Ticaret Kanunu ve İleti Yönetim Sistemi (İYS) yükümlülüklerinin sizin işinize nasıl uygulandığını bir hukukçuyla netleştirin; aşağıdaki yapı o kararları güvenle uygulayabileceğiniz bir altyapı sunar.
İzin neden bir boolean alan olamaz?
marketing_opt_in = true alanı üç soruyu cevapsız bırakır. Birincisi, hangi kanal için? E-posta bülteni almak isteyen biri telefonla aranmak istemeyebilir. İkincisi, hangi amaç için? Satın aldığı daireyle ilgili teslim bilgilendirmesi ile yeni bir projenin kampanyası aynı şey değildir. Üçüncüsü, nasıl kanıtlanır? Bir şikâyet geldiğinde "evet, izin vardı" demek yetmez; ne zaman, hangi formda, hangi metin gösterilerek alındığını gösterebilmeniz gerekir.
Bir de zaman boyutu var. İzin verilir, geri çekilir, yeniden verilir. Tek alanlı yapıda her değişiklik bir öncekini siler. Geçen ay bu kişiye gönderilen mesajın o tarihte izinli olup olmadığını sormak imkânsız hale gelir. Benim önerim şudur: izni bir olay defteri olarak tut, güncel durumu bu defterden türet.
Değişmez rıza defteri
Defterin her satırı tek bir olaydır: verildi, geri çekildi ya da doğrulandı. Satırlar güncellenmez, silinmez; yalnız eklenir.
CREATE TYPE consent_channel AS ENUM ('email', 'sms', 'call', 'whatsapp'); CREATE TYPE consent_action AS ENUM ('granted', 'withdrawn'); CREATE TABLE consent_event ( id bigserial PRIMARY KEY, contact_id bigint NOT NULL REFERENCES contact(id), channel consent_channel NOT NULL, purpose text NOT NULL, -- 'marketing', 'project_updates', 'surveys' action consent_action NOT NULL, occurred_at timestamptz NOT NULL, -- olayın gerçekte olduğu an recorded_at timestamptz NOT NULL DEFAULT now(), source text NOT NULL, -- 'web_form', 'call_center', 'sms_reply', 'iys_sync' notice_version text, -- gösterilen metnin sürüm anahtarı evidence jsonb NOT NULL DEFAULT '{}'::jsonb, actor_id bigint, -- kaydı giren kullanıcı, otomatikse NULL idempotency_key text UNIQUE ); CREATE INDEX consent_event_lookup ON consent_event (contact_id, channel, purpose, occurred_at DESC); REVOKE UPDATE, DELETE ON consent_event FROM app_role; Birkaç ayrıntı önemli. occurred_at ile recorded_at ayrıdır, çünkü çağrı merkezinden gelen bir geri çekme talebi sisteme birkaç saat sonra girilebilir; hangi olayın önce geçerli olduğuna gerçek zamanla karar verilir. idempotency_key aynı form gönderiminin ya da aynı İYS kaydının iki kez işlenmesini engeller. REVOKE satırı uygulama kullanıcısının bu tabloyu değiştirmesini veritabanı düzeyinde yasaklar; yanlışlıkla yazılmış bir UPDATE sorgusu sessizce geçmez, hata verir.
Kanıtı neyle saklarız?
evidence alanı, izin alınan anın fotoğrafıdır. Web formu için tipik içerik şöyledir:
{ "form_id": "proje-a-lansman-formu", "page_url": "https://ornek-proje.com/basvuru", "notice_version": "etk-2026-09", "notice_sha256": "9f2c...e1", "checkbox_label": "Kampanya ve duyurular için SMS almak istiyorum.", "prechecked": false, "ip": "203.0.113.24", "user_agent": "Mozilla/5.0 ..." } notice_version ve notice_sha256 metnin kendisini değil, metnin hangi sürümünün gösterildiğini kaydeder. Metinlerin kendisi ayrı bir consent_notice tablosunda sürümlü olarak durur. Böylece pazarlama ekibi onay kutusu yazısını değiştirdiğinde eski kayıtlar bozulmaz; her olay kendi metnine bağlı kalır. prechecked: false küçük görünür ama önemlidir: önceden işaretlenmiş kutuyla alınmış bir onayın geçerliliği tartışmalıdır ve bunu sonradan kanıtlamak için o anda kaydetmek gerekir.
IP ve tarayıcı bilgisi de kişisel veridir. Bunları saklama sürenizi veri saklama politikanızla uyumlu tutun ve bu alanları önceki yazımda anlattığım maskeleme kurallarına dahil edin; kanıt kaydı herkesin görebileceği bir ekran olmamalı.
Kanal ve amaç ayrımı
Kanal (e-posta, SMS, arama, WhatsApp) ile amaç (pazarlama, proje bilgilendirmesi, anket) iki ayrı eksendir ve izin bu ikisinin kesişiminde verilir. Gayrimenkul tarafında en sık karışan ayrım şudur: satın alma süreci devam eden bir müşteriye ödeme hatırlatması ya da teslim tarihi bilgisi göndermek ile ona yeni bir projenin kampanyasını göndermek farklı hukuki dayanaklara ve farklı izin ihtiyacına sahip olabilir. Bu ayrımı şemada açıkça tutmazsanız, "işlemsel" etiketiyle kampanya gönderilmesi kaçınılmaz olur.
Bunu kod düzeyinde zorlamanın yolu, her mesaj şablonunun bir amaca bağlı olmasıdır:
CREATE TABLE message_template ( id bigserial PRIMARY KEY, name text NOT NULL, channel consent_channel NOT NULL, purpose text NOT NULL REFERENCES consent_purpose(key), requires_consent boolean NOT NULL DEFAULT true ); requires_consent = false olan amaçların listesi kısa olmalı ve değiştirilmesi bir kod incelemesi ya da yönetici onayı gerektirmelidir. Kampanya aracında kullanıcıya serbest metin alanı verip amacı seçtirmemek, en sık gördüğüm açıktır.
Güncel durumu defterden türetmek
Her gönderimde tüm defteri taramak pahalıdır. Güncel durumu ayrı bir tabloda tutarız, ama bu tablo yalnız defterden beslenir ve istenirse baştan yeniden hesaplanabilir.
CREATE TABLE consent_state ( contact_id bigint NOT NULL, channel consent_channel NOT NULL, purpose text NOT NULL, status consent_action NOT NULL, effective_at timestamptz NOT NULL, last_event_id bigint NOT NULL, PRIMARY KEY (contact_id, channel, purpose) ); -- Olay eklendikten sonra aynı transaction içinde: INSERT INTO consent_state AS s (contact_id, channel, purpose, status, effective_at, last_event_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (contact_id, channel, purpose) DO UPDATE SET status = EXCLUDED.status, effective_at = EXCLUDED.effective_at, last_event_id = EXCLUDED.last_event_id WHERE s.effective_at <= EXCLUDED.effective_at; WHERE koşulu geç gelen eski bir olayın yeni durumu ezmesini önler. Saat dilimi ve sıra sorunlarında bir kural daha koyuyorum: aynı ana denk gelen bir "verildi" ile bir "geri çekildi" olayı varsa geri çekme kazanır. Belirsizlikte kısıtlayıcı taraf seçilir.
Haftalık bir mutabakat işi, consent_state tablosunu defterden sıfırdan hesaplayıp farkları raporlar. Fark çıkıyorsa bir yerde defteri atlayan bir yazma yolu vardır ve o yol bulunmalıdır.
Kontrol gönderimin son anında yapılır
Kampanya listesini oluştururken izin filtrelemek gereklidir ama yeterli değildir. Liste pazartesi hazırlanıp çarşamba gönderiliyorsa aradaki iki günde gelen geri çekmeler kaçar. Bu yüzden izin kontrolünü mesajı sağlayıcıya teslim etmeden hemen önce, tek bir fonksiyonda yaparım:
class ConsentDenied(Exception): pass def assert_can_send(db, contact_id: int, template) -> None: if not template.requires_consent: return row = db.fetchone( """ SELECT status FROM consent_state WHERE contact_id = %s AND channel = %s AND purpose = %s """, (contact_id, template.channel, template.purpose), ) if row is None or row["status"] != "granted": raise ConsentDenied(f"{contact_id}/{template.channel}/{template.purpose}") def deliver(db, outbox_item): template = load_template(db, outbox_item.template_id) try: assert_can_send(db, outbox_item.contact_id, template) except ConsentDenied: mark_skipped(db, outbox_item.id, reason="no_consent") return provider_send(outbox_item) Varsayılan "kayıt yoksa gönderme"dir. Kayıt yoksa izin de yoktur. Atlanan gönderimler sessizce kaybolmaz, no_consent nedeniyle işaretlenir; böylece kampanya raporunda "1.200 kişilik listenin 140'ı gönderim anında izin dışı kaldı" gibi bir satır görürsünüz (sayılar örnek olarak verilmiştir). Bu satır, izin süreçlerinin işlediğinin en iyi kanıtıdır.
Çağrı merkezi için de aynı fonksiyon kullanılır. Otomatik arayıcı (dialer) bir numarayı kuyruğa almadan önce call/marketing iznini kontrol eder; manuel aramada ise temsilcinin ekranında arama düğmesi izin yoksa pasif görünür ve neden pasif olduğu yazar.
Geri çekmenin her yere yayılması
Geri çekme talebi birçok yoldan gelir: e-postadaki abonelikten çık bağlantısı, SMS'e verilen "RET" yanıtı, WhatsApp'ta "mesaj istemiyorum" yazısı, çağrı merkezinde sözlü talep, İYS üzerinden yapılan ret. Hepsinin ortak noktası, sonunda aynı deftere bir withdrawn olayı olarak düşmesidir.
def record_withdrawal(db, contact_id, channel, purpose, source, occurred_at, evidence, actor_id=None, idem=None): with db.transaction(): event_id = insert_consent_event( db, contact_id, channel, purpose, "withdrawn", occurred_at, source, evidence, actor_id, idem, ) upsert_consent_state(db, contact_id, channel, purpose, "withdrawn", occurred_at, event_id) enqueue_outbox(db, "consent.withdrawn", { "contact_id": contact_id, "channel": channel, "purpose": purpose, "event_id": event_id, }) Outbox'a düşen olay, dış sistemlere yayılır: e-posta pazarlama aracındaki abonelik durumu, reklam platformlarına yüklenen özel kitleler, varsa İYS'ye bildirim ve iş ortaklarının listeleri. Reklam kitleleri en sık unutulan noktadır; izni geri çekilen bir kişinin telefon numarasının hash'li hali hâlâ bir yeniden hedefleme kitlesinde duruyor olabilir.
Çağrı merkezindeki sözlü talep için temsilcinin serbest nota yazmasına izin vermiyorum. Görüşme sonucu kodlarına "pazarlama aramasını reddetti" gibi bir sonuç eklenir ve bu sonuç seçildiğinde record_withdrawal doğrudan çağrılır. Notlar okunmaz; yapılandırılmış alanlar işlenir.
İleti Yönetim Sistemi ile eşitleme
Türkiye'de ticari elektronik ileti izinleri İYS üzerinden de yönetilir ve alıcı iznini İYS'den de geri çekebilir. Bu, CRM'in tek doğruluk kaynağı olmadığı anlamına gelir. Benim tasarımımda İYS de bir olay kaynağıdır: oradan gelen değişiklikler source = 'iys_sync' ile deftere eklenir, kendi occurred_at değeriyle sıralanır ve aynı kurala tabi olur.
Eşitlemede dikkat ettiğim iki konu var. Birincisi, CRM'de verilen bir iznin İYS'ye bildirilmesi başarısız olursa, bu durum bir hata kuyruğunda görünür olmalı ve izin "beklemede" sayılmalıdır. İkincisi, İYS'den gelen bir ret, CRM'deki daha eski bir onayı her zaman ezer; ama CRM'deki daha yeni bir onay İYS'ye bildirilmeden gönderimde kullanılmamalıdır. Bu ikinci kural için consent_state tablosuna bir synced_to_iys alanı eklemek ve gönderim kontrolünde bunu da sorgulamak yeterlidir.
Yeniden izin isteme ve form tasarımı
Geliştirici tarafında form da bu yapının parçasıdır. Her kanal ve amaç için ayrı, önceden işaretlenmemiş onay kutusu, aydınlatma metnine bağlantı ve gönderimde notice_version değerinin sunucuya iletilmesi gerekir. İstemcinin gönderdiği sürüm anahtarı sunucuda doğrulanır; tanınmayan bir sürüm gelirse olay kaydedilmez, form hatası döner.
Bir projeden diğerine geçişte de dikkat gerekir. A projesi için izin veren bir kişiye B projesinin kampanyasını göndermek, amaç tanımınız "firmanın tüm projeleri" değilse sorunludur. Bu yüzden purpose anahtarlarını en baştan doğru kapsamla tanımlamak, sonradan veri taşımaktan çok daha ucuzdur.
İzleme ve denetim
Panoda üç sayıya bakıyorum. Gönderim anında izin nedeniyle atlanan mesaj oranı, geri çekme olayının deftere düşmesinden dış sistemlere yayılmasına kadar geçen süre ve haftalık mutabakatta çıkan fark sayısı. İlki sıfır olursa şüphelenirim, çünkü gerçek bir listede her zaman birkaç kişi arada fikrini değiştirir. İkincisi saatlerle ölçülüyorsa kuyrukta bir tıkanıklık vardır. Üçüncüsü sıfırdan büyükse defteri atlayan bir kod yolu vardır.
Denetim için de defter yeterlidir: "Bu kişiye 12 Ekim'de gönderilen SMS için o anda geçerli izin var mıydı?" sorusu, occurred_at <= '2026-10-12' koşuluyla en son olayı bulan tek bir sorguyla yanıtlanır.
Sık sorulan sorular
Eski sistemden gelen, kanıtı olmayan izinleri ne yapmalıyım?
Bunları deftere source = 'legacy_import' ve boş kanıtla aktarın, ama gönderim kontrolünde ayrı bir sınıf olarak ele alın. Hangi gönderimlerde kullanılabileceğine hukuk ekibiyle birlikte karar verin; çoğu durumda yeniden izin isteme kampanyası en güvenli yoldur.
Geri çekme olayını silmek gerekirse ne olur?
Kişinin verilerinin silinmesi talebinde bile "bu kişiye mesaj gönderme" bilgisini tutmak gerekebilir; aksi halde kişi bir sonraki veri aktarımında yeniden listeye girer. Bunun için kimliği hash'lenmiş bir bastırma listesi (suppression list) tutulur. Hangi verinin ne kadar saklanacağına hukukçunuzla karar verin.
İşlemsel mesajlar için de defter gerekir mi?
İzin gerektirmeyen amaçlar için bile gönderim kaydı ve amacı tutulmalıdır. Bir mesajın gerçekten işlemsel olduğunu sonradan gösterebilmek, şablonun hangi amaca bağlı olduğunu kaydetmekle mümkündür.
Küçük bir emlak ofisi için bu yapı fazla değil mi?
Defter tablosu, durum tablosu ve tek bir assert_can_send fonksiyonu birkaç günlük iştir ve en büyük riski kapatır. İYS eşitlemesi, reklam kitlesi temizliği ve mutabakat işi ekip ve hacim büyüdükçe eklenebilir. Önemli olan, ilk günden izni bir alan yerine olay olarak tutmaktır.
WhatsApp için ayrı bir izin gerekir mi?
Kanal olarak ayrı tutmanızı öneririm. WhatsApp Business tarafında platformun kendi kuralları da vardır; bir kişinin SMS iznini WhatsApp için geçerli saymak hem platform hem mevzuat açısından risklidir.
Koray Yalçın — lead yönetimi, CRM mimarisi, dijital pazarlama ve PropTech üzerine çalışıyor.

