Back to Blog
StripeReact.NETPayments

Three ways to integrate Stripe payments (and when to use each)

·6 min read

Stripe gives you several ways to accept payments. Which one you pick changes how much work you do, how much control you keep, and whether you ever need to think about PCI compliance. This post walks through the three main approaches using a real coffee shop e-commerce app built with React and .NET as the reference.


The two keys you need to know first

Before picking an integration approach, you need to understand the two Stripe API keys and why they are treated differently.

Publishable key (pk_test_... / pk_live_...)
This key is loaded in your frontend JavaScript. It is intentionally public. Anyone who opens DevTools on your site can read it, and that is fine. The publishable key can only be used to tokenise card data and initialise Stripe.js. It cannot create charges, issue refunds, or access customer data.

Secret key (sk_test_... / sk_live_...)
This key lives on your server only. It can do everything: create charges, retrieve customer records, issue refunds. If it leaks, someone can drain your Stripe account. It must never appear in frontend code, browser requests, or version control.

CODE
Frontend bundle  →  pk_test_...  ✅ safe to expose
Server env var   →  sk_test_...  ❌ never leave the server

Approach 1: PaymentIntent with Stripe Elements (frontend as middleman)

This is the approach most apps should start with. Your frontend handles the UX, coordinates the flow, and passes the result to your backend.

The card never touches your server. The user fills in a Stripe-hosted iframe embedded on your page. Your backend only ever sees a PaymentIntent ID, which it independently verifies with Stripe before creating the order.

Why verify server-side? A malicious user could skip confirmCardPayment and send any string as the paymentIntentId. Your backend calling GET /v1/payment_intents/:id and checking status === "succeeded" closes that hole.

TypeScript
// Frontend: confirm payment then send ID to backend
const { paymentIntent, error } = await stripe.confirmCardPayment(clientSecret, {
  payment_method: { card: cardElement }
})

if (paymentIntent?.status === 'succeeded') {
  await createOrder({ payment: { paymentIntentId: paymentIntent.id } })
}
CSHARP
// Backend: verify before trusting
var result = await _stripe.VerifyPaymentIntentAsync(req.Payment.PaymentIntentId);
if (!result.IsSucceeded)
    return BadRequest(new { message = "Payment has not been confirmed." });

Pros

  • Simple architecture, no infrastructure beyond your API
  • Full control over the payment UI
  • PCI compliant: card data goes iframe → Stripe, your server never sees it
  • Works immediately with no public URL required

Cons

  • If the user closes the browser after confirmCardPayment but before POST /api/orders, you have a charged card with no order record
  • You are relying on the frontend to complete the flow

Approach 2: Webhook-driven (production standard)

Same card flow as Approach 1, but the backend acts on a Stripe event rather than a frontend request. This is the correct approach for production.

The key difference: the backend creates a pending order at PaymentIntent creation time, then a Stripe webhook fires payment_intent.succeeded regardless of what happens in the browser. The order gets updated even if the user never loads the confirmation page.

Pros

  • Reliable: no lost orders when the browser closes mid-flow
  • Industry standard for any real e-commerce system
  • Stripe retries webhook delivery if your server is briefly down

Cons

  • Requires a publicly accessible URL (localhost needs Stripe CLI tunnel for local dev)
  • More state to manage: orders have a Pending status until the webhook fires
  • Slightly more complex to test end-to-end

Approach 3: Stripe Checkout (full redirect)

Stripe hosts the entire payment page. You create a Checkout Session on your backend, redirect the user to checkout.stripe.com, and Stripe sends them back to your success_url after payment.

There is no card iframe to embed. Stripe renders and handles the entire payment experience.

Pros

  • Fastest to implement, almost no frontend work
  • Stripe handles all UI, validation, error messages, and 3DS
  • Automatic support for Apple Pay, Google Pay, and 40+ payment methods

Cons

  • User leaves your site, which can hurt conversion
  • No control over the payment page design
  • Requires webhook handling to confirm orders (same as Approach 2)

Comparison

PaymentIntent + ElementsWebhook-drivenStripe Checkout
User leaves your siteNoNoYes
Handles browser close gracefullyNoYesYes (via webhook)
Infrastructure neededNonePublic URLPublic URL
PCI scopeMinimalMinimalNone
Implementation effortMediumHighLow
UI controlFullFullNone
Best forDemos, MVPsProductionQuick integrations

What the coffee shop app uses

The app uses Approach 1 with a backend verification step. For a portfolio project, the simplicity is worth the trade-off. The IStripePaymentService interface in the .NET backend makes the Stripe calls mockable in integration tests, so the test suite never hits Stripe's API directly.

For a real store, Approach 1 combined with a webhook listener for payment_intent.succeeded would be the right combination: you get the clean embedded UI from Elements, and you get the reliability guarantee from webhooks.