Introduction
Handling payments in production .NET services requires more than just calling an API—you need robust error handling, secure credential management, and reliable webhook processing. This guide walks you through Stripe with ASP.NET Core: a hands-on guide for engineers that covers the practical patterns we've learned running e-commerce and SaaS platforms at scale.
By the end of this article, you'll have a working integration that handles customer charges, processes webhooks safely, and queues events for reliable background processing. We'll focus on production-ready patterns rather than toy examples.
Stripe.NET — Official SDK with Typed Models
The Stripe.NET library is the official C# SDK and it saves you from manually parsing JSON and building HTTP requests. Install it via NuGet:
dotnet add package Stripe.netThe SDK provides strongly-typed models for every Stripe resource. When you create a charge, you're not working with anonymous objects—you get full IntelliSense and compile-time safety. Here's a practical example of charging a customer:
using Stripe;
var options = new ChargeCreateOptions
{
Amount = 2999, // $29.99 in cents
Currency = "usd",
Source = "tok_visa", // or customer ID
Description = "Order #12345",
Metadata = new Dictionary<string, string>
{
{ "order_id", "12345" },
{ "customer_id", "cust_abc123" }
}
};
var service = new ChargeService();
Charge charge = await service.CreateAsync(options);
if (charge.Status == "succeeded")
{
// Update your database
await _orderService.MarkAsPaidAsync(charge.Metadata["order_id"], charge.Id);
}
else
{
// Log failure
_logger.LogWarning("Charge failed: {ChargeId}", charge.Id);
}The typed models matter in production. You catch typos at compile time, not in production logs. The SDK also handles retry logic and connection pooling automatically—something you'd otherwise build yourself.
For recurring charges, use the Subscriptions API with typed models:
var subscriptionOptions = new SubscriptionCreateOptions
{
Customer = customerId,
Items = new List<SubscriptionItemOptions>
{
new SubscriptionItemOptions
{
Price = "price_monthly_pro", // Your price ID
}
},
PaymentBehavior = "default_incomplete",
OffSession = true
};
var subscriptionService = new SubscriptionService();
Subscription subscription = await subscriptionService.CreateAsync(subscriptionOptions);Options Pattern — Configure Keys with IOptions
Never hardcode your Stripe secret key. The Options pattern in ASP.NET Core gives you clean, testable configuration. Define a settings class:
public class StripeSettings
{
public string SecretKey { get; set; }
public string PublishableKey { get; set; }
public string WebhookSecret { get; set; }
}Register it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<StripeSettings>(
builder.Configuration.GetSection("Stripe"));
builder.Services.AddScoped<StripePaymentService>();
var app = builder.Build();In your appsettings.json:
{
"Stripe": {
"SecretKey": "sk_live_...",
"PublishableKey": "pk_live_...",
"WebhookSecret": "whsec_..."
}
}Now inject the options into your service:
public class StripePaymentService
{
private readonly IOptions<StripeSettings> _stripeOptions;
private readonly ILogger<StripePaymentService> _logger;
public StripePaymentService(
IOptions<StripeSettings> stripeOptions,
ILogger<StripePaymentService> logger)
{
_stripeOptions = stripeOptions;
_logger = logger;
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
}
public async Task<Charge> ChargeAsync(
decimal amount,
string sourceToken,
string orderId)
{
try
{
var chargeService = new ChargeService();
var options = new ChargeCreateOptions
{
Amount = (long)(amount * 100), // Convert to cents
Currency = "usd",
Source = sourceToken,
Metadata = new Dictionary<string, string>
{
{ "order_id", orderId }
}
};
return await chargeService.CreateAsync(options);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe charge failed for order {OrderId}", orderId);
throw;
}
}
}This pattern gives you environment-specific keys without touching code. In testing, you inject test keys; in production, you inject live keys. No secrets in your repository.
Webhook Endpoint — EventUtility.ConstructEvent with the Raw Body
Webhooks are how Stripe notifies you of events: charges succeeded, invoices failed, subscriptions ended. The critical detail is that you must read the raw request body to verify the signature. Never deserialize and then verify—always verify first.
Create a webhook controller:
[ApiController]
[Route("api/webhooks")]
public class StripeWebhookController : ControllerBase
{
private readonly IOptions<StripeSettings> _stripeOptions;
private readonly ILogger<StripeWebhookController> _logger;
private readonly IEventQueue _eventQueue;
public StripeWebhookController(
IOptions<StripeSettings> stripeOptions,
ILogger<StripeWebhookController> logger,
IEventQueue eventQueue)
{
_stripeOptions = stripeOptions;
_logger = logger;
_eventQueue = eventQueue;
}
[HttpPost("stripe")]
public async Task<IActionResult> HandleStripeWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
try
{
var stripeEvent = EventUtility.ConstructEvent(
json,
Request.Headers["Stripe-Signature"],
_stripeOptions.Value.WebhookSecret);
// Queue the event for processing
await _eventQueue.EnqueueAsync(new WebhookEventMessage
{
StripeEventId = stripeEvent.Id,
EventType = stripeEvent.Type,
DataJson = json
});
return Ok();
}
catch (StripeException ex)
{
_logger.LogError(ex, "Webhook signature verification failed");
return BadRequest();
}
catch (Exception ex)
{
_logger.LogError(ex, "Webhook processing error");
return StatusCode(500);
}
}
}Key points here: we read the raw body as a string, pass it to EventUtility.ConstructEvent along with the signature header, and let Stripe.NET verify the HMAC. If verification fails, an exception is thrown—we catch it and return 400 to signal Stripe not to retry.
If we return 200 but then queue the event for background processing, we acknowledge receipt immediately. This is safer than blocking the webhook endpoint on event handling.
Background Processing — Hosted Services or Queues for Event Handling
Processing webhook events synchronously in the HTTP request is risky. If your event handler crashes, Stripe retries indefinitely. If it's slow, you block the webhook endpoint. Instead, queue the event and process it in the background.
Define a simple queue abstraction:
public interface IEventQueue
{
Task EnqueueAsync(WebhookEventMessage message);
Task<WebhookEventMessage> DequeueAsync(CancellationToken ct);
}
public class WebhookEventMessage
{
public string StripeEventId { get; set; }
public string EventType { get; set; }
public string DataJson { get; set; }
}In production, you'd use Redis, RabbitMQ, or AWS SQS. For simplicity, here's an in-memory implementation for local development:
public class InMemoryEventQueue : IEventQueue
{
private readonly Channel<WebhookEventMessage> _channel;
public InMemoryEventQueue()
{
_channel = Channel.CreateUnbounded<WebhookEventMessage>();
}
public Task EnqueueAsync(WebhookEventMessage message)
{
return _channel.Writer.WriteAsync(message);
}
public async Task<WebhookEventMessage> DequeueAsync(CancellationToken ct)
{
return await _channel.Reader.ReadAsync(ct);
}
}Now create a hosted service to process events:
public class StripeWebhookProcessor : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IEventQueue _eventQueue;
private readonly ILogger<StripeWebhookProcessor> _logger;
public StripeWebhookProcessor(
IServiceProvider serviceProvider,
IEventQueue eventQueue,
ILogger<StripeWebhookProcessor> logger)
{
_serviceProvider = serviceProvider;
_eventQueue = eventQueue;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var message = await _eventQueue.DequeueAsync(stoppingToken);
await ProcessEventAsync(message, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing webhook");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
private async Task ProcessEventAsync(WebhookEventMessage message, CancellationToken ct)
{
using var scope = _serviceProvider.CreateScope();
var stripeEvent = JsonSerializer.Deserialize<Event>(message.DataJson);
switch (message.EventType)
{
case "charge.succeeded":
await HandleChargeSucceeded(stripeEvent, scope, ct);
break;
case "invoice.payment_failed":
await HandleInvoicePaymentFailed(stripeEvent, scope, ct);
break;
case "customer.subscription.deleted":
await HandleSubscriptionDeleted(stripeEvent, scope, ct);
break;
default:
_logger.LogInformation("Unhandled event type: {EventType}", message.EventType);
break;
}
}
private async Task HandleChargeSucceeded(Event stripeEvent, IServiceScope scope, CancellationToken ct)
{
var charge = stripeEvent.Data.Object as Charge;
var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>();
if (charge?.Metadata?.ContainsKey("order_id") == true)
{
var orderId = charge.Metadata["order_id"];
await orderService.MarkAsPaidAsync(orderId, charge.Id, ct);
_logger.LogInformation("Order {OrderId} marked as paid", orderId);
}
}
private async Task HandleInvoicePaymentFailed(Event stripeEvent, IServiceScope scope, CancellationToken ct)
{
var invoice = stripeEvent.Data.Object as Invoice;
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
await notificationService.SendFailedPaymentNoticeAsync(invoice.CustomerId, ct);
_logger.LogWarning("Payment failed for customer {CustomerId}", invoice.CustomerId);
}
private async Task HandleSubscriptionDeleted(Event stripeEvent, IServiceScope scope, CancellationToken ct)
{
var subscription = stripeEvent.Data.Object as Subscription;
var subscriptionService = scope.ServiceProvider.GetRequiredService<ISubscriptionService>();
await subscriptionService.CancelAsync(subscription.Id, ct);
_logger.LogInformation("Subscription {SubscriptionId} cancelled", subscription.Id);
}
}Register the hosted service in Program.cs:
builder.Services.AddSingleton<IEventQueue, InMemoryEventQueue>();
builder.Services.AddHostedService<StripeWebhookProcessor>();Now your webhook endpoint returns 200 immediately, and events are processed asynchronously. If processing fails, you can retry or dead-letter the message without blocking incoming webhooks.
Common Mistakes
- Ignoring Stripe exceptions: Don't catch and swallow
StripeException. Log it, but let it bubble up so you know charges failed. - Blocking the webhook endpoint: Process webhooks synchronously and your endpoint becomes a bottleneck. Always queue and process asynchronously.
- Deserializing before verifying signatures: Some engineers deserialize the JSON first, then verify. That's backwards—verify the signature on the raw body, then deserialize.
- Hardcoding amounts: Always work in cents (integers). Floating-point arithmetic on money causes off-by-one errors. Multiply by 100, round, and pass an integer to Stripe.
- Forgetting idempotency keys: For critical operations like charges, use idempotency keys so retries don't charge twice. Stripe.NET supports this via request options.
- Not testing webhook signatures locally: Use Stripe's CLI to forward webhooks to your local machine and verify your signature logic works before deploying.
Key Takeaways
- Use Stripe.NET SDK for type safety and built-in retry logic—don't roll your own HTTP calls.
- Configure credentials via the Options pattern; rotate secrets without touching code.
- Always verify webhook signatures on the raw body before deserializing.
- Queue webhook events and process them asynchronously with hosted services to avoid blocking and enable retries.
- Handle Stripe exceptions explicitly; log them and alert your team so you catch payment failures early.
- Work in cents (integers) to avoid floating-point precision issues with money.
- Test webhook logic locally with Stripe's CLI before shipping to production.






Comments
Loading comments…