Reklamdan CRM'e uzanan bir lead akışında en pahalı hata, gürültü çıkaran hata değildir. En pahalı hata, hiç kimsenin fark etmediği hatadır. Bir webhook 500 döner, otomasyon aracı "başarısız" diye küçük bir kırmızı ikon gösterir, kimse bakmaz; üç gün sonra satış ekibi "bu kampanyadan neden hiç lead gelmedi" diye sorar. Formu dolduran kişi o sırada çoktan başka bir projeyle görüşmeye başlamıştır.

Ben Koray Yalçın; lead yönetimi, CRM ve dijital pazarlama altyapıları üzerine, özellikle gayrimenkul ve PropTech tarafında çalışıyorum. Bu yazıda hata yönetimini bir "try/catch alışkanlığı" olarak değil, bir tasarım kararı olarak ele alacağım: hatalar nasıl sınıflandırılır, ne zaman ve nasıl yeniden denenir, aynı lead'in iki kez oluşması nasıl engellenir, kurtarılamayan mesajlar nereye gider ve sonradan nasıl güvenle yeniden oynatılır. Örnekler TypeScript ve PostgreSQL ile, ama fikirler dil bağımsız.

Problemi doğru tanımlamak: "Hata" tek bir şey değil

Bir lead entegrasyonunda tipik zincir şöyledir: reklam platformu (Meta Lead Ads, Google lead form uzantısı) veya landing page formu bir olay üretir, bu olay sizin alıcı servisinize gelir, servis veriyi doğrular ve zenginleştirir, ardından CRM'e yazar ve son olarak bildirim ya da WhatsApp/SMS gibi bir ilk temas adımını tetikler.

Bu zincirin her halkasında farklı türde hatalar çıkar ve hepsine aynı tepkiyi vermek iki kötü sonuçtan birine yol açar: ya her şeyi sonsuza kadar yeniden denersiniz ve kalıcı hatalar kuyruğu tıkar, ya da ilk hatada vazgeçersiniz ve geçici bir ağ kesintisi yüzünden lead kaybedersiniz.

Bu yüzden ilk iş, hataları davranışa göre sınıflandırmaktır. Ben pratikte dört sınıf kullanıyorum:

  1. Geçici (transient): Zaman aşımı, bağlantı kopması, 502/503/504, hız sınırı (429). Aynı istek bir süre sonra büyük ihtimalle başarılı olur.
  2. Kalıcı veri hatası (permanent-data): Zorunlu alan eksik, telefon biçimi çözülemiyor, CRM bir alanı reddediyor (400/422). Aynı veriyle yüz kez denemek sonucu değiştirmez; verinin düzeltilmesi gerekir.
  3. Kalıcı yapılandırma hatası (permanent-config): Süresi dolmuş token (401), yetki eksikliği (403), silinmiş bir pipeline aşaması ya da alan. Veri doğru, ama sistem yanlış ayarlı. Düzeltildiğinde tüm bekleyen mesajlar tekrar oynatılabilir olmalıdır.
  4. Belirsiz (unknown): İstek gitti, ama cevap gelmeden bağlantı koptu. İşlem karşı tarafta gerçekleşmiş olabilir de olmayabilir de. En tehlikeli sınıf budur, çünkü körü körüne yeniden denemek mükerrer kayıt üretebilir.

Bu ayrımın kodda açıkça görünmesi gerekir; HTTP durum kodlarını iş mantığının içine dağıtmak yerine tek bir sınıflandırma fonksiyonu yazmak işi çok kolaylaştırır.

