Payments belong on the server—always
Frontend SDKs collect card details or UPI intents, but your ASP.NET Core backend owns order state, amount verification, and webhook trust. Teams get burned when they trust client-side success callbacks, skip idempotency keys, or store raw card data in SQL. This guide walks through integrating Stripe (global cards, subscriptions) and Cashfree (India-focused UPI, netbanking, popular wallets) from a single .NET API with a provider abstraction.
Domain model before SDK calls
public class PaymentOrder
{
public Guid Id { get; set; }
public string IdempotencyKey { get; set; } = default!;
public decimal AmountInr { get; set; }
public string Currency { get; set; } = "INR";
public PaymentProvider Provider { get; set; }
public PaymentStatus Status { get; set; }
public string? ProviderOrderId { get; set; }
public string? ProviderPaymentId { get; set; }
}
Statuses: Created, Pending, Paid, Failed, Refunded. Transitions only from webhooks or verified status API—never from the browser alone.
Stripe: Payment Intents from ASP.NET Core
public class StripePaymentService : IPaymentGateway
{
public async Task<CheckoutSessionDto> CreateCheckoutAsync(PaymentOrder order, CancellationToken ct)
{
StripeConfiguration.ApiKey = _config["Stripe:SecretKey"];
var options = new PaymentIntentCreateOptions
{
Amount = (long)(order.AmountInr * 100), // paise for INR
Currency = order.Currency.ToLowerInvariant(),
Metadata = new Dictionary<string, string>
{
["internal_order_id"] = order.Id.ToString()
}
};
var service = new PaymentIntentService();
var intent = await service.CreateAsync(options,
new RequestOptions { IdempotencyKey = order.IdempotencyKey }, cancellationToken: ct);
return new CheckoutSessionDto(intent.ClientSecret, intent.Id);
}
}
React or mobile uses clientSecret with Stripe.js—card data touches Stripe, not your disk. Store Stripe secret in Key Vault.
Cashfree: order create and session
Cashfree PG REST API flow: create order server-side, return payment session id to frontend Cashfree JS SDK. Example HTTP call pattern:
var payload = new
{
order_id = order.Id.ToString("N"),
order_amount = order.AmountInr,
order_currency = "INR",
customer_details = new { customer_id = userId, customer_email = email }
};
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.cashfree.com/pg/orders");
request.Headers.Add("x-api-version", "2023-08-01");
request.Headers.Add("x-client-id", _config["Cashfree:AppId"]);
request.Headers.Add("x-client-secret", _config["Cashfree:SecretKey"]);
request.Content = JsonContent.Create(payload);
var response = await _http.SendAsync(request, ct);
Use sandbox endpoints and keys in development. Map returned payment_session_id to your checkout UI.
Unified controller surface
[Authorize]
[ApiController]
[Route("api/payments")]
public class PaymentsController : ControllerBase
{
[HttpPost("checkout")]
public async Task<ActionResult<CheckoutSessionDto>> CreateCheckout(CreateCheckoutRequest req)
{
var userId = User.GetUserId();
var order = await _orders.CreateAsync(userId, req.CourseId, req.IdempotencyKey);
var gateway = _gatewayFactory.Resolve(order.Provider);
var session = await gateway.CreateCheckoutAsync(order, HttpContext.RequestAborted);
return Ok(session);
}
}
Webhooks: the source of truth
Stripe signs payloads with Stripe-Signature; Cashfree sends signature headers per their docs. Verify before any state change.
[HttpPost("webhooks/stripe")]
[AllowAnonymous]
public async Task<IActionResult> StripeWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"];
try
{
var stripeEvent = EventUtility.ConstructEvent(json, signature, _config["Stripe:WebhookSecret"]);
if (stripeEvent.Type == Events.PaymentIntentSucceeded)
{
var intent = (PaymentIntent)stripeEvent.Data.Object;
await _orders.MarkPaidAsync(intent.Metadata["internal_order_id"], intent.Id);
}
}
catch (StripeException) { return BadRequest(); }
return Ok();
}
Return 200 quickly; heavy work (enrollment emails) via queue. Store processed event ids to prevent duplicate webhook delivery from double-charging access.
Idempotency and race conditions
Clients generate Idempotency-Key per checkout attempt. Database unique constraint on IdempotencyKey. Webhook handler checks current status before transition Paid to Paid. Refunds get their own idempotency keys.
Amount tampering
Never accept final amount from the client. Compute price from database course row + tax rules + coupon validation server-side. Compare webhook amount to stored order; mismatch triggers alert and manual review.
Subscriptions and LMS access
Stripe Billing for monthly plans: listen to invoice.paid and customer.subscription.deleted. Cashfree subscriptions where available—mirror events into your entitlements table. Grant course access in a single transaction with payment confirmation row.
Compliance and operations
- PCI: SAQ A when using hosted fields / redirect flows only.
- GST invoices: generate after Paid; store provider receipt ids.
- Reconciliation: nightly job compares provider settlement reports to internal ledger.
- Secrets rotation without downtime: dual-read keys during cutover.
Testing
Stripe CLI stripe listen --forward-to localhost. Cashfree sandbox webhooks via dashboard. Integration tests mock HTTP handlers; do not hit live APIs in CI.
AI checkout support (optional)
Chatbots explaining payment failure codes should call a read-only FAQ endpoint—not live payment APIs. If using AI to classify support tickets, redact card numbers and UPI VPA from logs before sending to models.
Integrating Cashfree and Stripe from ASP.NET Core is straightforward when webhooks drive state, amounts are server-computed, and idempotency is database-enforced. Build the abstraction once; your LMS or SaaS product can switch providers per region without rewriting business logic.