How to build a payment API in Node.js
A payment API in Node.js needs to create payment intents, handle webhook events from the payment provider, and update order status atomically when payment is confirmed. As the creator of CoreUI with 25 years of backend development experience, I’ve built payment integrations for e-commerce platforms where a missed webhook means a customer paid but their order was never fulfilled. The safest pattern creates a pending order, creates a Stripe payment intent referencing that order, and then fulfills the order only when Stripe confirms payment via webhook. This decoupled approach handles network failures and browser closures gracefully.
Install Stripe and set up the Express payment routes.
npm install stripe
// src/payments/payments.router.js
import { Router } from 'express'
import Stripe from 'stripe'
import { PrismaClient } from '@prisma/client'
const router = Router()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const prisma = new PrismaClient()
// Create a payment intent for an order
router.post('/create-intent', async (req, res, next) => {
try {
const { orderId } = req.body
const userId = req.user.id
const order = await prisma.order.findFirst({
where: { id: orderId, userId, status: 'pending' }
})
if (!order) return res.status(404).json({ error: 'Order not found' })
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(Number(order.total) * 100),
currency: 'usd',
metadata: { orderId: String(orderId) },
automatic_payment_methods: { enabled: true }
})
// Save the payment intent ID to the order
await prisma.order.update({
where: { id: orderId },
data: { paymentIntentId: paymentIntent.id }
})
res.json({ clientSecret: paymentIntent.client_secret })
} catch (err) {
next(err)
}
})
export { router as paymentsRouter }
Storing the paymentIntentId on the order links the Stripe payment to your order record. The metadata.orderId field is returned in webhook events so you can find the correct order to fulfill.
Handling Stripe Webhooks
Listen for Stripe events to fulfill orders after successful payment.
// src/payments/webhook.router.js
import { Router } from 'express'
import Stripe from 'stripe'
import { PrismaClient } from '@prisma/client'
const router = Router()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const prisma = new PrismaClient()
// Webhook endpoint must receive raw body - mount BEFORE express.json()
router.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
)
} catch (err) {
return res.status(400).json({ error: `Webhook Error: ${err.message}` })
}
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object
const orderId = Number(intent.metadata.orderId)
await prisma.order.update({
where: { id: orderId },
data: { status: 'paid' }
})
// Trigger fulfillment (send email, update inventory, etc.)
await fulfillOrder(orderId)
}
if (event.type === 'payment_intent.payment_failed') {
const intent = event.data.object
const orderId = Number(intent.metadata.orderId)
await prisma.order.update({
where: { id: orderId },
data: { status: 'payment_failed' }
})
}
res.json({ received: true })
}
)
async function fulfillOrder(orderId) {
// Send confirmation email, reduce inventory, etc.
console.log(`Fulfilling order ${orderId}`)
}
export { router as webhookRouter }
stripe.webhooks.constructEvent verifies the webhook signature using your webhook secret, ensuring the request came from Stripe and not a malicious actor. Always return 200 to Stripe even if your fulfillment logic fails — otherwise Stripe retries the webhook indefinitely. Handle fulfillment failures separately with a queue.
Registering the Routes
// src/app.js
import express from 'express'
import { webhookRouter } from './payments/webhook.router.js'
import { paymentsRouter } from './payments/payments.router.js'
import { authMiddleware } from './auth/auth.middleware.js'
const app = express()
// Webhook must come before express.json() middleware
app.use('/api', webhookRouter)
app.use(express.json())
app.use('/api/payments', authMiddleware, paymentsRouter)
The webhook route must be registered before express.json() because Stripe’s signature verification requires the raw request body. After parsing with express.json(), the original buffer is no longer available.
Best Practice Note
This is the same webhook-driven payment pattern used in CoreUI e-commerce backend templates. For production, make webhook handlers idempotent — Stripe may deliver the same event more than once. Check if the order is already in paid status before fulfilling to prevent double-fulfillment. See how to build an e-commerce backend in Node.js for the complete order management structure.



