Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 006 / 100

Stripe PaymentIntent tutorial: best practices explained

The core object for accepting payments.

Expona Admin10 min read

Most payment failures in production happen not because Stripe is unreliable, but because teams misunderstand who owns each step of the payment flow. A Stripe PaymentIntent tutorial: best practices explained cuts through that confusion by anchoring your entire payment lifecycle to one critical principle: the server controls the money, the client handles the interface, and webhooks are your source of truth.

In this guide, we'll walk through the architecture that keeps payment data secure, handles edge cases like 3D Secure, and ensures every transaction either succeeds or fails with full accountability.

What is a PaymentIntent and why does it matter?

A PaymentIntent is Stripe's unified object for managing a payment lifecycle from creation through confirmation and settlement. It replaces the fragmented world of tokens and charges with a single resource that tracks every state your payment passes through. Instead of your client creating charges directly, you create an intent on your server, pass an identifier to the client, and then confirm it with payment method details.

This separation of concerns is not bureaucracy—it's security. Your server never sees the raw card number. Your client never knows the order amount until the server tells it to. And if a network fails mid-transaction, the intent persists so you can retry without double-charging.

Create on the server — Amount and currency come from your server, never the client

The first and most critical rule: always create your PaymentIntent on your server. This is where your order, inventory, and authorization logic lives. Your client sends back an order ID and payment method type. Your server responds with an intent and client secret.

Never, ever accept amount or currency from the client request. A malicious actor can intercept a network call, change the amount from 2500 to 250, and you've just given away goods for 90% off. Your server must look up the order, calculate the total (including tax and fees if applicable), and embed that amount into the intent.

Here's the typical flow:

typescript

// Backend: Node.js with Stripe SDK
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post('/api/create-payment-intent', async (req, res) => {
  const { orderId } = req.body;

  // Fetch order from your database
  const order = await db.orders.findById(orderId);
  if (!order) return res.status(404).json({ error: 'Order not found' });

  // Verify order belongs to authenticated user
  if (order.userId !== req.user.id) {
    return res.status(403).json({ error: 'Unauthorized' });
  }

  // Calculate amount from your database
  const amountCents = order.totalPrice * 100; // $25.00 = 2500 cents
  const currency = order.currency || 'usd';

  try {
    const intent = await stripe.paymentIntents.create({
      amount: amountCents,
      currency: currency,
      automatic_payment_methods: {
        enabled: true, // Accepts cards, wallets, bank transfers based on currency
      },
      metadata: {
        orderId: order.id,
        userId: req.user.id,
      },
      description: `Order #${order.id}`,
      statement_descriptor: 'YourCompany Order', // Shows on bank statement
    });

    res.json({
      clientSecret: intent.client_secret,
      intentId: intent.id,
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

Notice a few details here. The automatic_payment_methods flag tells Stripe to accept all available methods for your currency and region—cards, Apple Pay, Google Pay, bank transfers. You're not locking yourself to cards alone. The metadata field stores your order ID and user ID so that when you receive a webhook later, you know exactly which order succeeded. The statement_descriptor is what appears on the customer's bank statement—keep it short and recognizable.

Your client receives only the clientSecret and intentId. It has no idea what the amount was. It will send this secret to Stripe.js, which will perform the actual payment confirmation.

Confirm on the client — Stripe.js handles card details and 3D Secure

Your frontend now has the client secret. Its job is to collect payment details (either a card form or let the user tap Apple Pay) and confirm the intent with Stripe.js.

typescript

// Frontend: React example
import { loadStripe } from '@stripe/js';
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';

const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);

function PaymentForm({ clientSecret, orderId }) {
  const stripe = useStripe();
  const elements = useElements();
  const [processing, setProcessing] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    setProcessing(true);

    const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
      payment_method: {
        card: elements.getElement(CardElement),
        billing_details: {
          email: 'customer@example.com',
        },
      },
    });

    if (error) {
      setError(error.message);
      setProcessing(false);
    } else if (paymentIntent.status === 'succeeded') {
      // Success! But don't fulfill yet—wait for webhook.
      console.log('PaymentIntent confirmed:', paymentIntent.id);
      setProcessing(false);
    } else if (paymentIntent.status === 'requires_action') {
      // 3D Secure or SCA challenge needed
      console.log('Customer authentication required');
    }
  };

  return (
    
      
      
        {processing ? 'Processing...' : 'Pay'}
      
      {error && <p>{error}</p>}
    
  );
}

export default function CheckoutPage({ orderId }) {
  const [clientSecret, setClientSecret] = useState(null);

  useEffect(() => {
    fetch('/api/create-payment-intent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ orderId }),
    })
      .then((res) => res.json())
      .then((data) => setClientSecret(data.clientSecret));
  }, [orderId]);

  if (!clientSecret) return <div>Loading...</div>;

  return (
    
      
    
  );
}

The confirmCardPayment method (or the newer confirmPayment in Stripe.js v3) sends the card details directly to Stripe's servers. Your backend never touches them. Stripe handles 3D Secure challenges if they're required—the customer may be redirected to their bank's app, and Stripe will poll or wait for the result automatically.

