Stripe in C#, part 2: the PaymentIntent lifecycle — server, client, and 3DS without fear
Part 2from the Stripe in C# series · 6 parts in all
The PaymentIntent is a small state machine, and 90% of Stripe integration bugs are misreadings of it. Part 2 walks the full lifecycle with server-side C# and the thin client-side piece that only the browser can do.
The states, in order
requires_payment_method → requires_confirmation →
requires_action (3DS challenge!) → processing →
succeeded (or canceled). The one people miss is
requires_action: with European cards the payment is not failed and not
succeeded — it is paused, waiting for the customer to complete a bank challenge in their
browser. Your UI must handle that third outcome.
Server: create and (optionally) confirm
using Stripe;
using Stripe.Checkout; // not yet needed here, but this is the modern namespace
var intentService = new PaymentIntentService();
// 1. Create the intent when the customer clicks "Pay". Amount lives on the
// SERVER - never trust a total sent up from the browser.
var intent = intentService.Create(new PaymentIntentCreateOptions
{
Amount = 4599,
Currency = "usd",
// In 2020: automatic_payment_methods is emerging; card-first integrations
// still list payment method types explicitly.
PaymentMethodTypes = new List<string> { "card" },
Metadata = new Dictionary<string, string> { { "orderId", "1042" } },
});
// 2. Hand ONLY the client secret to the browser. It is scoped to this one
// intent and safe to expose; the sk_test key never leaves the server.
return Ok(new { clientSecret = intent.ClientSecret });
Client: Stripe.js owns the card data
Card numbers must never touch your server (that is how PCI scope stays minimal). Stripe.js sends the payment method straight to Stripe and confirms the intent:
const stripe = Stripe('pk_test_...');
const result = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement } // Element = card fields Stripe hosts
});
if (result.error) { show(result.error.message); } // declined, etc.
else if (result.paymentIntent.status === 'succeeded') { done(); }
// A status of 'requires_action' already resolved itself here: Stripe.js
// popped the 3DS modal and the promise resolves after the customer finishes.
Server-side confirmation for headless flows
// Mail-order or API-driven payment: confirm on the server with raw card
// details (requires PCI SS-AOEP attestation) or, better, with a tokenized
// payment method created via Stripe Elements or the mobile SDKs.
var confirmed = intentService.Confirm(paymentIntentId, new PaymentIntentConfirmOptions
{
PaymentMethod = "pm_card_visa", // test payment method id
});
if (confirmed.Status == "requires_action")
{
// Server-side there is no modal: hand back the next_action redirect URL
// (3DS1) or use the SDK's handleNextAction equivalent client-side.
return Ok(new { nextAction = confirmed.NextAction.RedirectToUrl.Url });
}
if (confirmed.Status == "succeeded") { FulfillOrder("1042"); }
One more 2020 habit worth forming: do not fulfill the order in the confirm response path. The confirm call can time out after Stripe accepted the payment. The only reliable "money moved" signal is the webhook, which is exactly where part 6 lives. Next: customers, saved cards, and charging people while they sleep.