Webhook Göndermek: Standard Webhooks, İmzalama, Outbox ile Teslimat ve Retry Takvimi
Webhook'u request içinde değil outbox'tan, arka planda gönder; her isteği Standard Webhooks header'larıyla (webhook-id, webhook-timestamp, webhook-signature) HMAC-SHA256 ile imzala, günlere yayılan bir retry takvimi uygula ve hep hata veren endpoint'i kapat. Bookstore'un kurumsal müşterilerine order.shipped event'ini bu şekilde teslim ediyoruz.
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; imzalar Standard Webhooks'un resmî JavaScript kütüphanesi standardwebhooks 1.1 ile ayrıca doğrulanmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) kurumsal müşterilerine, yani okullara ve kütüphanelere, siparişleri kargoya verildiğinde haber vermek istiyor. Bir okulun webhook endpoint'i yanıt vermeden önce 20 saniye bekliyor. Üç soru:
Birincisi: siparişi kargoya veren POST /api/orders/1003/ship isteği ne kadar sürer?
İkincisi: ilk teslimat 500 aldı, 5 saniye sonra tekrar deneniyor. webhook-id header'ı aynı mı kalır, webhook-timestamp ile imza da aynı mı?
Üçüncüsü: müşterinin endpoint'i 301 Moved Permanently dönüyor. Yönlendirmeyi takip etmeli misin?
Cevaplar bu yazının sonundaki gerçek koşulardan: ship isteği 3,5 milisaniye sürüyor, çünkü webhook istek içinde gönderilmiyor. webhook-id her denemede aynı, timestamp ve imza ise her denemede yeni. Yönlendirme takip edilmiyor; 3xx bir teslimat hatası sayılıyor.
Bir önceki yazıda Bookstore bir dış servisin müşterisiydi ve bilinmeyen sonuçla uğraştı. Bu yazıda rolü değiştiriyoruz: Bookstore, kurumsal müşterilerinin sistemlerine çağrı yapan taraf. Webhook gönderen taraf olmak, aslında başkaları için bir dış servis olmak demek. Bu serinin ilk yazılarında karşı taraftan beklediğimiz her şeyi (net bir sözleşme, makul timeout'lar, retry'larda tutarlılık) şimdi biz sağlamak zorundayız.
Webhook'u Bir Ürün Gibi Tasarlamak
Webhook, müşterinin üzerine kod yazdığı bir API'dir. Endpoint'leri versiyonlayıp dokümante ettiğin gibi event'leri de öyle ele almak gerekiyor. Bookstore'un kurumsal müşteriler için yayımladığı event kataloğu şimdilik tek satır:
| Event | Ne zaman | data alanları | Kural |
|---|---|---|---|
order.shipped | Sipariş kargoya verildiğinde | orderId, shipmentId, trackingNumber | Alan eklenebilir, silinemez ve anlamı değişemez |
Payload olarak Standard Webhooks'un önerdiği yapıyı kullanıyoruz. Standard Webhooks, sektörde zaten kullanılan iyi uygulamaları tek bir sözleşmede toplayan, topluluk tarafından geliştirilen açık kaynak bir spesifikasyon (Apache 2.0 lisanslı 1.0.0 sürümü) ve birçok dilde kütüphane seti; bir RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) değil. Önerdiği payload üç alanlı: noktalarla ayrılmış bir type, olayın ne zaman olduğunu gösteren, tarih ve saat için uluslararası yazım standardı ISO 8601 biçiminde bir timestamp ve asıl veriyi taşıyan data.
Payload'ın ne kadarını göndereceğin ayrı bir karar. Spesifikasyon iki yaklaşımı tarif ediyor: "full" payload ilgili kaydın bütün hâlini taşıyor, "thin" payload yalnız kimlikleri. Thin payload'ta müşteri ayrıntı için API'mizi çağırıyor; veri yetkilendirmeden geçiyor ve erişim loglanabiliyor, yani hangi kurumun hangi siparişi ne zaman okuduğunu biliyoruz. Bookstore'da ikisinin ortasını seçtik: kimlikler ve müşterilerin hemen hemen hep istediği tek alan olan takip numarası. Adres, tutar ve kitap listesi payload'da yok. Spesifikasyon payload'ı küçük tutmayı (genelde 20 KB'ın altında) öneriyor; büyük veri için bir link göndermek daha doğru.
Versioning için en basit kural tabloda: alan eklemek serbest, alan silmek ya da anlamını değiştirmek yeni bir event tipi demek (order.shipped.v2 gibi) ve eski tip bir süre paralel gönderilmeye devam ediyor. Müşteri hangi event'leri istediğini abonelik sırasında seçiyor; filtrelemeyi gönderen tarafta yapmak, müşteriyi ilgilenmediği trafikten kurtarıyor.
İmza: webhook-id, webhook-timestamp ve webhook-signature
Webhook endpoint'i internete açık bir URL ve oraya herkes istek gönderebilir. Müşterinin "bu istek gerçekten Bookstore'dan geldi ve yolda değişmedi" diyebilmesi için her isteği imzalıyoruz. Standard Webhooks üç header tanımlıyor: webhook-id olayın benzersiz kimliği, webhook-timestamp denemenin Unix zamanı (saniye) ve webhook-signature imzanın kendisi. İmzalanan metin bu üçünün noktayla birleşimi: id.timestamp.body. Böylece imza yalnız gövdeyi değil, id'yi ve zamanı da koruyor:
// Standard Webhooks, simetrik şema: "id.timestamp.body" üzerinde HMAC-SHA256, "v1,<base64>" olarak gönderilir
public static class WebhookSignature
{
public const string SecretPrefix = "whsec_";
public static string NewSecret() => SecretPrefix + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
// Her secret için bir "v1,...", boşlukla ayrılmış: rotation sırasında eski ve yeni secret birlikte imzalar
public static string Sign(string messageId, long timestamp, ReadOnlySpan<byte> body, IEnumerable<string> secrets)
{
var signatures = new List<string>();
foreach (var secret in secrets)
{
signatures.Add("v1," + Convert.ToBase64String(ComputeHmac(secret, messageId, timestamp, body)));
}
return string.Join(' ', signatures);
}
public static byte[] ComputeHmac(string secret, string messageId, long timestamp, ReadOnlySpan<byte> body)
{
var key = Convert.FromBase64String(secret.StartsWith(SecretPrefix, StringComparison.Ordinal) ? secret[SecretPrefix.Length..] : secret);
var prefix = Encoding.UTF8.GetBytes($"{messageId}.{timestamp}.");
var signed = new byte[prefix.Length + body.Length];
prefix.CopyTo(signed, 0);
body.CopyTo(signed.AsSpan(prefix.Length));
return HMACSHA256.HashData(key, signed);
}
}
Secret her abonelik için ayrı üretiliyor: 32 rastgele byte, base64 ve whsec_ öneki. Spesifikasyon 24 ile 64 byte arasını istiyor ve aynı secret'ın farklı müşterilerde kullanılmamasını öneriyor; bir müşterinin secret'ı sızarsa yalnız onun webhook'ları taklit edilebilir. v1, HMAC (Hash-based Message Authentication Code; paylaşılan bir secret ile hesaplanan doğrulama kodu) ve SHA-256 ile yapılan simetrik imzanın etiketi. Spesifikasyon ed25519 ile asimetrik bir şema da (v1a) tanımlıyor; o durumda müşteri yalnız public key'i bilir ve secret'ı paylaşmak gerekmez. Biz kurumsal müşterilerle secret'ı güvenli bir kanaldan paylaşabildiğimiz için simetrik şemayla başladık.
Kendi yazdığın imza kodunun doğru olduğundan emin olmanın en iyi yolu onu başkasının koduyla doğrulamak. Fake müşteri endpoint'ine giden isteği kaydettim:
webhook-id: msg_01a1d979b39d7343bd2e231853640e0b
webhook-timestamp: 1794649995
webhook-signature: v1,0Q0CW0BC2MMOVnkJAiCN3qBBe8cyp+t9IYB/z6EJIWc=
{"type":"order.shipped","timestamp":"2026-11-14T09:53:14.141046+00:00","data":{"orderId":1003,"shipmentId":"SHP-1003","trackingNumber":"LC-7781240"}}
Sonra bu header'ları ve gövdeyi Standard Webhooks'un resmî JavaScript kütüphanesine (standardwebhooks npm paketi) verdim. Kütüphane imzayı doğruladı, gövdedeki takip numarasının tek bir rakamını değiştirdiğimde ise reddetti:
first delivery, old secret: verified
tampered body: No matching signature found
Payload'daki timestamp olayın zamanı, header'daki webhook-timestamp ise bu denemenin zamanı; ikisi aynı şey değil. Alıcı tarafın replay korumasını header'daki zamana göre yaptığını bir sonraki yazıda göreceğiz.
İstek İçinde Gönderme, Outbox'a Yaz
En kolay uygulama, sipariş kargoya verilince müşterinin URL'ine hemen POST atmak. Bu, müşterinin endpoint'ini kendi request'imizin bir parçası yapıyor: o yavaşsa ship isteği yavaş, o çökmüşse ship isteği hata veriyor ya da event kayboluyor. Bunun yerine webhook'u bir outbox'a yazıyoruz:
order.Status = OrderStatus.Shipped;
var shipmentId = $"SHP-{orderId}";
var queued = 0;
if (order.InstitutionId is { } institutionId)
{
// Burada yalnız outbox'a yazılıyor: request müşterinin endpoint'ini beklemiyor
queued = webhooks.Publish(institutionId, WebhookEventTypes.OrderShipped,
new OrderShippedData(order.Id, shipmentId, trackingNumber));
}
WebhookPublisher.Publish, olaya abone olan her abonelik için bir WebhookMessage oluşturuyor. Payload burada bir kez serialize ediliyor ve byte dizisi olarak saklanıyor. Bu ayrıntı önemli: retry'larda payload'ı yeniden üretirsen alan sırası, sayı biçimi ya da o arada değişmiş bir veri yüzünden farklı byte'lar gönderebilirsin ve aynı webhook-id iki farklı içerikle gitmiş olur.
// Standard Webhooks payload'ı: type, timestamp (olayın zamanı) ve data
var payload = JsonSerializer.SerializeToUtf8Bytes(new { type = eventType, timestamp = now, data }, Json);
outbox.Add(new WebhookMessage
{
Id = $"msg_{Guid.CreateVersion7(now):N}",
SubscriptionId = subscription.Id,
EventType = eventType,
Payload = payload,
CreatedAt = now,
NextAttemptAt = now
});
Önceki yazıdaki attempt'ler gibi outbox da bu örnekte bellekte. Gerçek Bookstore'da WebhookMessages bir tablo ve sipariş durumunun Shipped olmasıyla aynı transaction'da yazılıyor; transaction commit olmazsa webhook da yok, commit olursa webhook mutlaka gönderilecek. Bu garantinin nasıl kurulduğunu Message Queue'larla Çalışmak serisinin Transactional Outbox yazısında anlattık. Webhook teslimatı için hazır ürünler de var (açık kaynak ya da managed webhook servisleri); karar verirken aradığın şey bu yazıdaki listenin aynısı: imza, retry takvimi, devre dışı bırakma, teslimat log'u.
Müşteri endpoint'ini 20 saniye bekleyen moda (--receiver Slow) alıp siparişi kargoya verdim:
{"orderId":1003,"status":"Shipped","shipmentId":"SHP-1003","webhooksQueued":1}
200 0.003514s
Ship isteği 3,5 milisaniyede döndü. Webhook'un kendisi ise birkaç saniye sonra arka planda gönderildi ve 15 saniyelik timeout'a takıldı; bunu aşağıdaki teslimat log'unda göreceğiz.
Teslimat: Kısa Timeout, Yönlendirme Yok
Outbox'ı WebhookDispatcher adında bir BackgroundService boşaltıyor. Her saniye zamanı gelmiş mesajları alıp en fazla 8 tanesini paralel gönderiyor; böylece yavaş bir müşteri diğerlerini bekletmiyor. Bir sonraki tur önceki tur bitmeden başlamadığı için aynı mesaj aynı anda iki kez gönderilmiyor. Webhook client'ı diğer client'larımızdan farklı ayarlandı:
// Başkasının sunucusu: yönlendirme yok, kısa connect timeout, deneme başına 15 sn, burada retry yok
// (retry, outbox'ın takvimi). Hedef adresin SSRF filtresi: API Güvenliği serisi, 08. yazı
builder.Services.AddHttpClient(WebhookDispatcher.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(5),
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
})
.AddResilienceHandler("webhooks", pipeline => pipeline.AddTimeout(TimeSpan.FromSeconds(15)));
Her ayarın bir nedeni var. Standard Webhooks 3xx yanıtları başarısız teslimat sayıyor ve yönlendirmeyi takip etmek yerine müşterinin URL'ini güncellemesini öneriyor; ayrıca yönlendirme, SSRF (Server-Side Request Forgery; sunucuyu istenmeyen bir adrese istek atmaya zorlama) korumasını aşmanın klasik yolu. Spesifikasyonun önerdiği timeout 15 ile 30 saniye arası; biz alt sınırı seçtik. Pipeline'da retry yok, çünkü retry'ı saniyeler değil saatler ölçeğinde outbox yapıyor. URL'in çözüldüğü IP adresini bağlantı anında kontrol eden SSRF filtresini API Güvenliği serisinin dış dünyayla konuşan API'ler yazısında ConnectCallback ile kurduk; aynı handler buraya eklenir.
Teslimatın kendisinde iki ayrıntı var. Header'lar her denemede yeniden üretiliyor; webhook-id sabit, timestamp ve imza yeni. Yanıtın gövdesini ise hiç tamponlamıyoruz: istek HttpCompletionOption.ResponseHeadersRead ile gidiyor ve hata durumunda gövdenin yalnız ilk 1 KB'ı teslimat log'u için okunuyor. Başkasının sunucusu sana 1 GB'lık bir hata sayfası dönebilir.
request.Headers.Add("webhook-id", message.Id);
request.Headers.Add("webhook-timestamp", timestamp.ToString());
request.Headers.Add("webhook-signature", WebhookSignature.Sign(message.Id, timestamp, message.Payload, subscription.ActiveSecrets(now)));
var started = Stopwatch.GetTimestamp();
int? status = null;
string? error = null;
TimeSpan? retryAfter = null;
try
{
var client = httpClientFactory.CreateClient(ClientName);
// Yalnız header'lar: başkasının sunucusundan gelen büyük bir gövdeyi asla tamponlamıyoruz
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
status = (int)response.StatusCode;
retryAfter = response.Headers.RetryAfter?.Delta;
if (!response.IsSuccessStatusCode)
{
error = await ReadStartAsync(response, cancellationToken);
}
}
catch (Exception ex) when (ex is HttpRequestException or Polly.Timeout.TimeoutRejectedException)
{
error = ex.Message;
}
Retry Takvimi ve Endpoint'i Devre Dışı Bırakmak
2xx dışındaki her sonuç, timeout ve bağlantı hatası bir başarısız deneme. Sonraki denemenin zamanını bir takvim belirliyor. Standard Webhooks'un örnek takvimini aldık: hemen, 5 saniye, 5 dakika, 30 dakika, 2, 5, 10, 14, 20 ve 24 saat; toplam 10 deneme ve yaklaşık 3 gün. Her gecikmeye %10'a kadar jitter ekliyoruz; bir kesintiden dönen müşteri endpoint'ine bütün mesajlar aynı saniyede yığılmasın. Yanıtta Retry-After varsa ve takvimdeki gecikmeden uzunsa ona uyuyoruz.
var schedule = options.Value.RetrySchedule;
var failures = ++message.ConsecutiveFailures;
if (failures > schedule.Length)
{
message.Status = WebhookMessageStatus.Failed;
logger.LogWarning("Webhook {MessageId} failed after {Attempts} attempts", message.Id, failures);
return;
}
// En fazla %10 jitter: kesintiden dönen endpoint'lere hepsi aynı anda yüklenmesin
var delay = schedule[failures - 1] * (1 + Random.Shared.NextDouble() * 0.1);
if (retryAfter > delay)
{
delay = retryAfter.Value;
}
message.NextAttemptAt = now + delay;
Bir endpoint uzun süre hep hata veriyorsa ona göndermeye devam etmek iki tarafa da yük. İki kuralımız var: 410 Gone, spesifikasyona göre "artık webhook istemiyorum" demek ve aboneliği hemen kapatıyor; bir endpoint 5 gün boyunca tek bir başarılı teslimat almadan hata veriyorsa abonelik yine kapanıyor. Spesifikasyon bu durumda müşteriye başka bir kanaldan da haber verilmesini öneriyor; Bookstore'da kurumun teknik iletişim adresine e-posta gidiyor, örnek kodda bunun yerine bir log satırı var.
Fake endpoint'i sırayla Error (500) ve Gone (410) modlarına alıp aynı mesajı yeniden gönderdim. API log'u:
Webhook msg_01a1d979b39d7343bd2e231853640e0b attempt 1 failed (500), next attempt in 00:00:05.3427864
Webhook msg_01a1d979b39d7343bd2e231853640e0b attempt 2 failed (500), next attempt in 00:05:24.2581344
Webhook subscription 01a1d979-b251-709e-825f-c6655eb2a99a disabled: Endpoint answered 410 Gone.
Yavaş endpoint'teki deneme ise teslimat log'una şöyle düştü:
{"at":"2026-11-14T09:54:04.370883+00:00","statusCode":null,"error":"The operation didn't complete within the allowed timeout of '00:00:15'.","durationMs":15021}
Takvimi gerçek saatle beklemek mümkün değil, o yüzden bir test FakeTimeProvider ile saati her denemenin NextAttemptAt'ine ilerletiyor. WireMock'taki fake endpoint iki kez 500, sonra 200 dönüyor:
passed Bookstore.Api.Tests.WebhookDeliveryTests.Failed_deliveries_follow_the_schedule_with_the_same_webhook_id (199ms)
status=Delivered, requests=3, webhook-ids=1, timestamps=1794646800,1794646805,1794647125, delays=00:00:05.3359866, 00:05:20.5658503
Üç istek, tek webhook-id, üç farklı timestamp; gecikmeler 5 saniye ve 5 dakika artı jitter. Tahmin sorusunun ikinci cevabı bu satırda.
Secret Rotation ve "Yeniden Gönder" Butonu
Secret'lar değişir: bir çalışan ayrılır, bir log dosyasına yanlışlıkla yazılır, ya da güvenlik politikası yılda bir rotation ister. Müşteri yeni secret'ı sistemine koyana kadar webhook'ların doğrulamadan düşmemesi için Standard Webhooks bir yol tanımlıyor: webhook-signature header'ı boşlukla ayrılmış bir liste ve geçiş süresince her mesaj hem eski hem yeni secret'la imzalanıyor. Alıcı listedeki imzalardan biri tutarsa mesajı kabul ediyor.
POST /admin/webhooks/subscriptions/{id}/rotate-secret yeni bir secret üretiyor ve eskisine 24 saatlik bir son kullanma zamanı veriyor. Rotation'dan sonra giden mesajın header'ı:
webhook-signature: v1,/OFCxPttqUZBslFfwkTkU4UODCzD8Ea1x8iWB/Eb0Ns= v1,XIb2YNWlU5pZaXydQ+C5+XHWMVB5iSd9AKNy2IDieXw=
Resmî kütüphane bu mesajı iki secret'la da doğruladı; rotation'dan önceki mesajı ise yalnız eski secret'la:
first delivery, old secret: verified
first delivery, new secret: No matching signature found
after rotation, old secret: verified
after rotation, new secret: verified
Destek ekibinin en çok kullandığı ekran da teslimat log'u. GET /admin/webhooks/messages her mesajın denemelerini, status code'larını, hata gövdesinin başını ve süresini dönüyor. Kurum "kargo bildirimi gelmedi" dediğinde ilk bakılacak yer burası; bizim tarafımızda 200 görünüyorsa sorun alıcıda, 500 ya da timeout görünüyorsa müşteriye kendi log'una bakması için tam zaman verilebiliyor. POST /admin/webhooks/messages/{id}/resend ise "yeniden gönder" butonunun arkasındaki endpoint: mesajı aynı webhook-id ile kuyruğa geri koyuyor. Id'nin aynı kalması önemli, çünkü alıcı mesajı zaten işlemişse bunu id'den anlayıp ikinci kez işlemiyor. Bu admin endpoint'leri örnekte yetkilendirmesiz; Bookstore'da mağaza personeli policy'sinin arkasında duruyor.
Üretimde Dikkat
Production'da yalnız HTTPS URL'leri kabul et. İmza içeriğin değişmediğini kanıtlar ama içeriği gizlemez; HTTP üzerinden giden bir sipariş bildirimi yolda okunabilir. Örnekteki kayıt endpoint'i HTTP'ye yalnız Development ortamında ve yalnız localhost için izin veriyor.
Kurumsal müşterilerin bir kısmı firewall arkasında ve yalnız belirli IP'lerden gelen isteği kabul ediyor. Webhook trafiğini sabit çıkış IP'leri olan bir NAT (Network Address Translation; iç ağdaki adresleri dışarıya tek adresle çıkaran yapı) ya da proxy üzerinden göndermek ve bu IP'leri dokümanda yayımlamak çok sorulan bir ihtiyaç.
Outbox tablosu büyür. Teslim edilmiş mesajları belirli bir süre (destek ekibinin ihtiyacı kadar, örneğin 30 gün) sonra silen bir job planla ve teslimat başarısını metric olarak izle: abonelik başına başarılı teslimat oranı, en eski bekleyen mesajın yaşı ve kapanan abonelik sayısı.
Sık Düşülen Tuzaklar
Webhook'u request içinde senkron göndermek. Müşterinin endpoint'i yavaşladığında senin endpoint'in de yavaşlar; çökerse event kaybolur.
Sıralama garantisi varmış gibi davranmak. Retry'lar ve paralel teslimat yüzünden order.shipped ondan önce olan bir event'ten önce varabilir. Alıcıya payload'daki timestamp alanını verip sıralamayı ona bırak, dokümanda da sıralama garantisi vermediğini açıkça yaz.
Payload'ı her denemede yeniden serialize etmek. Aynı webhook-id farklı byte'larla gider; alıcının idempotency kontrolü ilk içeriği, senin destek ekibin ikincisini görür.
Yönlendirmeyi takip etmek ve yanıt gövdesini sınırsız okumak. İkisi de kontrolünde olmayan bir sunucunun senin kaynaklarını kullanmasına izin vermek demek.
Bütün müşterilere tek bir secret vermek. Bir müşterinin sızdırdığı secret ile diğer bütün müşterilere sahte event gönderilebilir.
Küçük Bir Deneme
★☆☆ WebhookPublisher'a order.delivered event'ini ekle ve kataloğa yaz. Hangi alanları thin, hangilerini full göndermeyi seçtin, neden?
★★☆ Fake endpoint'e 503 ve Retry-After: 600 dönen bir mod ekle. Teslimat log'unda bir sonraki denemenin zamanı 5 saniye mi, 10 dakika mı?
★★★ WebhookDispatcher'a abonelik başına en fazla 2 eşzamanlı teslimat sınırı ekle (Polly'nin concurrency limiter'ı ya da bir SemaphoreSlim sözlüğü). Bir müşteriye 100 mesaj birikmişken diğer müşterilerin teslimatının gecikmediğini bir testle göster.
Bu yazıda webhook'u gönderen taraftaydık. Bir sonraki yazıda masanın öbür tarafına geçiyoruz: ödeme sağlayıcısı Bookstore'a payment.succeeded gönderiyor. İmzayı ham gövde üzerinden doğrulamak, replay'e karşı timestamp kontrolü, hızlı 2xx için inbox ve bir önceki yazıda açık kalan "bilinmeyen sonuç"ları webhook'la kapatmak orada.