The key insight: when confirmCardPayment returns, the PaymentIntent has usually been confirmed, but its status might be requires_action (waiting for 3D Secure), processing (still being verified), or succeeded (done). You should not fulfill the order based on the client response alone.

Track status — requires_action, processing, succeeded

A PaymentIntent transitions through several states. Understanding each one prevents you from shipping goods before payment is actually guaranteed.

  • requires_payment_method: The intent was created but no payment method has been attached yet.
  • requires_confirmation: Rare; means your server confirmed the intent but it needs additional client-side confirmation.
  • requires_action: The customer must complete a challenge, usually 3D Secure. Stripe will handle the redirect or modal automatically if you use Stripe.js correctly.
  • processing: Payment method has been confirmed and Stripe is waiting for the card network or bank to settle.
  • succeeded: Payment has been approved and funds will reach your account (after hold periods). This is your reliable signal to fulfill.
  • canceled: The intent was explicitly canceled (either by you or due to failure after retries).

You can inspect the status of an intent at any time:

typescript

// Check status server-side
const intent = await stripe.paymentIntents.retrieve(intentId);
console.log('Status:', intent.status); // 'succeeded', 'processing', etc.

if (intent.status === 'succeeded') {
  await db.orders.updateOne(
    { _id: intent.metadata.orderId },
    { status: 'payment_confirmed', paymentIntentId: intent.id }
  );
}

Fulfill via webhook — payment_intent.succeeded is the source of truth

This is where many teams go wrong. They fulfill orders when the client says "success" or when the status endpoint returns "succeeded" in a polling loop. That works until a network partition separates your client from Stripe, or your server crashes mid-fulfillment. Then you're trying to reconcile what actually happened.

Instead, rely on webhooks. Stripe will send you a payment_intent.succeeded event for every successful payment, with automatic retries if your endpoint times out. Webhooks are idempotent: if you receive the same event twice, you can safely process it twice without side effects.

Set up your webhook endpoint:

typescript

// Backend: Webhook listener
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post('/api/stripe-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (error) {
    console.error('Webhook signature verification failed:', error.message);
    return res.sendStatus(400);
  }

  // Handle only the events you care about
  if (event.type === 'payment_intent.succeeded') {
    const paymentIntent = event.data.object;
    const orderId = paymentIntent.metadata.orderId;

    try {
      // Idempotent: check if order is already fulfilled
      const order = await db.orders.findById(orderId);
      if (order.status === 'fulfilled') {
        console.log('Order already fulfilled, skipping');
        return res.sendStatus(200);
      }

      // Mark order as fulfilled
      await db.orders.updateOne(
        { _id: orderId },
        {
          status: 'fulfilled',
          paymentIntentId: paymentIntent.id,
          fulfilledAt: new Date(),
        }
      );

      // Trigger fulfillment pipeline (email, inventory deduction, shipping, etc.)
      await fulfillOrder(orderId);

      console.log(`Order ${orderId} fulfilled via webhook`);
    } catch (error) {
      console.error('Error fulfilling order:', error);
      // Return 500 so Stripe retries
      return res.sendStatus(500);
    }
  }

  // Acknowledge receipt
  res.sendStatus(200);
});

Register this endpoint in your Stripe Dashboard under Developers > Webhooks. Listen for at least payment_intent.succeeded, but also consider payment_intent.payment_failed (for sending failure notifications to customers) and charge.refunded (for refund handling).

The webhook runs in isolation from your client. Even if the customer closes their browser after hitting "Pay," the webhook will still fire and your order will be fulfilled. This is the resilience you want in production.

Common mistakes

Accepting amount from the client: Always compute it server-side. No exceptions.

Fulfilling before the webhook: Polling the status endpoint works in development but fails under real-world conditions. Use webhooks exclusively.

Forgetting to verify the webhook signature: Anyone can send a fake webhook event to your endpoint. Always verify using stripe.webhooks.constructEvent. Your webhook secret is not a password—it's authentication.

Not handling idempotency: Process your webhook such that receiving the same event twice results in the same state (same database changes). A simple check like "is this order already fulfilled?" prevents duplicate fulfillments.

Hardcoding statement descriptors or not including metadata: Make your payments traceable. Use metadata to link payments back to your orders. Customers reference bank statement descriptions when disputing charges.

Missing 3D Secure handling: If you use automatic_payment_methods, some cards will trigger 3D Secure. Your client code must handle requires_action status. Stripe.js does this automatically if you use the right methods, but verify in testing.

Key takeaways

  • Create PaymentIntent on your server with amount and currency from your database, never from the client.
  • Confirm the intent on the client using Stripe.js; it handles card collection and 3D Secure automatically.
  • Monitor intent status, but understand that requires_action and processing are transient states.
  • Fulfill orders only via the payment_intent.succeeded webhook event, not client responses or polling.
  • Always verify webhook signatures and implement idempotency to survive duplicate delivery.
  • Use metadata to link payments to your business objects so debugging and reconciliation are straightforward.
  • Enable automatic_payment_methods to accept all available payment types for your currency region.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles