How to integrate PayPal in Node.js
Integrating PayPal in Node.js requires calling the PayPal v2 Checkout Orders API to create and capture orders server-side, keeping your Client Secret secure and verifying webhooks to fulfill orders reliably. As the creator of CoreUI with 25 years of backend development experience, I’ve integrated PayPal payments in e-commerce platforms alongside Stripe to give customers maximum payment choice. The two-step pattern — create order from frontend, capture on approval via server — ensures that payment capture only happens after user confirmation. Always verify webhooks from PayPal for asynchronous payment confirmation rather than trusting the browser callback alone.
Create a reusable PayPal API client.
// src/paypal/paypal.client.js
const BASE_URL = process.env.NODE_ENV === 'production'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com'
async function getAccessToken() {
const credentials = Buffer.from(
`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
).toString('base64')
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
})
if (!res.ok) throw new Error('PayPal auth failed')
const { access_token } = await res.json()
return access_token
}
export async function paypalRequest(method, path, body) {
const token = await getAccessToken()
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
})
const data = await res.json()
if (!res.ok) throw new Error(data.message ?? 'PayPal request failed')
return data
}
The paypalRequest helper fetches a fresh access token for every request. In production, cache the token until its expires_in time to reduce API calls.
Creating a PayPal Order
Create an order and return the approval URL to the frontend.
// src/paypal/paypal.router.js
import { Router } from 'express'
import { paypalRequest } from './paypal.client.js'
const router = Router()
router.post('/create-order', async (req, res, next) => {
try {
const { amount, currency = 'USD' } = req.body
const order = await paypalRequest('POST', '/v2/checkout/orders', {
intent: 'CAPTURE',
purchase_units: [{
amount: {
currency_code: currency,
value: Number(amount).toFixed(2)
},
custom_id: String(req.user.id)
}],
application_context: {
return_url: `${process.env.CLIENT_URL}/payment/success`,
cancel_url: `${process.env.CLIENT_URL}/payment/cancel`,
user_action: 'PAY_NOW'
}
})
res.json({ orderId: order.id })
} catch (err) {
next(err)
}
})
custom_id stores your user ID in the order for reference in webhook events. user_action: 'PAY_NOW' shows “Pay Now” on the PayPal confirmation page instead of “Continue”.
Capturing Payment After Approval
Capture the payment after user approval.
router.post('/capture-order/:orderId', async (req, res, next) => {
try {
const { orderId } = req.params
const capture = await paypalRequest(
'POST',
`/v2/checkout/orders/${orderId}/capture`,
{}
)
const status = capture.status
const transactionId = capture.purchase_units[0].payments.captures[0].id
if (status === 'COMPLETED') {
// Save transaction to database and fulfill order
res.json({ success: true, transactionId })
} else {
res.status(400).json({ error: `Payment status: ${status}` })
}
} catch (err) {
next(err)
}
})
export { router as paypalRouter }
Only fulfill the order when the capture status is COMPLETED. Other statuses like PENDING require additional verification before fulfillment.
Best Practice Note
This is the same PayPal server-side integration pattern referenced in CoreUI e-commerce templates. For subscriptions and recurring payments, use PayPal’s Subscriptions API instead of the Checkout Orders API. For the React frontend consuming this API, see how to integrate PayPal in React. Always use PayPal’s sandbox environment for testing with test accounts created in the PayPal Developer Dashboard.



