Create a PaymentIntent by calling stripe.paymentIntents.create() on your server with an amount (in cents) and currency. Pass the returned client_secret to your frontend where Stripe.js confirms the payment securely. This gives you full control over the payment UI unlike hosted Checkout.
What Is a PaymentIntent and When Should You Use It?
A PaymentIntent represents a single payment attempt in Stripe. Unlike Checkout Sessions (which redirect to a Stripe-hosted page), PaymentIntents let you build a fully custom payment form using Stripe Elements. You create the PaymentIntent on your server, pass the client_secret to your frontend, and Stripe.js handles the rest — including 3D Secure and SCA compliance. Use PaymentIntents when you need full UI control over the checkout experience.
Prerequisites
- A Stripe account with API keys from Dashboard → Developers
- Node.js 18+ and the stripe npm package installed
- An Express server (or equivalent HTTP framework)
- Stripe.js loaded on your frontend (pk_test_ publishable key)
Step-by-step guide
Install dependencies
Install dependencies
Install the Stripe SDK for your server. You'll also need a way to serve your frontend and parse JSON request bodies.
1npm install stripe expressExpected result: Both packages installed successfully.
Create the PaymentIntent on your server
Create the PaymentIntent on your server
Call stripe.paymentIntents.create() with the amount in cents, the currency, and optionally automatic_payment_methods to let Stripe choose the best payment methods for the customer's location.
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);23app.post('/create-payment-intent', async (req, res) => {4 try {5 const { amount } = req.body; // amount in cents67 const paymentIntent = await stripe.paymentIntents.create({8 amount: amount,9 currency: 'usd',10 automatic_payment_methods: { enabled: true },11 metadata: { order_id: 'order_123' },12 });1314 // Send ONLY the client_secret to the frontend15 res.json({ clientSecret: paymentIntent.client_secret });16 } catch (err) {17 res.status(500).json({ error: err.message });18 }19});Expected result: The endpoint returns a JSON object with a clientSecret string like pi_xxx_secret_yyy.
Load Stripe.js on the frontend
Load Stripe.js on the frontend
Load Stripe.js using the publishable key (pk_test_). This is the only Stripe key safe to use on the frontend.
1<script src="https://js.stripe.com/v3/"></script>2<script>3 const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');4</script>Expected result: The Stripe object is available in your frontend code.
Fetch the client_secret and mount Elements
Fetch the client_secret and mount Elements
Call your server to create the PaymentIntent, then use the client_secret to initialize Stripe Elements. The PaymentElement renders an all-in-one payment form.
1const response = await fetch('/create-payment-intent', {2 method: 'POST',3 headers: { 'Content-Type': 'application/json' },4 body: JSON.stringify({ amount: 2000 }), // $20.005});6const { clientSecret } = await response.json();78const elements = stripe.elements({ clientSecret });9const paymentElement = elements.create('payment');10paymentElement.mount('#payment-element');Expected result: A payment form renders in the #payment-element div, showing card fields and any other enabled payment methods.
Test the PaymentIntent
Test the PaymentIntent
Use Stripe's test card 4242 4242 4242 4242 with any future expiry and any CVC. In your Stripe Dashboard (test mode), you'll see the PaymentIntent transition from requires_payment_method → succeeded.
1// Test card: 4242 4242 4242 42422// Expiry: 12/343// CVC: 123Expected result: The payment succeeds and the PaymentIntent status changes to 'succeeded' in the Stripe Dashboard.
Complete working example
1const express = require('express');2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);34const app = express();5app.use(express.static('public'));6app.use(express.json());78// Create a PaymentIntent9app.post('/create-payment-intent', async (req, res) => {10 try {11 const { amount, currency = 'usd' } = req.body;1213 if (!amount || amount < 50) {14 return res.status(400).json({ error: 'Amount must be at least 50 cents' });15 }1617 const paymentIntent = await stripe.paymentIntents.create({18 amount: Math.round(amount), // Ensure integer cents19 currency,20 automatic_payment_methods: { enabled: true },21 metadata: {22 integration: 'custom_payment_form',23 },24 });2526 res.json({ clientSecret: paymentIntent.client_secret });27 } catch (err) {28 console.error('PaymentIntent error:', err.message);29 res.status(500).json({ error: err.message });30 }31});3233// Check PaymentIntent status34app.get('/payment-status/:id', async (req, res) => {35 try {36 const paymentIntent = await stripe.paymentIntents.retrieve(req.params.id);37 res.json({38 status: paymentIntent.status,39 amount: paymentIntent.amount,40 currency: paymentIntent.currency,41 });42 } catch (err) {43 res.status(500).json({ error: err.message });44 }45});4647const PORT = process.env.PORT || 3000;48app.listen(PORT, () => console.log(`Server on port ${PORT}`));Common mistakes when creating a Payment Intent in Stripe
Why it's a problem: Sending the full PaymentIntent object to the frontend
How to avoid: Only send the client_secret. The full object contains sensitive data. The client_secret is designed to be safe for frontend use.
Why it's a problem: Creating the PaymentIntent on the frontend
How to avoid: PaymentIntents require your secret key (sk_). Always create them on your server and send only the client_secret to the client.
Why it's a problem: Using floating-point amounts instead of integer cents
How to avoid: Stripe expects amounts as integers in the smallest currency unit. Use 2000 for $20.00, not 20 or 20.00.
Why it's a problem: Forgetting to handle the requires_action status
How to avoid: Some payments require 3D Secure. Stripe.js handles this automatically when you use confirmPayment(), but you must handle the redirect flow.
Best practices
- Always create PaymentIntents server-side using your secret key (sk_test_ or sk_live_)
- Send only the client_secret to the frontend — never the full PaymentIntent object
- Use automatic_payment_methods: { enabled: true } to let Stripe enable the best methods
- Store the PaymentIntent ID in your database to track payment status
- Use metadata to attach your internal order ID or user ID to the PaymentIntent
- Validate the amount on your server — never trust client-submitted amounts without checking
- Set up a webhook for payment_intent.succeeded rather than polling for status
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a Node.js Express server that creates a Stripe PaymentIntent for a given amount in cents. Return only the client_secret. Then show the frontend code to mount a PaymentElement using that client_secret and confirm the payment.
Add a Stripe payment flow to my app. Create a server endpoint POST /create-payment-intent that accepts an amount, creates a Stripe PaymentIntent, and returns the client_secret. On the frontend, mount a Stripe PaymentElement and handle payment confirmation.
Frequently asked questions
What's the difference between a PaymentIntent and a Checkout Session?
A Checkout Session redirects to a Stripe-hosted payment page. A PaymentIntent lets you build a custom payment form on your own site using Stripe Elements. Use Checkout for speed, PaymentIntents for full UI control.
What is the minimum amount for a PaymentIntent?
The minimum is $0.50 USD (50 cents) or equivalent in other currencies. Stripe will reject amounts below this.
Can I update a PaymentIntent after creating it?
Yes. Call stripe.paymentIntents.update(id, { amount: newAmount }) on your server before the customer confirms payment. You cannot update a PaymentIntent that has already succeeded.
How do I handle 3D Secure authentication?
Stripe.js handles 3D Secure automatically when you use stripe.confirmPayment(). It opens the bank's authentication modal and resolves the promise when the customer completes it.
Is it safe to expose the client_secret?
Yes. The client_secret can only confirm the specific PaymentIntent it belongs to. It cannot create new charges, access your account, or read other data. It's designed for frontend use.
Can RapidDev help with a custom PaymentIntent integration?
Yes. RapidDev can help you build custom payment flows including PaymentIntents, saved cards, subscriptions, and multi-party payments. This is especially useful for complex checkout experiences.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation