Introduction #
In the landscape of modern e-commerce, payment flexibility isn’t just a feature—it’s a requirement for conversion. As we move through 2025, users expect friction-free checkout experiences. If you force a user to search for a credit card when they prefer PayPal, you risk cart abandonment.
For Node.js developers, building a unified payment service that handles multiple providers is a critical skill. However, integrating payment gateways is high-stakes engineering. One wrong move with currency formatting, idempotency keys, or webhook signature verification can lead to lost revenue or, worse, double-charging customers.
In this guide, we will move beyond “Hello World” examples. We are going to build a modular, production-grade Express API that integrates Stripe (using the Payment Intents API) and PayPal (using the Orders API v2). We will focus on backend architecture, security best practices, and handling the asynchronous nature of payments.
Prerequisites & Environment #
Before we dive into the code, ensure your development environment is ready. We are targeting modern Node.js features.
- Node.js: Version 20.x or 22.x (LTS) recommended.
- Package Manager:
npmorpnpm. - Accounts:
- Stripe Dashboard: You need your Secret Key (
sk_test_...) and Publishable Key. - PayPal Developer Dashboard: You need a Client ID and Secret for a Sandbox App.
- Stripe Dashboard: You need your Secret Key (
- Tools: Postman or Insomnia for API testing;
ngrokfor testing webhooks locally.
Project Initialization #
Let’s set up a clean project structure. We will use express for the server and dotenv for configuration management.
mkdir node-payment-gateway
cd node-payment-gateway
npm init -y
npm install express dotenv cors helmet stripe @paypal/checkout-server-sdk
npm install --save-dev nodemonNote: We use helmet for setting HTTP headers securely, a must-have for any API handling financial transactions.
Architecture: The Payment Flow #
Before writing code, visualize the data flow. A common mistake is trusting the client-side (frontend) to tell the server how much to charge. Never do this. The server must calculate totals based on database inventory.
Here is the robust flow we will implement:
Step 1: Configuration and Server Setup #
We need a centralized configuration strategy. Do not hardcode API keys. Create a .env file in your root directory.
.env File
#
PORT=3000
# Stripe
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here
# PayPal
PAYPAL_CLIENT_ID=your_paypal_client_id
PAYPAL_CLIENT_SECRET=your_paypal_secret
PAYPAL_ENVIRONMENT=sandboxconfig.js
#
Create a src/config.js file to export these safely.
require('dotenv').config();
module.exports = {
port: process.env.PORT || 3000,
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID,
clientSecret: process.env.PAYPAL_CLIENT_SECRET,
environment: process.env.PAYPAL_ENVIRONMENT || 'sandbox',
},
};Step 2: Implementing the Stripe Service #
Stripe’s API is excellent, but we need to handle “Payment Intents.” A Payment Intent tracks the lifecycle of a transaction from creation to completion.
Create src/services/stripeService.js.
const Stripe = require('stripe');
const config = require('../config');
// Initialize Stripe with the specific API version to prevent breaking changes
const stripe = Stripe(config.stripe.secretKey, {
apiVersion: '2024-06-20', // Always pin your API version
});
/**
* Create a Stripe Payment Intent
* @param {number} amount - Amount in lowest currency unit (e.g., cents)
* @param {string} currency - Currency code (usd, eur)
* @returns {Promise<object>} - The client secret for the frontend
*/
const createPaymentIntent = async (amount, currency = 'usd') => {
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount), // Ensure integer
currency: currency,
automatic_payment_methods: {
enabled: true,
},
metadata: {
integration_check: 'accept_a_payment',
},
});
return {
clientSecret: paymentIntent.client_secret,
id: paymentIntent.id,
};
} catch (error) {
console.error('Error creating Stripe PaymentIntent:', error);
throw new Error('Payment initialization failed');
}
};
module.exports = { createPaymentIntent, stripe };Crucial Logic: Notice Math.round(amount). Stripe requires amounts in cents (or the smallest currency unit). Passing 10.50 will cause a crash; passing 1050 is correct for $10.50.
Step 3: Implementing the PayPal Service #
PayPal requires a slightly different approach. We generate an “Order” on the server, send the ID to the client, and eventually “Capture” the funds.
Create src/services/paypalService.js.
const paypal = require('@paypal/checkout-server-sdk');
const config = require('../config');
// Setup PayPal Environment
const environment =
config.paypal.environment === 'production'
? new paypal.core.LiveEnvironment(config.paypal.clientId, config.paypal.clientSecret)
: new paypal.core.SandboxEnvironment(config.paypal.clientId, config.paypal.clientSecret);
const client = new paypal.core.PayPalHttpClient(environment);
/**
* Create a PayPal Order
* @param {string} amount - Amount as a string "10.50"
* @returns {Promise<object>} - The order ID and approval link
*/
const createOrder = async (amount) => {
const request = new paypal.orders.OrdersCreateRequest();
request.prefer('return=representation');
request.requestBody({
intent: 'CAPTURE',
purchase_units: [
{
amount: {
currency_code: 'USD',
value: amount, // PayPal expects string format "10.00"
},
},
],
});
try {
const order = await client.execute(request);
return {
id: order.result.id,
links: order.result.links,
};
} catch (error) {
console.error('Error creating PayPal Order:', error);
throw new Error('PayPal initialization failed');
}
};
/**
* Capture payment after user approval
* @param {string} orderId
*/
const captureOrder = async (orderId) => {
const request = new paypal.orders.OrdersCaptureRequest(orderId);
request.requestBody({});
try {
const capture = await client.execute(request);
return capture.result;
} catch (error) {
console.error('Error capturing PayPal Order:', error);
throw new Error('PayPal capture failed');
}
};
module.exports = { createOrder, captureOrder, client };Step 4: Building the API Controller #
Now, let’s bring them together. We’ll simulate a shopping cart calculation to ensure the price comes from the server.
Create src/app.js (or a route controller).
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const stripeService = require('./services/stripeService');
const paypalService = require('./services/paypalService');
const config = require('./config');
const app = express();
// Middleware
app.use(helmet());
app.use(cors());
// IMPORTANT: Webhooks require raw body, regular API needs JSON.
// We will handle raw body in the webhook section later.
app.use((req, res, next) => {
if (req.originalUrl.includes('/webhook')) {
next();
} else {
express.json()(req, res, next);
}
});
// Mock Database lookup
const products = {
'item-123': { price: 2000, name: 'Premium Node Course' }, // $20.00
};
const calculateOrderAmount = (items) => {
// In production, fetch from DB
let total = 0;
items.forEach((item) => {
if (products[item.id]) {
total += products[item.id].price * item.quantity;
}
});
return total;
};
// Route: Initialize Checkout
app.post('/api/checkout', async (req, res) => {
const { items, provider } = req.body;
// 1. Calculate secure total
const amountInCents = calculateOrderAmount(items);
if (amountInCents === 0) return res.status(400).json({ error: 'Invalid cart' });
try {
if (provider === 'stripe') {
const data = await stripeService.createPaymentIntent(amountInCents, 'usd');
return res.json(data);
}
else if (provider === 'paypal') {
// PayPal expects "20.00" string format
const amountString = (amountInCents / 100).toFixed(2);
const data = await paypalService.createOrder(amountString);
return res.json(data);
}
else {
return res.status(400).json({ error: 'Invalid provider' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Route: PayPal Capture (Stripe handles this via client SDK confirm)
app.post('/api/paypal/capture', async (req, res) => {
const { orderID } = req.body;
try {
const captureData = await paypalService.captureOrder(orderID);
// TODO: Save successful transaction to DB here
res.json(captureData);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});Step 5: Handling Webhooks (The “Real” Integration) #
Frontend confirmation is not enough. Users can close browsers, lose internet, or maliciously alter client-side code. You rely on Webhooks for the source of truth.
Stripe requires the raw request body to verify the signature.
// Add this BEFORE other middleware in app.js
const bodyParser = require('body-parser');
app.post('/webhook/stripe', bodyParser.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripeService.stripe.webhooks.constructEvent(
req.body,
sig,
config.stripe.webhookSecret
);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
console.log(`PaymentIntent ${paymentIntent.id} was successful!`);
// TODO: Fulfill order in database (e.g., send email, unlock content)
break;
case 'payment_intent.payment_failed':
console.log('Payment failed.');
break;
default:
console.log(`Unhandled event type ${event.type}`);
}
// Return a 200 response to acknowledge receipt of the event
res.send();
});Why Signature Verification Matters #
If you skip the signature check (constructEvent), an attacker can send a fake payment.succeeded JSON payload to your endpoint. Your server would believe them and ship the product for free. The signature ensures the data actually came from Stripe.
Comparison: Stripe vs. PayPal Integration #
When choosing or maintaining these integrations, it helps to understand the architectural differences.
| Feature | Stripe (Payment Intents) | PayPal (Orders v2) |
|---|---|---|
| Data Format | Integers (Cents) | Strings (“10.00”) |
| Flow | Create Intent -> Client Confirm -> Webhook | Create Order -> Client Approve -> Server Capture |
| SDK Quality | Excellent, typed, consistent | Good, but complex object structures |
| Webhooks | Essential for success confirmation | Optional (if using Server Capture), but recommended |
| Testing | Excellent CLI for triggering events | Sandbox dashboard (can be slow) |
Best Practices & Production Pitfalls #
-
Idempotency is King: Network errors happen. If a client retries a
POST /checkout, you don’t want to charge them twice. Stripe supportsIdempotency-Keyheaders. Pass a unique ID (like a cart ID or UUID) in the headers when calling Stripe create functions. -
Decimal Math: JavaScript is notorious for floating-point math errors (
0.1 + 0.2 !== 0.3).- Rule: Store all monetary values in your database as integers (cents).
- Only convert to decimal strings when sending to PayPal or displaying to the frontend.
-
Logging and Monitoring: Payment errors are critical. Use a logger like
winstonorpino. Don’t justconsole.log. If a webhook fails, you need to know immediately, or you might have paid customers who haven’t received their goods. -
PCI Compliance: Notice we never touched a credit card number in our Node code? That’s by design. Using Stripe Elements or PayPal Buttons on the frontend ensures the raw card data goes directly from the browser to the provider. Your server only handles tokens/IDs. This keeps your PCI compliance burden low (SAQ-A).
Conclusion #
integrating Stripe and PayPal into a Node.js application is about more than just reading API docs; it’s about orchestration and security. By separating your services, enforcing server-side calculations, and verifying webhooks, you create a system that is resilient to errors and fraud.
The dual-gateway approach maximizes your revenue potential. While Stripe offers a superior developer experience, the brand trust of PayPal is undeniable for consumer transactions.
Next Steps:
- Implement the database logic to change order status upon successful webhooks.
- Add logic to handle refunds via the API.
- Set up
ngrokto test your local webhooks with the actual Payment Provider sandboxes.
Happy coding, and may your API responses always be 200 OK!