// errors.ts export type ErrorClass = "transient" | "permanent_data" | "permanent_config" | "unknown"; export class IntegrationError extends Error { constructor( message: string, public readonly cls: ErrorClass, public readonly retryAfterMs?: number, public readonly detail?: unknown, ) { super(message); } } export function classifyHttp(status: number, headers: Headers, body: unknown): IntegrationError { const retryAfter = parseRetryAfter(headers.get("retry-after")); if (status === 429) return new IntegrationError("rate_limited", "transient", retryAfter, body); if (status >= 500) return new IntegrationError(`upstream_${status}`, "transient", retryAfter, body); if (status === 401 || status === 403) return new IntegrationError("auth_or_permission", "permanent_config", undefined, body); if (status === 404) return new IntegrationError("target_missing", "permanent_config", undefined, body); if (status === 400 || status === 422) return new IntegrationError("rejected_payload", "permanent_data", undefined, body); return new IntegrationError(`unexpected_${status}`, "unknown", undefined, body); } export function classifyNetwork(err: unknown, requestSent: boolean): IntegrationError { // İstek hiç gönderilmediyse (DNS, bağlantı reddi) güvenle geçici sayılabilir. // Gönderildikten sonra kopma ise sonuç belirsizdir. return new IntegrationError(String(err), requestSent ? "unknown" : "transient"); } function parseRetryAfter(v: string | null): number | undefined { if (!v) return undefined; const sec = Number(v); if (!Number.isNaN(sec)) return sec * 1000; const at = Date.parse(v); return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now()); } 

Burada önemli bir nokta var: 404'ü "veri hatası" değil "yapılandırma hatası" saydım. Lead akışlarında 404 çoğu zaman birisinin CRM'de bir aşamayı, kullanıcıyı ya da özel alanı silmesinden kaynaklanır. Bu durumda tek tek lead'leri düzeltmek yerine eşlemeyi düzeltip hepsini toplu yeniden oynatmak gerekir. Sınıflandırma, sonradan alacağınız operasyonel aksiyonu belirlemelidir.

Önce kalıcı kayıt, sonra işlem: Gelen olayı kaybetmemek

Webhook alıcısında en sık gördüğüm hata, gelen isteği alır almaz CRM'e yazmaya çalışmak ve CRM yavaşsa ya da hata verirse kaynak platforma da hata döndürmektir. Bu durumda yeniden deneme sorumluluğunu tamamen kaynak platforma bırakmış olursunuz; onun yeniden deneme politikası ise sizin kontrolünüzde değildir.

Daha sağlam desen şudur: gelen olayı önce kendi veritabanınıza ham haliyle yazın, hızlıca 200 dönün, işlemi arka planda yapın.

CREATE TABLE inbound_event ( id bigserial PRIMARY KEY, source text NOT NULL, -- 'meta_lead_ads', 'web_form', ... source_ref text NOT NULL, -- kaynağın kendi olay/lead kimliği received_at timestamptz NOT NULL DEFAULT now(), payload jsonb NOT NULL, status text NOT NULL DEFAULT 'pending', -- pending|processing|done|dead attempts int NOT NULL DEFAULT 0, next_attempt_at timestamptz NOT NULL DEFAULT now(), last_error_class text, last_error text, UNIQUE (source, source_ref) ); CREATE INDEX inbound_event_due ON inbound_event (next_attempt_at) WHERE status = 'pending'; 

