Fintech’te Güvenli Ödeme Akışları: Stripe Checkout + Webhook + Idempotency ile Çift İşlem Riskini Önleme
Stripe Checkout, webhook signature verification ve idempotency key propagation ile çift işlem riskini azaltan üretim yaklaşımı.
Fintech ürünlerinde en pahalı hatalardan biri aynı finansal etkinin iki kez uygulanmasıdır. Aynı işlem için iki kez tetiklemek, cüzdan bakiyesinin çift artması veya transferin tekrar yazılması; teknik olarak “retry problemi”, operasyonel olarak güven kaybıdır.
- Stripe Checkout ile ödeme oturumu açma
- Webhook doğrulaması (signature verification)
- Idempotency key’in frontend → API → Stripe metadata → webhook hattında taşınması
- Uygulama + veritabanı seviyesinde tekrar işlem engelleme
1) Gerçek Problem: Retry = Double Charge Riski
Aşağıdaki senaryolar normaldir:
- Kullanıcı butona iki kez tıklar.
- Mobil ağ zayıftır, frontend timeout alır ve isteği tekrar atar.
- Stripe webhook’u
at least once deliverymodeli nedeniyle aynı event’i yeniden gönderir. - Aynı iş olayı backend’de birden fazla kez işlenir.
Eğer tasarım idempotent değilse tek bir iş, birden fazla muhasebe kaydı üretebilir.
2) Ödeme Akışı
- UI
Idempotency-Keyüretir. - API
/api/payments/create-sessionçağrısında bu key’i header’dan alır. - API, Stripe Checkout Session metadata’sına
walletId,amount,currency,idempotencyKeyyazar. - API, Stripe tarafına session create çağrısını da aynı key ile (
RequestOptions.IdempotencyKey) gönderir. - Kullanıcı Stripe sayfasında ödemeyi tamamlar.
- Stripe,
/api/payments/webhookendpoint’inecheckout.session.completedevent’i gönderir. - API,
Stripe-Signatureile event’i doğrular. - API metadata’dan
walletId+idempotencyKeyokur,DepositCommandçağırır. - Deposit handler ve DB unique index aynı işlemin tekrar yazılmasını engeller.
3) Frontend Tarafı: Idempotency-Key Üretimi ve Gönderimi
UI tarafında key üretimi yardımcı fonksiyonla yapılabilir:
1
2
3
4
5
6
7
8
// /src/shared/lib/idempotency.ts
export const generateIdempotencyKey = (): string => {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
Ödeme session çağrısında header’a ekleniyor:
1
2
3
4
5
6
7
8
9
10
11
// /src/shared/lib/create-session.ts
const idempotencyKey = generateIdempotencyKey();
await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/payments/create-session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey
},
body: JSON.stringify({ walletId, amount, currency })
});
Buradaki amaç, kullanıcı aynı eylemi tekrar etse bile backend’in bunu “aynı niyet” olarak tanıması.
4) Backend: Checkout Session Oluşturma ve Metadata Propagation
PaymentsController.CreateCheckoutSession içinde key header’dan okunuyor ve Stripe metadata’ya geçiriliyor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
[HttpPost("create-session")]
public IActionResult CreateCheckoutSession([FromBody] CheckoutRequest request, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey)
{
var metadata = new Dictionary<string, string>
{
{ "walletId", request.WalletId },
{ "amount", request.Amount.ToString() },
{ "currency", request.Currency }
};
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
metadata["idempotencyKey"] = idempotencyKey;
}
var options = new SessionCreateOptions
{
Mode = "payment",
SuccessUrl = _config["Stripe:SuccessUrl"],
CancelUrl = _config["Stripe:CancelUrl"],
Metadata = metadata,
PaymentIntentData = new SessionPaymentIntentDataOptions
{
Metadata = metadata
}
};
var service = new SessionService();
Session session;
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
session = service.Create(options, new RequestOptions { IdempotencyKey = idempotencyKey });
}
else
{
session = service.Create(options);
}
return Ok(new { sessionId = session.Id });
}
Burada iki ayrı güvenlik katmanı birlikte çalışıyor:
- Aynı key metadata’da taşındığı için webhook işleme sırasında business context, önceki HTTP isteğine bağımlı kalmadan yeniden oluşturulabilir.
- Aynı key
RequestOptions.IdempotencyKeyile Stripe API çağrısına da verildiği için,create-sessionisteğinin ağ retry senaryolarında Stripe tarafında da duplicate session üretme riski düşüyor.
Not: Webhook geldiğinde uygulama önceki HTTP request state’ine bağımlı kalmadan, ihtiyacı olan bilgiyi Stripe event metadata’sından okur.
5) Webhook Güvenliği: Signature Verification (Zorunlu)
Webhook endpoint’inde event doğrulaması yapılmadan işlem yapılmıyor:
1
2
3
4
5
6
7
8
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var secret = _config["Stripe:WebhookSecret"];
stripeEvent = Stripe.EventUtility.ConstructEvent(
json,
Request.Headers["Stripe-Signature"],
secret
);
Bu adım yoksa endpoint sahte isteklerle suistimal edilebilir. Doğrulama başarısızsa doğrudan BadRequest dönülebilir.
6) checkout.session.completed Event’i ile Deposit Entegrasyonu
Webhook event tipi checkout.session.completed olduğunda ödeme modunda şu işlem yapılıyor:
walletIdmetadata’dan alınıyoramountStripe session’dan okunuyor (AmountTotal / 100m)idempotencyKeymetadata’dan alınıyorDepositCommand(walletId, amount, idempotencyKey)çağrılıyor
Basitleştirilmiş akış:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (stripeEvent.Type == EventTypes.CheckoutSessionCompleted)
{
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
if (session?.Mode == "payment" && session.Metadata != null &&
session.Metadata.TryGetValue("walletId", out var walletIdStr) &&
Guid.TryParse(walletIdStr, out var walletGuid))
{
var amount = session.AmountTotal.HasValue ? session.AmountTotal.Value / 100m : 0m;
session.Metadata.TryGetValue("idempotencyKey", out var idempotencyKeyValue);
await _mediator.Send(new DepositCommand(walletGuid, amount, idempotencyKeyValue));
}
}
7) Uygulama Katmanı Idempotency: Handler Seviyesinde Tekrarı Kesme
Deposit handler, yazmadan önce önceki işlemi arıyor:
1
2
3
4
5
6
7
8
9
10
11
12
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
var existing = await _transactionRepository.GetByIdempotencyKeyAsync(
request.IdempotencyKey,
TransactionType.Deposit,
cancellationToken);
if (existing != null)
{
return true;
}
}
Kayıt yoksa wallet güncelleniyor ve transaction yazılıyor. Bu sayede aynı event ikinci kez işlenirse finansal etki tekrarlanmıyor.
8) DB Katmanı Idempotency: Race Condition Problemi
Sadece handler kontrolü yetmez; eşzamanlı isteklerde race condition olabilir. Bu nedenle migration ile (IdempotencyKey, Type) unique index ekleyebiliriz.
1
2
3
4
5
migrationBuilder.CreateIndex(
name: "IX_Transactions_IdempotencyKey_Type",
table: "Transactions",
columns: new[] { "IdempotencyKey", "Type" },
unique: true);
Bu, uygulama katmanı kaçırsa bile veritabanı seviyesinde atomic bir bariyer oluşturarak aynı işlemin ikinci kez sonuca etki etmesini engeller.
Sonuç
“Ödeme başarılı” demek yeterli değil; tek bir iş niyetinin tek bir durumu etkilemesi gerekir.
Idempotency key propagation , Webhook signature verification , Application check + DB unique index kombinasyonu çift işlem riskini ciddi biçimde düşürür.
