API Tasarımı & Event-Driven Architecture 9 dk okuma 1 okunma

Webhook Tasarımı: Güvenlik, İmzalama ve Retry

Webhook'lar, bir olay gerçekleştiğinde sistemlerin birbirine otomatik bildirim göndermesini sağlayan HTTP tabanlı geri çağırma (callback) mekanizmalarıdır. Bu yazıda, webhook mimarisi, güvenlik stratejileri (HMAC imzalama, timestamp, replay attack koruması), idempotency, retry mekanizmaları (exponential backoff), dead letter queue yönetimi, teslimat garantileri ve .NET'te webhook gönderme/alma implementasyonları ele alınır.

Webhook Tasarımı: Güvenlik, İmzalama ve Retry

Webhook Tasarımı: Güvenlik, İmzalama ve Retry Mekanizması

Webhook'lar, modern event-driven mimarilerin vazgeçilmez bir parçasıdır. Bir sistemde (kaynak) bir olay gerçekleştiğinde (ör. ödeme tamamlandı, sipariş oluşturuldu), bu sistem başka bir sisteme (tüketici) HTTP POST isteği göndererek bildirimde bulunur. Polling'in (sürekli sorgulama) aksine, webhook'lar gerçek zamanlı, verimli ve kaynak dostu bir iletişim yöntemidir. Ancak, webhook'ları güvenli, güvenilir ve ölçeklenebilir hale getirmek ciddi bir mühendislik gerektirir. Bu yazıda, üretim kalitesinde bir webhook sisteminin nasıl tasarlanacağını detaylıca ele alacağız.


1. Webhook Mimarisi: Temel Bileşenler

Tipik bir webhook akışı şu adımlardan oluşur:

  1. Olay Gerçekleşir: Kaynak sistemde bir olay tetiklenir (ör. order.created).

  2. Webhook Kaydı Bulunur: Kaynak sistem, bu olayı dinlemek üzere kayıtlı aboneleri (subscribers) bulur.

  3. Payload Oluşturulur: Olay verisi JSON formatında paketlenir.

  4. İmzalanır: Payload, bir gizli anahtar (secret) ile HMAC kullanılarak imzalanır.

  5. HTTP POST Gönderilir: Payload, abonenin webhook URL'sine POST edilir.

  6. Abone Doğrular: Abone, imzayı doğrular ve payload'ı işler.

  7. Yanıt Döner: Abone, 200 OK (başarılı) veya hata kodu döner.

  8. Retry Yönetimi: Başarısız olursa, kaynak sistem belirli bir politika ile yeniden dener.


2. Webhook Kayıt ve Yönetim Modeli

Abonelerin webhook'larını yönetmek için bir veri modeli gerekir:

csharp

public class WebhookSubscription
{
    public Guid Id { get; set; }
    public string TenantId { get; set; }
    public string Url { get; set; }              // Hedef endpoint
    public string Secret { get; set; }           // HMAC imzalama anahtarı (şifrelenmiş)
    public List<string> EventTypes { get; set; } // Abone olunan olaylar (order.created, payment.completed)
    public bool IsActive { get; set; }
    public DateTime CreatedAt { get; set; }
    public string[] AllowedIpAddresses { get; set; } // Opsiyonel: IP kısıtlaması
    public int MaxRetries { get; set; } = 5;
    public int TimeoutSeconds { get; set; } = 30;
}

Bu model, çok kiracılı (multi-tenant) sistemlerde her abonenin kendi yapılandırmasına sahip olmasını sağlar.


3. Güvenlik: Webhook'ları Korumak

Webhook endpoint'leri halka açık URL'lerdir ve kötü niyetli kişiler tarafından taklit edilebilir (spoofing). Bu nedenle güvenlik, webhook tasarımının en kritik parçasıdır.

A. HMAC İmzalama (Signature Verification)

En yaygın ve etkili yöntem, paylaşılan bir gizli anahtar (shared secret) ile HMAC-SHA256 imzası kullanmaktır.

Gönderen Taraf (Kaynak Sistem):

csharp

public class WebhookSigner
{
    public static string SignPayload(string payload, string secret, long timestamp)
    {
        // Timestamp'i payload'a dahil et (replay attack koruması)
        var signedPayload = $"{timestamp}.{payload}";
        
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
        return Convert.ToHexString(hash).ToLowerInvariant();
    }
}

// Gönderim sırasında
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var signature = WebhookSigner.SignPayload(payload, secret, timestamp);

httpClient.DefaultRequestHeaders.Add("X-Webhook-Signature", signature);
httpClient.DefaultRequestHeaders.Add("X-Webhook-Timestamp", timestamp.ToString());
httpClient.DefaultRequestHeaders.Add("X-Webhook-Id", webhookEventId.ToString());

Alıcı Taraf (Abone):

csharp

[HttpPost("webhook")]
public async Task<IActionResult> ReceiveWebhook()
{
    // 1. Body'yi oku (raw string olarak)
    using var reader = new StreamReader(Request.Body);
    var payload = await reader.ReadToEndAsync();

    // 2. Header'lardan imza ve timestamp al
    var signature = Request.Headers["X-Webhook-Signature"].ToString();
    var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString();
    var webhookId = Request.Headers["X-Webhook-Id"].ToString();

    // 3. Timestamp kontrolü (5 dakikadan eski istekleri reddet - replay attack koruması)
    var requestTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp));
    if (DateTimeOffset.UtcNow - requestTime > TimeSpan.FromMinutes(5))
    {
        return Unauthorized("İstek çok eski, reddedildi.");
    }

    // 4. İmzayı doğrula
    var expectedSignature = WebhookSigner.SignPayload(payload, secret, long.Parse(timestamp));
    if (!CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(signature),
        Encoding.UTF8.GetBytes(expectedSignature)))
    {
        return Unauthorized("Geçersiz imza.");
    }

    // 5. Idempotency kontrolü
    if (await _processedWebhooks.ExistsAsync(webhookId))
    {
        return Ok(); // Zaten işlenmiş, tekrar işleme
    }

    // 6. Payload'ı işle
    var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(payload);
    await _webhookProcessor.ProcessAsync(webhookEvent);

    // 7. İşlendi olarak kaydet
    await _processedWebhooks.MarkAsProcessedAsync(webhookId);

    return Ok();
}

Kritik Nokta: CryptographicOperations.FixedTimeEquals kullanmak, zamanlama saldırılarını (timing attacks) önler. Normal == karşılaştırması, karakter karakter karşılaştırma yaptığı için saldırgana bilgi sızdırabilir.

B. Timestamp ve Replay Attack Koruması

Timestamp, aynı webhook'un yakalanıp tekrar gönderilmesini (replay attack) engeller. Sunucu, gelen isteğin zaman damgasını kontrol eder ve belirli bir süreden (genellikle 5 dakika) eski istekleri reddeder.

C. HTTPS Zorunluluğu

Webhook URL'leri her zaman HTTPS olmalıdır. HTTP üzerinden gönderilen webhook'lar, araya giren (man-in-the-middle) saldırganlar tarafından okunabilir ve değiştirilebilir.

D. IP Beyaz Listeleme (Whitelisting)

Bazı sistemler, webhook gönderen sunucuların IP adreslerini yayınlar. Aboneler, sadece bu IP'lerden gelen istekleri kabul edecek şekilde yapılandırılabilir. Ancak, kaynak sistem esnek IP'lere sahipse (bulut ortamları), bu yöntem kırılgandır ve HMAC imzalamaya ek bir katman olarak kullanılmalıdır.

E. Secret Yönetimi

Her abone için benzersiz bir secret oluşturulmalıdır. Secret'lar:

  • Veritabanında şifrelenmiş olarak saklanmalıdır.

  • Aboneler tarafından düzenli olarak döndürülebilmelidir (rotation).

  • Asla log'larda, hata mesajlarında veya URL'lerde görünmemelidir.


4. Idempotency: Aynı Webhook'un Tekrar İşlenmesini Engelleme

Webhook'lar "en az bir kez" (at-least-once) teslim edilir. Yani aynı webhook birden fazla kez gelebilir. Bu nedenle aboneler, işlemlerini idempotent yapmalıdır.

Çözüm: Webhook ID ile Takip

Her webhook olayına benzersiz bir webhook_id (UUID) atanır. Abone, bu ID'yi bir veritabanında veya Redis'te saklayarak aynı olayın birden fazla kez işlenmesini engeller.

csharp

public async Task<bool> IsAlreadyProcessedAsync(string webhookId)
{
    // Redis ile hızlı kontrol (TTL: 7 gün)
    return await _redis.KeyExistsAsync($"processed_webhook:{webhookId}");
}

public async Task MarkAsProcessedAsync(string webhookId)
{
    await _redis.StringSetAsync(
        $"processed_webhook:{webhookId}", 
        "1", 
        TimeSpan.FromDays(7));
}

5. Retry Mekanizması: Güvenilir Teslimat

Webhook gönderimi başarısız olursa (ağ hatası, sunucu kapalı, 5xx yanıtı), kaynak sistem belirli bir politika ile yeniden denemelidir.

A. Hangi Durumlarda Retry Yapılmalı?

HTTP Durum Kodu Retry Yapılmalı mı? Açıklama
2xx (200, 201, 204) ❌ Hayır Başarılı, retry gerekmez.
3xx (301, 302) ❌ Hayır Yönlendirme, genellikle webhook URL'si güncellenmeli.
400 Bad Request ❌ Hayır Kalıcı hata, retry faydasız.
401 Unauthorized ❌ Hayır İmza hatası, retry faydasız.
404 Not Found ❌ Hayır Endpoint yok, retry faydasız.
408 Request Timeout ✅ Evet Geçici hata.
429 Too Many Requests ✅ Evet Rate limit, Retry-After başlığına uyulmalı.
5xx Server Error ✅ Evet Sunucu hatası, geçici olabilir.
Bağlantı Hatası (Timeout) ✅ Evet Ağ sorunu, geçici olabilir.

B. Exponential Backoff (Üstel Geri Çekilme)

Retry denemeleri arasındaki bekleme süresi her seferinde katlanarak artmalıdır. Bu, hem abonenin sistemini aşırı yüklemekten korur hem de geçici sorunların düzelmesi için zaman tanır.

Örnek Retry Politikası:

    1. deneme: Hemen

    1. deneme: 10 saniye sonra

    1. deneme: 1 dakika sonra

    1. deneme: 10 dakika sonra

    1. deneme: 1 saat sonra

    1. deneme: 6 saat sonra (son deneme)

Retry Politikası ile .NET Uygulaması (Polly ile):

csharp

public class WebhookDispatcher
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<WebhookDispatcher> _logger;

    public async Task DispatchAsync(WebhookSubscription subscription, WebhookEvent webhookEvent)
    {
        var payload = JsonSerializer.Serialize(webhookEvent);
        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var signature = WebhookSigner.SignPayload(payload, subscription.Secret, timestamp);

        // Polly ile retry politikası
        var retryPolicy = Policy
            .Handle<HttpRequestException>()
            .OrResult<HttpResponseMessage>(r => 
                (int)r.StatusCode >= 500 || 
                r.StatusCode == HttpStatusCode.RequestTimeout ||
                r.StatusCode == HttpStatusCode.TooManyRequests)
            .WaitAndRetryAsync(
                retryCount: subscription.MaxRetries,
                sleepDurationProvider: retryAttempt => 
                    TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) + 
                    TimeSpan.FromMilliseconds(new Random().Next(0, 1000)), // Jitter
                onRetry: (outcome, timespan, retryCount, context) =>
                {
                    _logger.LogWarning(
                        "Webhook retry {RetryCount} - {Timespan} sonra denenecek. Hedef: {Url}",
                        retryCount, timespan, subscription.Url);
                });

        var response = await retryPolicy.ExecuteAsync(async () =>
        {
            var request = new HttpRequestMessage(HttpMethod.Post, subscription.Url)
            {
                Content = new StringContent(payload, Encoding.UTF8, "application/json")
            };
            request.Headers.Add("X-Webhook-Signature", signature);
            request.Headers.Add("X-Webhook-Timestamp", timestamp.ToString());
            request.Headers.Add("X-Webhook-Id", webhookEvent.Id.ToString());
            request.Headers.Add("User-Agent", "MyApp-Webhook/1.0");

            return await _httpClient.SendAsync(request);
        });

        if (!response.IsSuccessStatusCode)
        {
            // Tüm retry'ler başarısız -> Dead Letter Queue'ya gönder
            await SendToDeadLetterQueueAsync(subscription, webhookEvent, response.StatusCode);
        }
    }
}

Jitter (Rastgelelik): Aynı anda binlerce webhook başarısız olursa, hepsinin aynı anda yeniden denenmesi (thundering herd) abonenin sistemini çökertebilir. Bekleme süresine rastgele bir değer eklemek bu sorunu önler.

C. Retry-After Başlığına Uyum

Abone, 429 Too Many Requests yanıtı ile birlikte Retry-After başlığı dönebilir. Kaynak sistem bu süreye uymalıdır.

csharp

if (response.StatusCode == HttpStatusCode.TooManyRequests &&
    response.Headers.RetryAfter != null)
{
    var delay = response.Headers.RetryAfter.Delta ?? 
                TimeSpan.FromSeconds(response.Headers.RetryAfter.Date.Value.Subtract(DateTimeOffset.UtcNow).TotalSeconds);
    await Task.Delay(delay);
}

6. Dead Letter Queue (DLQ) ve Manuel Müdahale

Tüm retry denemeleri başarısız olduğunda, webhook bir Dead Letter Queue'ya (DLQ) gönderilir. Bu, kaybolmaması gereken önemli olaylar için kritiktir.

csharp

public class FailedWebhook
{
    public Guid Id { get; set; }
    public Guid SubscriptionId { get; set; }
    public string Url { get; set; }
    public string Payload { get; set; }
    public string LastError { get; set; }
    public int AttemptCount { get; set; }
    public DateTime FailedAt { get; set; }
    public FailedWebhookStatus Status { get; set; } // Pending, Reviewed, Ignored
}

Aboneler, DLQ'daki webhook'ları manuel olarak inceleyip yeniden göndermeyi tetikleyebilmelidir. Bu, bir yönetim paneli (admin dashboard) veya API endpoint'i ile sağlanır.


7. Teslimat Garantileri ve Sıralama

Garanti Açıklama
At-Least-Once (En Az Bir Kez) Webhook en az bir kez teslim edilir. Tekrarlar mümkündür.
At-Most-Once (En Fazla Bir Kez) Webhook en fazla bir kez teslim edilir. Kayıp mümkündür (nadiren tercih edilir).
Exactly-Once (Tam Olarak Bir Kez) Teorik olarak idealdir ancak pratikte imkansızdır. Idempotency ile "etkili olarak bir kez" sağlanır.
Ordering (Sıralama) Webhook'lar genellikle sıralı teslim edilmez. Sıralama gerekiyorsa, her olaya bir sequence_number eklenmeli ve abone tarafında sıralama kontrol edilmelidir.

Önemli: Webhook'lar için "sıralama garantisi" vermek zordur. Örneğin, order.created ve order.cancelled olayları, ağ gecikmeleri nedeniyle ters sırada teslim edilebilir. Bu nedenle aboneler, olayları işlerken kendi iş mantıklarını buna göre tasarlamalıdır (ör. event time'a göre sıralama).


8. Ölçeklenebilirlik: Yüksek Hacimli Webhook Gönderimi

Binlerce aboneye saniyede yüzlerce webhook göndermek, ciddi bir ölçeklenebilirlik gerektirir.

Önerilen Mimari:

  1. Event Bus (Kafka, RabbitMQ): Olaylar, önce bir mesaj kuyruğuna yayınlanır.

  2. Worker Service (BackgroundService): Ayrı worker'lar, kuyruktan olayları alır ve webhook'ları gönderir.

  3. Rate Limiting (Abone Bazlı): Her aboneye gönderilen webhook sayısı sınırlandırılır. Abone saniyede 10 webhook alabilir diyorsa, worker bu limiti aşmamalıdır.

  4. Retry Queue: Başarısız webhook'lar, ayrı bir retry kuyruğuna gönderilir.

csharp

public class WebhookWorker : BackgroundService
{
    private readonly IWebhookQueue _queue;
    private readonly IWebhookDispatcher _dispatcher;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var message = await _queue.DequeueAsync(stoppingToken);
            
            // Her abone için rate limiting
            await _rateLimiter.WaitAsync(message.SubscriptionId);
            
            // Webhook'u gönder
            await _dispatcher.DispatchAsync(message.Subscription, message.Event);
        }
    }
}

9. En İyi Pratikler

  1. HMAC-SHA256 ile İmzalayın: Her webhook'u paylaşılan bir gizli anahtar ile imzalayın.

  2. Timestamp Kullanın: Replay attack'ları önlemek için.

  3. Idempotency Sağlayın: Webhook ID ile tekrar işlemeyi engelleyin.

  4. Exponential Backoff + Jitter Kullanın: Retry'larda akıllı bekleme süreleri uygulayın.

  5. HTTPS Zorunlu Kılın: HTTP üzerinden webhook göndermeyin.

  6. DLQ Kullanın: Başarısız webhook'ları kaybetmeyin, manuel müdahaleye izin verin.

  7. Timeout Belirleyin: 30 saniye gibi makul bir timeout kullanın; yanıt gelmezse retry yapın.

  8. Yanıtı Hızlı Dönün: Abone, webhook'u işlemeye başlamadan önce 200 OK dönmeli, işlemi arka planda (async) yapmalıdır. Bu, kaynak sistemin timeout'a uğramasını engeller.

  9. Observability (Gözlemlenebilirlik): Gönderilen, başarılı ve başarısız webhook'ları loglayın, metriklerle izleyin.

Sonuç

Webhook'lar, modern sistemlerin gerçek zamanlı iletişiminin temelidir. Ancak "payload gönder ve unut" yaklaşımı, üretim ortamında ciddi sorunlara yol açar. HMAC imzalama, timestamp, idempotency, exponential backoff, DLQ ve gözlemlenebilirlik gibi pratikleri uygulayarak, güvenli, güvenilir ve ölçeklenebilir bir webhook altyapısı kurabilirsiniz. Unutmayın: Webhook'lar, bir sisteminizin diğerine verdiği "söz"dür. Bu sözü tutmak, iyi bir mühendislik gerektirir.

Tüm yazılar Soru ya da yorumun mu var? Yaz