UNIQUE (source, source_ref) kısıtı burada sessiz bir kahramandır. Kaynak platform aynı olayı iki kez gönderirse (ki webhook'lar "en az bir kez" teslim garantisiyle çalışır) ikinci kayıt veritabanı seviyesinde reddedilir:

// webhook handler await db.query( `INSERT INTO inbound_event (source, source_ref, payload) VALUES ($1, $2, $3) ON CONFLICT (source, source_ref) DO NOTHING`, ["meta_lead_ads", body.leadgen_id, body], ); return new Response("ok", { status: 200 }); 

İmza doğrulaması (örneğin Meta webhook'larında gelen imza başlığının uygulama gizli anahtarıyla doğrulanması) bu adımdan önce yapılmalıdır. İmzası tutmayan istek kalıcı bir hata değildir, saldırı ya da yanlış yapılandırmadır; kaydetmeden reddedin ve ayrıca loglayın.

Jitter'lı exponential backoff: Yeniden denemeyi düzgün yapmak

Geçici hatalarda yeniden deneme şarttır, ama sabit aralıklı ya da anında yeniden deneme iki sorun çıkarır. Birincisi, karşı sistem zaten zorlanıyorsa onu daha da boğarsınız. İkincisi, bir kesintiden sonra biriken bütün mesajlar aynı saniyede tekrar denenir ve "thundering herd" dediğimiz ani yük oluşur.

Çözüm, katlanarak büyüyen bekleme süresine rastgelelik (jitter) eklemektir. Ben genellikle "full jitter" yaklaşımını kullanıyorum: bekleme süresi sıfır ile o denemenin üst sınırı arasında rastgele seçilir.

// backoff.ts const BASE_MS = 2_000; const CAP_MS = 30 * 60_000; // tek bekleme en fazla 30 dk export const MAX_ATTEMPTS = 12; export function nextDelayMs(attempt: number, retryAfterMs?: number): number { const ceiling = Math.min(CAP_MS, BASE_MS * 2 ** attempt); const jittered = Math.random() * ceiling; // Karşı taraf Retry-After verdiyse ondan erken deneme return Math.max(jittered, retryAfterMs ?? 0); } 

Buradaki sayılar örnek değerlerdir; doğru değerler sizin iş gereksiniminize bağlıdır. Lead akışında belirleyici soru şudur: bir lead en geç ne kadar sürede satış ekibinin önüne düşmeli? Gayrimenkulde ilk temas hızı satış sonucunu doğrudan etkilediği için, ben yeniden deneme penceresini saatlerle değil dakikalarla düşünmeyi ve pencere dolmadan önce bir insanı uyarmayı tercih ediyorum. Yeniden deneme sistemi lead'i "sonunda" teslim ederse ama bu altı saat sürerse, teknik olarak başarılı, ticari olarak başarısız olmuşsunuzdur.

Worker: Sınıfa göre karar veren tek döngü

Arka plan işçisi, vadesi gelen olayları alır, işler ve sonucu hata sınıfına göre kaydeder. Birden fazla işçi aynı anda çalışabileceği için satırları FOR UPDATE SKIP LOCKED ile kilitlemek, aynı olayın iki işçi tarafından aynı anda işlenmesini engeller.

// worker.ts async function tick() { const { rows } = await db.query( `UPDATE inbound_event SET status = 'processing' WHERE id IN ( SELECT id FROM inbound_event WHERE status = 'pending' AND next_attempt_at <= now() ORDER BY next_attempt_at LIMIT 20 FOR UPDATE SKIP LOCKED ) RETURNING *`, ); for (const ev of rows) { try { await processLead(ev); // doğrula, normalize et, CRM'e yaz await markDone(ev.id); } catch (e) { const err = e instanceof IntegrationError ? e : new IntegrationError(String(e), "unknown"); await handleFailure(ev, err); } } } async function handleFailure(ev: InboundEvent, err: IntegrationError) { const attempts = ev.attempts + 1; const retryable = err.cls === "transient" || err.cls === "unknown"; if (retryable && attempts < MAX_ATTEMPTS) { const delay = nextDelayMs(attempts, err.retryAfterMs); await db.query( `UPDATE inbound_event SET status='pending', attempts=$2, next_attempt_at=now() + ($3 || ' milliseconds')::interval, last_error_class=$4, last_error=$5 WHERE id=$1`, [ev.id, attempts, delay, err.cls, err.message], ); return; } await moveToDeadLetter(ev, err, attempts); } 

"Belirsiz" sınıfını yeniden denenebilir saydığıma dikkat edin. Bunu güvenle yapabilmemin tek sebebi, bir sonraki bölümdeki idempotency katmanı. O katman yoksa belirsiz hataları otomatik yeniden denemek yerine insan incelemesine göndermek daha doğrudur.

Idempotency: Aynı lead'i iki kez yaratmamak

Belirsiz hata senaryosunu düşünün: CRM'e "lead oluştur" isteğini gönderdiniz, CRM kaydı oluşturdu, ama cevap size ulaşmadan bağlantı koptu. Yeniden denerseniz CRM'de aynı kişi iki kez görünür; iki farklı danışmana atanabilir, iki kez aranabilir. Bu, müşterinin gözünde kurumun düzensiz görünmesinin en hızlı yoludur.

Bunu engellemenin üç yolu var ve genellikle en az ikisini birlikte kullanıyorum:

  1. Karşı sistemin idempotency desteği: Bazı API'ler bir idempotency anahtarı başlığı kabul eder; aynı anahtarla gelen ikinci istek yeni kayıt oluşturmaz, ilk sonucu döner. Varsa mutlaka kullanın ve anahtarı source + source_ref gibi deterministik bir değerden türetin, rastgele üretmeyin.
  2. Harici kimlik ile upsert: CRM'de "harici kaynak kimliği" gibi benzersiz bir özel alan tanımlayın ve "oluştur" yerine "bu harici kimlikle varsa güncelle, yoksa oluştur" mantığıyla yazın.
  3. Yerel eşleme tablosu: Hangi olayın hangi CRM kaydına dönüştüğünü kendi tarafınızda tutun.
CREATE TABLE crm_link ( source text NOT NULL, source_ref text NOT NULL, crm_object text NOT NULL, -- 'lead', 'contact', 'deal' crm_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (source, source_ref, crm_object) ); 
async function upsertLeadInCrm(ev: InboundEvent, lead: NormalizedLead) { const existing = await findCrmLink(ev.source, ev.source_ref, "lead"); if (existing) return existing.crm_id; // Belirsiz bir önceki denemeden kalmış olabilir: önce CRM'de harici kimlikle ara const found = await crm.searchByExternalId(`${ev.source}:${ev.source_ref}`); const crmId = found ?? (await crm.createLead({ ...lead, external_id: `${ev.source}:${ev.source_ref}`, }, { idempotencyKey: `${ev.source}:${ev.source_ref}` })); await saveCrmLink(ev.source, ev.source_ref, "lead", crmId); return crmId; } 

Bu katman, kişi düzeyindeki tekilleştirmenin (aynı kişinin farklı formlardan iki kez gelmesi) yerine geçmez. O ayrı bir problemdir ve eşleşme kuralları gerektirir. Buradaki amaç daha dardır: aynı olayın iki kez işlenmesini engellemek.

Zincirin sonraki adımları için de aynı ilke geçerlidir. Lead CRM'e yazıldıktan sonra tetiklenen WhatsApp ilk mesajı, danışman bildirimi ya da görev oluşturma adımlarının her biri kendi idempotency anahtarına sahip olmalıdır. Aksi halde CRM adımı başarılı olup bildirim adımı başarısız olduğunda tüm akışı yeniden denemek, müşteriye ikinci kez "merhaba" mesajı gitmesine yol açar. Bu yüzden ben her yan etkiyi ayrı bir adım olarak kaydediyor ve yalnızca tamamlanmamış adımları yeniden deniyorum.

Dead-letter tablosu: Kurtarılamayanı kaybetmeden ayırmak

Kalıcı hatalar ve deneme hakkı biten olaylar silinmemeli, ayrı bir yere taşınmalıdır. Bu "dead-letter" alanı bir çöp kutusu değil, bir iş kuyruğudur: her satırın bir sahibi, bir sebebi ve bir çözüm durumu olmalıdır.

CREATE TABLE dead_letter ( id bigserial PRIMARY KEY, inbound_event_id bigint NOT NULL REFERENCES inbound_event(id), error_class text NOT NULL, error_message text NOT NULL, error_detail jsonb, attempts int NOT NULL, dead_at timestamptz NOT NULL DEFAULT now(), resolution text, -- NULL | 'replayed' | 'fixed_manually' | 'discarded' resolved_by text, resolved_at timestamptz, note text ); 
async function moveToDeadLetter(ev: InboundEvent, err: IntegrationError, attempts: number) { await db.tx(async (t) => { await t.query(`UPDATE inbound_event SET status='dead', attempts=$2, last_error_class=$3, last_error=$4 WHERE id=$1`, [ev.id, attempts, err.cls, err.message]); await t.query(`INSERT INTO dead_letter (inbound_event_id, error_class, error_message, error_detail, attempts) VALUES ($1,$2,$3,$4,$5)`, [ev.id, err.cls, err.message, JSON.stringify(err.detail ?? null), attempts]); }); await alert(err.cls, ev); } 

Uyarı politikası sınıfa göre değişmelidir. Tek bir permanent_data hatası için bütün ekibi uyandırmaya gerek yoktur; günlük bir özet yeterlidir. Ama tek bir permanent_config hatası, muhtemelen o andan sonra gelen bütün lead'lerin aynı yere düşeceği anlamına gelir; bu hemen bir insana ulaşmalıdır.

Kalıcı veri hatalarında bile lead'in kendisini tamamen kaybetmemek mümkündür. Telefon numarası çözülemeyen bir kayıt, e-postası geçerliyse "eksik veri" etiketiyle CRM'e yine de yazılabilir ve bir danışmana manuel kontrol görevi açılabilir. Teknik olarak hatalı olan veri, ticari olarak hâlâ değerli bir taleptir. Hangi alanların gerçekten zorunlu olduğuna, satış ekibiyle birlikte karar vermek gerekir.

Replay: Düzelttikten sonra güvenle yeniden oynatmak

Yapılandırma hatasını düzelttiniz, diyelim ki süresi dolan CRM token'ını yenilediniz. Şimdi dead-letter'daki yüzlerce olayı geri almanız gerekiyor. Bunu güvenli yapmak için birkaç kural:

  • Replay, normal işleme yolunu kullanmalı. Ayrı bir "toplu import" scripti yazmak yerine olayları tekrar pending durumuna alın. Böylece aynı doğrulama, aynı idempotency ve aynı izleme devreye girer.
  • Filtrelenebilir olmalı. Sınıfa, kaynağa ve zaman aralığına göre seçim yapabilmelisiniz.
  • Kademeli olmalı. Önce küçük bir parti, sonuçları kontrol, sonra geri kalanı.
  • İz bırakmalı. Kim, ne zaman, hangi gerekçeyle yeniden oynattı.
-- Örnek: belirli bir zaman aralığındaki yapılandırma hatalarını ilk 50 kayıtla yeniden kuyruğa al WITH picked AS ( SELECT d.id AS dl_id, d.inbound_event_id FROM dead_letter d JOIN inbound_event e ON e.id = d.inbound_event_id WHERE d.resolution IS NULL AND d.error_class = 'permanent_config' AND e.source = 'meta_lead_ads' AND d.dead_at BETWEEN $1 AND $2 ORDER BY d.dead_at LIMIT 50 ), requeue AS ( UPDATE inbound_event e SET status='pending', attempts=0, next_attempt_at=now() FROM picked p WHERE e.id = p.inbound_event_id RETURNING e.id ) UPDATE dead_letter d SET resolution='replayed', resolved_by=$3, resolved_at=now(), note=$4 FROM picked p WHERE d.id = p.dl_id; 

Replay sırasında unutulmaması gereken bir ayrıntı daha var: bazı adımlar zamana duyarlıdır. Üç gün önce gelmiş bir lead'e otomatik "Formunuzu aldık, hemen arıyoruz" mesajı göndermek yanlış bir beklenti yaratır. Bu yüzden ilk temas adımı, olayın orijinal alınma zamanına bakmalı ve belli bir süreyi aşan kayıtlarda otomatik mesaj yerine danışmana öncelikli bir arama görevi açmalıdır. Reklam platformlarına geri dönüşüm gönderen adımlarda da platformların olay zamanı için kabul ettiği geriye dönük sınırlar vardır; sınırı aşan olayları göndermeye çalışmak yerine raporlamak gerekir.

İzleme: Hatanın değil, sessizliğin alarmı

Bütün bu yapı kurulduğunda bile bir tür hata hiçbir tabloya düşmez: hiç gelmeyen olay. Reklam platformundaki webhook aboneliği düşmüşse, form sayfasında bir JavaScript hatası gönderimi engelliyorsa ya da bir DNS değişikliği endpoint'inizi erişilmez kılmışsa, sisteminiz kusursuz biçimde "hata yok" raporlar.

Bu yüzden ben izlemeyi üç katmanda kuruyorum:

  1. Hacim sapması: Her kaynak için beklenen saatlik lead aralığını geçmiş verilerden çıkarıp, belirgin bir düşüşte uyarı. Kampanya bütçesi açıkken bir kaynaktan uzun süre hiç olay gelmemesi başlı başına bir alarmdır.
  2. Mutabakat: Belirli aralıklarla reklam platformunun kendi API'sinden o dönemde oluşan lead kimliklerini çekip inbound_event tablosundaki source_ref değerleriyle karşılaştırmak. Fark varsa eksik olanları çekip normal yoldan işlemek.
  3. Kuyruk sağlığı: pending durumunda en eski olayın yaşı, dead-letter'daki çözülmemiş kayıt sayısı ve sınıflara göre dağılımı.
-- Kuyruk sağlığı için tek sorgu SELECT (SELECT max(now() - received_at) FROM inbound_event WHERE status='pending') AS oldest_pending_age, (SELECT count(*) FROM dead_letter WHERE resolution IS NULL) AS open_dead_letters, (SELECT jsonb_object_agg(error_class, c) FROM ( SELECT error_class, count(*) c FROM dead_letter WHERE resolution IS NULL GROUP BY error_class) s) AS open_by_class; 

Mutabakat adımı, bütün tasarımın sigortasıdır. Webhook'lar, kuyruklar ve yeniden denemeler ne kadar iyi kurulursa kurulsun, "kaynakta var, bizde yok" sorusunu düzenli soran bir süreç olmadan sistemin güvenilir olduğunu iddia edemezsiniz.

Otomasyon araçlarıyla çalışıyorsanız

Her ekip bu altyapıyı sıfırdan kod olarak yazmaz; çoğu akış Make, n8n ya da Zapier gibi araçlarda kuruludur. Aynı ilkeler orada da uygulanabilir: hata yolu (error route) olmayan bir senaryo bırakmayın, hata yolunda olayı ham haliyle bir tabloya ya da sayfaya yazın, kalıcı ve geçici hataları farklı dallara ayırın, CRM adımında "oluştur" yerine harici kimlikle arama + güncelleme kullanın ve haftalık bir mutabakat kontrolü planlayın. Aracın kendi yeniden deneme ayarlarına güvenmek yerine, en azından kurtarılamayan olayların görünür bir yerde biriktiğinden emin olun.

Sık sorulan sorular (SSS)

Her hatayı yeniden denemek neden kötü bir fikir?
Çünkü kalıcı hatalar yeniden denemeyle düzelmez. Eksik zorunlu alanı olan bir kaydı on iki kez denemek yalnızca kuyruğu meşgul eder, gerçek geçici hataların işlenmesini geciktirir ve insanın sorunu fark etmesini erteler. Kalıcı hatayı hemen dead-letter'a alıp sahibine bildirmek daha hızlı çözüm getirir.

Jitter gerçekten gerekli mi, sabit bekleme yetmez mi?
Az trafikte fark hissedilmez. Ama bir CRM ya da API kesintisi sonrası yüzlerce mesaj aynı anda beklemeye girer; sabit aralıkla hepsi aynı anda tekrar denenir ve sistem toparlanırken yeniden boğulur. Rastgelelik bu yükü zamana yayar.

Kaynak platform zaten yeniden deniyorsa benim ayrıca retry yazmama gerek var mı?
Evet. Kaynağın yeniden denemesi yalnızca sizin endpoint'inize ulaşma sorununu çözer; sizin CRM'e, mesajlaşma servisine ya da reklam platformuna yaptığınız çağrıların başarısızlığını çözmez. Ayrıca kaynağın politikası değişebilir ve sizin kontrolünüzde değildir. Olayı önce kendi tablonuza yazmak, sorumluluğu sizin tarafınıza alır.

Idempotency anahtarını nasıl seçmeliyim?
Deterministik olmalı ve olayın kendisinden türetilmelidir: kaynak adı ve kaynağın verdiği benzersiz kimlik iyi bir başlangıçtır. Her denemede rastgele bir anahtar üretirseniz, anahtarın hiçbir koruma sağlamaz.

Dead-letter'daki kayıtları ne kadar tutmalıyım?
Çözülene kadar mutlaka; çözüldükten sonra ise kişisel veri içerdikleri için şirketinizin veri saklama politikasına tabi olmalıdırlar. Çözülmüş kayıtların ham yükünü belirli bir süre sonra silip yalnızca istatistiksel özeti tutmak iyi bir dengedir.

Bu tasarım küçük bir ekip için fazla mı?
Tamamını ilk günden kurmak gerekmez. Benim önerdiğim öncelik sırası şöyle: önce gelen olayı ham haliyle kalıcı yazmak, sonra harici kimlikle upsert, ardından dead-letter ve uyarı, en son mutabakat. İlk iki adım bile sessiz lead kaybının en yaygın kaynaklarını kapatır.

Kapanış

Lead entegrasyonlarında hata yönetimi, bir altyapı detayı gibi görünse de doğrudan satışa dokunan bir konudur. Kaybolan her olay, bir insanın "bilgi almak istiyorum" demesinin cevapsız kalması anlamına gelir. Hataları sınıflandırm...