← All insights

Implementing Stripe Payments: A Complete Guide

Stripe has become the de facto standard for online payments, offering a comprehensive platform that handles everything from simple one-time payments to complex subscription billing. This guide walks you through implementing Stripe payments in your web application, covering best practices, security considerations, and real-world code examples.

Why Choose Stripe?

  • Developer-Friendly: Excellent documentation and APIs
  • Global Support: Accepts payments from 195+ countries
  • Security: PCI compliance handled for you
  • Flexible: One-time payments, subscriptions, marketplace features
  • No Monthly Fees: Pay only per transaction (2.9% + 30¢)
  • Built-in Features: Fraud detection, billing portal, tax calculation

Getting Started: Initial Setup

1. Create a Stripe Account

Sign up at stripe.com and get your API keys. You'll have test keys for development and live keys for production.

2. Install Stripe SDK

# For Node.js backend
npm install stripe

# For frontend (Stripe.js)
npm install @stripe/stripe-js

# For React (optional)
npm install @stripe/react-stripe-js

3. Initialize Stripe

// Backend (Node.js)
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2023-10-16',
});

// Frontend
import { loadStripe } from '@stripe/stripe-js';

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

Security Note

Never expose your secret key! Use environment variables and only use the publishable key on the frontend. Your secret key should only exist on your backend server.

Implementing One-Time Payments

Payment Flow Overview

  1. Customer enters payment details on your frontend
  2. Frontend creates a PaymentIntent on your backend
  3. Backend returns client secret to frontend
  4. Frontend confirms payment with Stripe
  5. Webhook notifies your backend of payment success

Backend: Create Payment Intent

// pages/api/create-payment-intent.js
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const { amount, currency = 'usd', metadata = {} } = req.body;

    // Create a PaymentIntent
    const paymentIntent = await stripe.paymentIntents.create({
      amount: amount * 100, // Convert to cents
      currency,
      metadata, // Store custom data
      automatic_payment_methods: {
        enabled: true,
      },
    });

    res.status(200).json({
      clientSecret: paymentIntent.client_secret,
    });
  } catch (error) {
    console.error('Payment Intent Error:', error);
    res.status(500).json({ error: error.message });
  }
}

Frontend: Payment Form with Stripe Elements

// components/CheckoutForm.jsx
import React, { useState } from 'react';
import {
  PaymentElement,
  useStripe,
  useElements
} from '@stripe/react-stripe-js';

export default function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [error, setError] = useState(null);
  const [processing, setProcessing] = useState(false);

  const handleSubmit = async (event) => {
    event.preventDefault();

    if (!stripe || !elements) {
      return;
    }

    setProcessing(true);

    // Confirm the payment
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/payment-success`,
      },
    });

    if (error) {
      setError(error.message);
      setProcessing(false);
    }
    // If successful, user will be redirected to return_url
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      {error && <div className="error">{error}</div>}
      <button disabled={!stripe || processing}>
        {processing ? 'Processing...' : 'Pay Now'}
      </button>
    </form>
  );
}

Complete Checkout Page

// pages/checkout.jsx
import { useEffect, useState } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import CheckoutForm from '../components/CheckoutForm';

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

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

  useEffect(() => {
    // Create PaymentIntent on mount
    fetch('/api/create-payment-intent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        amount: 50, // $50.00
        metadata: {
          orderId: '12345',
          userId: 'user_abc',
        },
      }),
    })
      .then((res) => res.json())
      .then((data) => setClientSecret(data.clientSecret));
  }, []);

  return (
    <div>
      <h1>Checkout</h1>
      {clientSecret && (
        <Elements stripe={stripePromise} options={{ clientSecret }}>
          <CheckoutForm />
        </Elements>
      )}
    </div>
  );
}

Implementing Subscriptions

Subscription Flow

  1. Create products and prices in Stripe Dashboard
  2. Create a customer in Stripe
  3. Create a subscription with payment method
  4. Listen to subscription events via webhooks

Backend: Create Subscription

// pages/api/create-subscription.js
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const { email, priceId, paymentMethodId } = req.body;

    // 1. Create or retrieve customer
    const customer = await stripe.customers.create({
      email,
      payment_method: paymentMethodId,
      invoice_settings: {
        default_payment_method: paymentMethodId,
      },
    });

    // 2. Create subscription
    const subscription = await stripe.subscriptions.create({
      customer: customer.id,
      items: [{ price: priceId }],
      payment_settings: {
        payment_method_types: ['card'],
        save_default_payment_method: 'on_subscription',
      },
      expand: ['latest_invoice.payment_intent'],
    });

    res.status(200).json({
      subscriptionId: subscription.id,
      clientSecret: subscription.latest_invoice.payment_intent.client_secret,
    });
  } catch (error) {
    console.error('Subscription Error:', error);
    res.status(500).json({ error: error.message });
  }
}

Managing Subscription Tiers

// pages/api/change-subscription.js
export default async function handler(req, res) {
  const { subscriptionId, newPriceId } = req.body;

  try {
    // Get current subscription
    const subscription = await stripe.subscriptions.retrieve(subscriptionId);

    // Update to new price
    const updatedSubscription = await stripe.subscriptions.update(
      subscriptionId,
      {
        items: [{
          id: subscription.items.data[0].id,
          price: newPriceId,
        }],
        proration_behavior: 'create_prorations', // Pro-rate the difference
      }
    );

    res.status(200).json(updatedSubscription);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}

Cancel Subscription

// pages/api/cancel-subscription.js
export default async function handler(req, res) {
  const { subscriptionId, cancelAtPeriodEnd = true } = req.body;

  try {
    const subscription = await stripe.subscriptions.update(subscriptionId, {
      cancel_at_period_end: cancelAtPeriodEnd,
    });

    // Or cancel immediately:
    // const subscription = await stripe.subscriptions.cancel(subscriptionId);

    res.status(200).json(subscription);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}

Webhook Implementation

Webhooks are critical for receiving real-time updates about payments, subscriptions, and disputes. Never rely solely on client-side success notifications.

Setting Up Webhooks

// pages/api/webhooks/stripe.js
import Stripe from 'stripe';
import { buffer } from 'micro';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

// Disable body parsing for webhooks
export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const buf = await buffer(req);
  const sig = req.headers['stripe-signature'];

  let event;

  try {
    // Verify webhook signature
    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle different event types
  switch (event.type) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(event.data.object);
      break;

    case 'payment_intent.payment_failed':
      await handlePaymentFailure(event.data.object);
      break;

    case 'customer.subscription.created':
      await handleSubscriptionCreated(event.data.object);
      break;

    case 'customer.subscription.updated':
      await handleSubscriptionUpdated(event.data.object);
      break;

    case 'customer.subscription.deleted':
      await handleSubscriptionCanceled(event.data.object);
      break;

    case 'invoice.payment_succeeded':
      await handleInvoicePayment(event.data.object);
      break;

    case 'invoice.payment_failed':
      await handleInvoiceFailure(event.data.object);
      break;

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  res.status(200).json({ received: true });
}

async function handlePaymentSuccess(paymentIntent) {
  console.log('Payment succeeded:', paymentIntent.id);

  // Update your database
  // Send confirmation email
  // Fulfill the order
}

async function handleSubscriptionCreated(subscription) {
  console.log('Subscription created:', subscription.id);

  // Update user's subscription status in database
  // Grant access to premium features
  // Send welcome email
}

async function handleInvoicePayment(invoice) {
  console.log('Invoice paid:', invoice.id);

  // Extend subscription period
  // Send receipt email
}

Webhook Testing

Use the Stripe CLI to test webhooks locally:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

Security Best Practices

1. Never Trust the Client

Always verify payment status on your backend using webhooks. Clients can manipulate frontend code.

// DON'T DO THIS
// Frontend (insecure)
const handleSuccess = () => {
  // Client could fake this!
  grantPremiumAccess();
};

// DO THIS INSTEAD
// Backend webhook (secure)
async function handlePaymentSuccess(paymentIntent) {
  // Verify payment in webhook
  await grantPremiumAccess(paymentIntent.metadata.userId);
}

2. Use Idempotency Keys

// Prevent duplicate charges
const paymentIntent = await stripe.paymentIntents.create({
  amount: 5000,
  currency: 'usd',
}, {
  idempotencyKey: `order_${orderId}`,
});

3. Validate Webhook Signatures

Always verify that webhook events came from Stripe using the signature verification shown earlier.

4. Store Minimal Card Data

Never store card numbers or CVV codes. Use Stripe's tokens and customer IDs instead.

5. Implement Rate Limiting

// Example with express-rate-limit
import rateLimit from 'express-rate-limit';

const paymentLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // Limit each IP to 5 requests per window
  message: 'Too many payment attempts, please try again later',
});

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

Handling Edge Cases

Failed Payments

// Retry failed payments with exponential backoff
async function retryFailedPayment(paymentIntentId) {
  try {
    const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);

    if (paymentIntent.status === 'requires_payment_method') {
      // Notify customer to update payment method
      await sendPaymentFailureEmail(paymentIntent.customer);
    }
  } catch (error) {
    console.error('Retry failed:', error);
  }
}

Disputed Charges

// Handle disputes via webhook
case 'charge.dispute.created':
  const dispute = event.data.object;

  // Gather evidence
  await stripe.disputes.update(dispute.id, {
    evidence: {
      customer_name: 'John Doe',
      customer_signature: 'https://example.com/signature.png',
      receipt: 'https://example.com/receipt.pdf',
    },
  });
  break;

Refunds

// pages/api/refund.js
export default async function handler(req, res) {
  const { paymentIntentId, amount, reason } = req.body;

  try {
    const refund = await stripe.refunds.create({
      payment_intent: paymentIntentId,
      amount, // Optional: partial refund
      reason, // 'duplicate', 'fraudulent', or 'requested_by_customer'
    });

    res.status(200).json(refund);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}

Testing Your Integration

Test Card Numbers

  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002
  • 3D Secure: 4000 0027 6000 3184
  • Insufficient Funds: 4000 0000 0000 9995

Use any future expiration date and any 3-digit CVC.

Testing Checklist

  • Test successful payments
  • Test declined cards
  • Test subscription creation and cancellation
  • Test webhook delivery
  • Test refunds
  • Test error handling
  • Test with different currencies

Going Live

Pre-Launch Checklist

  1. Complete Stripe account verification
  2. Switch from test keys to live keys
  3. Configure live webhook endpoints
  4. Test with real cards (small amounts)
  5. Set up monitoring and alerts
  6. Review PCI compliance requirements
  7. Configure tax settings if needed
  8. Set up customer support for payment issues

Monitoring and Optimization

  • Stripe Dashboard: Monitor payments, failed charges, and disputes
  • Radar: Use Stripe Radar for fraud detection
  • Analytics: Track conversion rates and payment success rates
  • Logs: Monitor webhook delivery and errors
  • Alerts: Set up email notifications for failed payments

Conclusion

Implementing Stripe payments doesn't have to be complicated. By following this guide and security best practices, you can create a robust payment system that handles one-time payments, subscriptions, and edge cases gracefully.

Remember to always verify payments on the backend using webhooks, never trust client-side data, and test thoroughly before going live. Stripe's excellent documentation and developer tools make it easier than ever to build production-ready payment systems.

Need help with payments?

Yonda Solutions integrates Stripe into web and mobile apps, from a simple checkout to subscription billing. Request a quote or email info@yondasolutions.com.