Webhook Almak: İmza Doğrulama, Replay Koruması, Hızlı 2xx ve Idempotent İşleme
Gelen webhook'un imzasını model binding'den önce ham gövde üzerinden ve sabit süreli karşılaştırmayla doğrula, timestamp toleransı ve webhook-id ile replay'i engelle, event'i inbox'a yazıp hemen 200 dön ve işlemeyi arka planda idempotent yap. Bookstore'da payment.succeeded ile bilinmeyen ödeme sonuçlarını kapatıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.Extensions.Http.Resilience 10.11, Polly 8.9, WireMock.Net 2.31 ve xUnit v3 4.0 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) bu yazıda webhook alan taraf: ödeme sağlayıcısı, bir ödeme tamamlandığında Bookstore'a payment.succeeded event'i gönderiyor. Üç soru:
Birincisi: endpoint'in gövdeyi bir record'a bind ediyor, imzayı da o record'u JsonSerializer.Serialize ile tekrar string'e çevirip hesaplıyorsun. Sağlayıcı ile aynı secret'ı kullanıyorsun. İmza tutar mı?
İkincisi: sağlayıcı aynı event'i iki kez gönderdi. İkincisine ne dönmelisin: 409, 422 mi, 200 mü?
Üçüncüsü: endpoint event'i alınca siparişi onaylıyor, faturayı kesiyor ve e-posta gönderiyor; toplam 30 saniye. Sağlayıcının timeout'u 10 saniye. Ne olur?
Birincinin cevabı "hayır" ve nedeni tek bir karakter: aşağıdaki testte sağlayıcının gönderdiği +00:00, System.Text.Json'ın varsayılan encoder'ından \u002B00:00 olarak çıkıyor. İkincinin cevabı 200; aksi hâlde sağlayıcı göndermeye devam eder. Üçüncüsünde sağlayıcı 10. saniyede pes edip event'i tekrar gönderir, sen de aynı ödemeyi ikinci kez işlemeye başlarsın.
Bir önceki yazıda webhook gönderen taraftaydık ve alıcıdan beklediğimiz davranışları sıraladık: imzayı doğrulasın, webhook-id ile tekrarları ayıklasın, hızlı 2xx dönsün. Bu yazıda o alıcıyı yazıyoruz.
Ham Gövdeyi Model Binding'den Önce Okumak
İmza, sağlayıcının gönderdiği byte'lar üzerinden hesaplanıyor. O byte'ları elde etmenin tek güvenilir yolu, gövdeyi hiçbir şeye dönüştürmeden okumak. Endpoint bu yüzden bir DTO (Data Transfer Object; yalnız veri taşıyan sınıf) almıyor, yalnız HttpRequest alıyor:
// Kasıtlı olarak anonim: çağıranın kim olduğunu bir kullanıcı değil, imza kanıtlıyor
app.MapPost("/webhooks/payment-provider", PaymentWebhookEndpoint.ReceiveAsync);
// Doğrula -> inbox'a yaz -> queue'ya at -> 200. Yanıttan önce iş mantığı yok
public static async Task<IResult> ReceiveAsync(
HttpRequest request,
PaymentWebhookInbox inbox,
IOptions<PaymentWebhookOptions> options,
TimeProvider time,
ILogger<PaymentWebhookInbox> logger,
CancellationToken cancellationToken)
{
// Gönderildiği hâliyle ham byte'lar: imza bunları kapsar, yeniden serialize edilmiş bir object'i değil
var body = await ReadBodyAsync(request, options.Value.MaxBodyBytes, cancellationToken);
if (body is null)
{
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
}
var id = request.Headers["webhook-id"].ToString();
var verification = WebhookSignature.Verify(id, request.Headers["webhook-timestamp"], request.Headers["webhook-signature"],
body, options.Value.Secrets, time.GetUtcNow(), options.Value.Tolerance);
if (verification != WebhookVerification.Valid)
{
logger.LogWarning("Rejected payment webhook {WebhookId}: {Reason}", id, verification);
return Results.Unauthorized();
}
if (!inbox.TryAdd(new InboxMessage { Id = id, Body = body, ReceivedAt = time.GetUtcNow() }))
{
// Zaten elimizde olan bir kopya: onayla, yoksa sağlayıcı göndermeye devam eder
logger.LogInformation("Payment webhook {WebhookId} is a duplicate, acknowledged", id);
return Results.Ok();
}
logger.LogInformation("Payment webhook {WebhookId} stored in the inbox", id);
return Results.Ok();
}
ReadBodyAsync gövdeyi 8 KB'lık parçalarla okuyor ve 64 KB'ı geçerse okumayı bırakıp 413 dönüyor. İmza doğrulanmadan önce gövdenin tamamını belleğe almak zorundayız; bu da endpoint'i, imzası olmayan büyük isteklerle belleği doldurmaya açık hâle getiriyor. Sınırı önce Content-Length'e, sonra gerçekten okunan byte sayısına göre kontrol ediyoruz, çünkü Content-Length yalan söyleyebilir ya da hiç gönderilmeyebilir.
Tahmin sorusundaki tuzağın gerçek hâli bir testte. Sağlayıcının JSON'unda iki nokta ve virgülden sonra boşluk var ve timestamp +00:00 ile bitiyor; bu tamamen geçerli bir JSON. Test aynı gövdeyi JsonSerializer.Deserialize ve Serialize ile geri çeviriyor ve imzayı orijinal gövdeyle hesaplıyor:
original: {"type": "payment.succeeded", "timestamp": "2026-11-14T10:00:00.000000+00:00", "data": {"id": "ch_000001", "status": "succeeded", "amount": 54890, "currency": "TRY", "merchant_reference": "bookstore-1001-01a1d981c28477d7827fbdd49814cbb4"}}
reserialized: {"type":"payment.succeeded","timestamp":"2026-11-14T10:00:00.000000\u002B00:00","data":{"id":"ch_000001","status":"succeeded","amount":54890,"currency":"TRY","merchant_reference":"bookstore-1001-01a1d981c28477d7827fbdd49814cbb4"}}
status: 401
Boşluklar gitmiş, + karakteri \u002B olmuş. System.Text.Json'ın varsayılan encoder'ı bazı karakterleri güvenlik amacıyla escape ediyor; çıktıda görüldüğü gibi + bunlardan biri. İki string aynı veriyi taşıyor ama byte olarak farklı, HMAC (Hash-based Message Authentication Code; paylaşılan bir secret ile hesaplanan doğrulama kodu) ise byte'lara bakıyor. Standard Webhooks spesifikasyonu da gövdeyi parse edip tekrar serialize etmeyi alıcı tarafta çok yaygın bir hata olarak anıyor.
İmzayı Doğrulamak: Sabit Süreli Karşılaştırma ve Rotation
Doğrulama, imzayı gönderen taraftaki hesabın aynısını yapıp sonuçları karşılaştırıyor. Bir önceki yazıdaki WebhookSignature sınıfına Verify ekledik:
// Alıcı taraf: header'daki herhangi bir "v1," imzası, aktif secret'larımızdan herhangi biriyle tutabilir (rotation)
public static WebhookVerification Verify(string? messageId, string? timestampHeader, string? signatureHeader,
ReadOnlySpan<byte> body, IEnumerable<string> secrets, DateTimeOffset now, TimeSpan tolerance)
{
if (string.IsNullOrEmpty(messageId) || string.IsNullOrEmpty(signatureHeader)
|| !long.TryParse(timestampHeader, out var timestamp))
{
return WebhookVerification.MissingHeaders;
}
// Replay koruması: yakalanmış bir istek yalnız tolerans penceresi içinde kullanılabilir
var sent = DateTimeOffset.FromUnixTimeSeconds(timestamp);
if (sent < now - tolerance || sent > now + tolerance)
{
return WebhookVerification.StaleTimestamp;
}
foreach (var secret in secrets)
{
var expected = ComputeHmac(secret, messageId, timestamp, body);
foreach (var candidate in signatureHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (!candidate.StartsWith("v1,", StringComparison.Ordinal))
{
continue;
}
var provided = new byte[expected.Length];
// Sabit süreli karşılaştırma: kaç byte'ın tuttuğunu sızdıran erken çıkış yok
if (Convert.TryFromBase64String(candidate[3..], provided, out var written)
&& written == expected.Length
&& CryptographicOperations.FixedTimeEquals(provided, expected))
{
return WebhookVerification.Valid;
}
}
}
return WebhookVerification.BadSignature;
}
CryptographicOperations.FixedTimeEquals iki diziyi, ilk farklı byte'ta durmadan sonuna kadar karşılaştırıyor. SequenceEqual ya da string karşılaştırması ilk farkta döner; saldırgan yanıt süresini ölçerek imzanın kaç byte'ının doğru olduğunu tahmin edebilir. Spesifikasyon da sabit süreli karşılaştırmayı açıkça istiyor. Base64 string'lerini değil, çözülmüş byte'ları karşılaştırıyoruz; base64'ün farklı ama eşdeğer yazımları da böylece sorun olmuyor.
İki döngü rotation için. Gönderen taraf geçiş süresinde header'a birden fazla v1, imzası koyabiliyor; alıcı tarafta da secret'ını değiştiren biz olabiliriz. Payments:Provider:Webhooks:Secrets bir dizi: yeni secret başta, eskisi geçiş süresince arkada. appsettings.json'daki değer bu örnek için uydurulmuş bir test secret'ı; gerçek secret configuration'a bir secret store'dan gelir. Test, iki secret tanımlıyken eski secret'la imzalanmış isteğin kabul edildiğini doğruluyor.
Replay Koruması: Timestamp ve webhook-id Birlikte
İmzalı bir isteği yakalayan biri onu olduğu gibi tekrar gönderebilir; imza hâlâ geçerlidir. İki önlem birbirini tamamlıyor. webhook-timestamp imzanın içinde olduğu için değiştirilemiyor ve 5 dakikalık tolerans dışındaki istekleri doğrudan reddediyoruz; testte 10 dakika önce imzalanmış istek 401 alıyor. Pencerenin içindeki tekrarları ise webhook-id yakalıyor: inbox'ta aynı id varsa istek işlenmiyor. Tolerans, sunucuların saat farkını kaldıracak kadar geniş, yakalanan isteğin uzun süre kullanılmasına izin vermeyecek kadar dar olmalı; Standard Webhooks'un resmî JavaScript kütüphanesi de 5 dakika kullanıyor.
Hızlı 2xx: Inbox ve Queue
Webhook gönderen taraf kısa bir timeout'la çalışıyor; bir önceki yazıda Bookstore 15 saniye bekliyordu, fake ödeme sağlayıcımız 10 saniye bekliyor. Bu sürede yanıt alamazsa event'i tekrar gönderiyor. Endpoint'te siparişi onaylayıp fatura kesip e-posta göndermeye kalkarsan iki şey olur: yavaş günde sağlayıcı aynı event'i tekrar tekrar gönderir ve bir kesinti sırasında bütün event'ler sağlayıcının retry kuyruğuna birikir.
Kural basit: doğrula, kalıcı olarak kaydet, sıraya koy, 200 dön. Asıl iş arka planda. PaymentWebhookInbox event'leri webhook-id anahtarıyla saklıyor ve id'yi bir Channel<string>'e yazıyor:
// webhook-id anahtar: bir event'in ikinci kopyası burada, hiçbir işlem yapılmadan tanınır
// Örnekte bellekte; Bookstore'da id'si primary key olan bir WebhookInbox tablosu
public bool TryAdd(InboxMessage message)
{
lock (_messages)
{
if (!_messages.TryAdd(message.Id, message))
{
return false;
}
}
_queue.Writer.TryWrite(message.Id);
return true;
}
Gerçek uygulamada inbox bir tablo ve webhook-id primary key. İkinci kopya geldiğinde insert unique constraint'e takılıyor ve endpoint 200 dönüyor. Kayıt yanıttan önce kalıcı olduğu için, process 200 döndükten hemen sonra çökse bile event kaybolmuyor; açılışta Received durumunda kalmış kayıtları tekrar kuyruğa alan bir adım bunu tamamlıyor (son bölümdeki denemelerden biri). Inbox pattern'ini mesaj tüketicileri için Message Queue'larla Çalışmak serisinin Idempotent Consumer ve Inbox yazısında anlattık; burada webhook'a uyarlanmış hâli var.
Yanıt süresini testte ölçtüm. İlk istek 3 ms'de 200 aldı, aynı id'yle gelen kopya da 200 aldı ama işlenmedi:
passed Bookstore.Api.Tests.PaymentWebhookTests.Signed_webhook_is_acknowledged_fast_and_processed_once (1s 932ms)
first=200 in 3 ms, copy=200, inbox=Processed attempts=1, order=Paid
Kopya için 409 ya da 422 dönmek yanlış: sağlayıcı 2xx dışındaki her yanıtı başarısız teslimat sayar ve tekrar dener. Sağlayıcının bakış açısından ikinci kopya da başarıyla teslim edildi.

Idempotent İşleme ve Event Sırası
PaymentWebhookProcessor bir BackgroundService; channel'dan id'leri okuyup inbox'taki kaydı işliyor. Her adım tekrar çalıştırılabilecek şekilde yazıldı:
var attempt = attempts.FindByReference(evt.Data.MerchantReference);
if (attempt is null)
{
throw new InvalidOperationException($"No payment attempt for reference {evt.Data.MerchantReference}.");
}
if (attempt.Status == PaymentAttemptStatus.Succeeded)
{
// Checkout, bir retry ya da reconciliation daha önce davrandı: yapılacak bir şey yok
logger.LogInformation("Order {OrderId} is already paid, webhook {WebhookId} changes nothing", attempt.OrderId, message.Id);
return;
}
// İmza kimin gönderdiğini söyler, içeriğin doğru olduğunu söylemez: güncel durumu sağlayıcıya sor
await using var scope = scopes.CreateAsyncScope();
var gateway = scope.ServiceProvider.GetRequiredService<IPaymentGateway>();
var charge = await gateway.FindChargeAsync(attempt.Reference, cancellationToken);
if (charge?.ProviderPaymentId != evt.Data.Id || evt.Data.Amount != (long)(attempt.Amount * 100))
{
throw new InvalidOperationException($"Webhook {message.Id} does not match the provider's charge for {attempt.Reference}.");
}
İki karar var. Birincisi, webhook'taki veriye körü körüne güvenmiyoruz. İmza event'in sağlayıcıdan geldiğini kanıtlıyor; ama bir entegrasyon hatası yanlış referans gönderebilir ya da sağlayıcı hesabı ele geçirilmiş olabilir. Bu yüzden tutarı kendi attempt'imizle karşılaştırıyor ve charge'ı sağlayıcının API'sinden doğruluyoruz. Üçüncü parti yanıtlarına güvenmemenin güvenlik tarafını API Güvenliği serisinin dış dünyayla konuşan API'ler yazısında anlattık.
İkincisi sıra. Webhook'ların gönderildikleri sırayla geleceğinin bir garantisi yok; bir önceki yazıda biz de vermedik. Bir ödeme için önce payment.refunded, sonra gecikmiş bir payment.succeeded gelebilir. Event'i "yapılacak iş" değil "bir şey değişti" sinyali olarak ele almak bu sorunu çözüyor: işlemci güncel durumu sağlayıcıdan sorguluyor ve ona göre karar veriyor. Sorgu yapamadığın durumlarda payload'daki timestamp ya da bir sürüm numarasıyla, kendi kaydındaki son değişiklikten eski olan event'leri yok sayabilirsin. Durum geçişlerini de tek yönlü tutuyoruz: Succeeded bir attempt asla tekrar Open olmuyor.
İşleme sırasında bir hata olursa kayıt inbox'ta Failed olarak kalıyor ve hata mesajı saklanıyor. Sağlayıcıya zaten 200 döndüğümüz için o tekrar göndermeyecek; hatalı kayıtları tekrar işlemek bizim sorumluluğumuz. Ödemenin kendisi için ikinci bir emniyet de var: 06. yazıdaki reconciliation job'u açık attempt'leri zaten sorguluyor.
Bilinmeyen Sonucu Webhook'la Kapatmak
06. yazının son cümlesinde üçüncü bir yoldan söz etmiştik: sağlayıcının ödeme bittiğinde haber vermesi. Fake sağlayıcıyı 15 saniyelik işlem süresiyle ve webhook adresiyle başlattım; event'i bilerek iki kez gönderiyor:
dotnet run --project src/Bookstore.FakeProviders -- --payments Slow --payments-delay 15 --payments-webhook http://localhost:5180/webhooks/payment-provider --webhook-copies 2
Checkout 8 saniyede pes edip 202 döndü. API log'u (zaman damgalı):
13:00:53.673 warn: Bookstore.Ordering.CheckoutService
Order 1001: payment outcome unknown, key 01a1d980-9837-7b1b-80d6-23a7afec145a
13:01:00.929 info: Bookstore.Payments.Webhooks.PaymentWebhookInbox
Payment webhook msg_01a1d980d2e579fc9128a4abe6013681 stored in the inbox
13:01:00.932 info: Bookstore.Payments.Webhooks.PaymentWebhookInbox
Payment webhook msg_01a1d980d2e579fc9128a4abe6013681 is a duplicate, acknowledged
13:01:00.937 info: Bookstore.Payments.Webhooks.PaymentWebhookProcessor
Order 1001 paid by webhook msg_01a1d980d2e579fc9128a4abe6013681 (ch_000001)
Sağlayıcı işlemi bitirdiği anda haber verdi ve sipariş 8 milisaniye içinde Paid oldu; reconciliation job'unun 2 dakikalık MinAge'ini beklemeye gerek kalmadı. Kopya, inbox'ta tanınıp işlenmeden onaylandı. Artık bilinmeyen sonucu kapatan üç yolumuz var: müşterinin aynı key'le tekrar denemesi, webhook ve reconciliation. İlk ikisi hızlı ama garantisiz; webhook kaybolabilir, müşteri geri gelmeyebilir. Reconciliation yavaş ama her zaman çalışıyor. Üçünü birlikte kullanmak, her birinin zayıf tarafını diğerleriyle kapatıyor.
Lokal Geliştirmede Webhook Test Etmek
Gerçek sağlayıcının sandbox ortamı senin localhost'una istek gönderemez. Bu seride sağlayıcıyı WireMock.Net tabanlı bir fake ile taklit ettiğimiz için sorun yok, ama sağlayıcının sandbox'ıyla uçtan uca deneme yapmak istediğinde lokal portunu internete açan bir tünel gerekiyor. Windows'ta Microsoft'un dev tunnels aracı bunu yapıyor. Microsoft Learn'deki komutlar:
winget install Microsoft.devtunnel
devtunnel user login
devtunnel host -p 5180 --allow-anonymous
host komutu https://<tunnel_id>-5180.<region>.devtunnels.ms/ biçiminde bir adres yazıyor; sağlayıcının paneline webhook adresi olarak bunu girersin. Webhook gönderen sağlayıcı tünele giriş yapamayacağı için anonim erişim gerekiyor ve dokümanın da uyardığı gibi tünel adresini bilen herkes lokal sunucuna erişebiliyor. Tüneli yalnız deneme süresince açık tut; imza doğrulaması zaten açık olduğu için imzasız istekler 401 alacaktır. Bu komutlar bir Microsoft ya da GitHub hesabıyla giriş istediği için bu yazı için çalıştırmadım; Microsoft Learn'deki CLI referansından aldım.
Üretimde Dikkat
Webhook endpoint'ini authentication middleware'inin dışında tut ama rate limiting'in dışında tutma. İmzasız istekler ucuz bir 401 alıyor; yine de birinin saniyede binlerce istekle endpoint'i meşgul etmesini engellemek gerekir. Gateway seviyesinde rate limiting'i ASP.NET Core Güvenliği serisinin rate limiting yazısında anlattık.
Inbox'ı izle. Received durumunda bekleyen kayıt sayısı ve en eskisinin yaşı, işlemcinin takılıp takılmadığını gösteriyor; Failed kayıtlar da bir insanın bakması gereken listedir.
Secret'ı configuration dosyasında tutma. Örnekteki değer uydurma bir test secret'ı; production'da bir secret store'dan gelsin ve rotation sırasında eski secret'ı dizide ne kadar tutacağını sağlayıcının geçiş süresine göre belirle.
Sağlayıcı IP listesi yayımlıyorsa onu ek bir kontrol olarak kullanabilirsin, ama imzanın yerine değil. IP'ler değişir; imza ise her istekte kendini kanıtlar.
Sık Düşülen Tuzaklar
İmzayı yeniden serialize edilmiş JSON'la doğrulamak. Boşluk, alan sırası ya da tek bir escape edilmiş karakter imzayı bozar; yukarıdaki testte bu karakter + idi.
İşi request içinde yapmak. 30 saniyelik bir işlem sağlayıcının timeout'unu aşar; sağlayıcı event'i tekrar gönderir ve aynı işi iki kez başlatırsın.
Kopya event'e hata dönmek. Sağlayıcı bunu başarısız teslimat sayar ve göndermeye devam eder; kopyayı tanıyıp 200 dön.
İmzayı == ile karşılaştırmak. Erken çıkan karşılaştırma bir timing saldırısına kapı açar; CryptographicOperations.FixedTimeEquals kullan.
Event sırasına güvenmek. Gecikmiş bir event daha yeni bir durumu ezebilir; güncel durumu sağlayıcıdan sor ya da geçişleri tek yönlü tut.
Küçük Bir Deneme
★☆☆ Fake sağlayıcıyı --webhook-copies 3 ile başlat. API log'unda kaç stored in the inbox ve kaç is a duplicate satırı görüyorsun?
★★☆ PaymentWebhookOptions.Tolerance'ı 1 saniyeye indir ve bir testte webhook-timestamp'i 2 saniye ileri al. Endpoint ne dönüyor? Sunucuların saatleri 3 saniye farklıysa production'da ne olurdu?
★★★ Uygulama açılırken inbox'ta Received durumunda kalmış kayıtları tekrar kuyruğa alan bir IHostedService yaz. Inbox'ı bir ConcurrentDictionary yerine SQL Server'da bir tabloya taşıdığında webhook-id'nin primary key olması, iki API instance'ının aynı event'i aynı anda alması durumunda ne sağlıyor?
Bu yazıyla Bookstore'un dış dünyayla konuşmasının iki yönü de tamamlandı: dışarıya giden çağrılar zaman sınırlı, tekrar denenebilir, idempotent ve gözlemlenebilir; dışarıdan gelen webhook'lar doğrulanmış, tekrarlara karşı korunmuş ve hızlı yanıtlanıyor. Serinin son yazısında geriye bakıyoruz: Microsoft.Extensions.Http.Polly'den Http.Resilience'a, Polly v7'den v8'e geçiş ve .NET 11'in HttpClient tarafına getirdikleri.