Custom API Endpoints in Payload CMS
Payload automatically generates REST API and GraphQL for all collections. Custom endpoints are needed for business operations that don't fit the standard CRUD: checkout, payment system integration, webhooks from external services.
Collection-level endpoints
// collections/Orders.ts
import type { CollectionConfig, PayloadRequest } from 'payload/types'
import { Response } from 'express'
const Orders: CollectionConfig = {
slug: 'orders',
endpoints: [
// POST /api/orders/checkout
{
path: '/checkout',
method: 'post',
handler: async (req: PayloadRequest, res: Response) => {
const { items, customerEmail, shippingAddress } = req.body
// Validation
if (!items?.length) {
return res.status(400).json({ error: 'Items required' })
}
// Calculate total
let total = 0
const enrichedItems = await Promise.all(
items.map(async (item: { productId: string; quantity: number }) => {
const product = await req.payload.findByID({
collection: 'products',
id: item.productId,
})
total += product.price * item.quantity
return {
product: item.productId,
quantity: item.quantity,
price: product.price,
name: product.name,
}
})
)
// Create order
const order = await req.payload.create({
collection: 'orders',
data: {
items: enrichedItems,
total,
customerEmail,
shippingAddress,
status: 'pending',
},
req,
})
// Create payment session
const paymentSession = await stripeClient.checkout.sessions.create({
payment_method_types: ['card'],
line_items: enrichedItems.map(item => ({
price_data: {
currency: 'rub',
product_data: { name: item.name },
unit_amount: Math.round(item.price * 100),
},
quantity: item.quantity,
})),
mode: 'payment',
success_url: `${process.env.FRONTEND_URL}/order/${order.id}/success`,
cancel_url: `${process.env.FRONTEND_URL}/cart`,
metadata: { orderId: String(order.id) },
})
return res.json({
orderId: order.id,
paymentUrl: paymentSession.url,
})
},
},
// POST /api/orders/webhook/stripe
{
path: '/webhook/stripe',
method: 'post',
handler: async (req: PayloadRequest, res: Response) => {
const sig = req.headers['stripe-signature'] as string
let event
try {
event = stripe.webhooks.constructEvent(
req.rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return res.status(400).json({ error: 'Webhook signature verification failed' })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
const orderId = session.metadata?.orderId
await req.payload.update({
collection: 'orders',
id: orderId!,
data: { status: 'paid', paymentId: session.payment_intent as string },
req,
})
}
return res.json({ received: true })
},
},
],
}
Global endpoints in payload.config.ts
// payload.config.ts
export default buildConfig({
endpoints: [
// GET /api/search
{
path: '/search',
method: 'get',
handler: async (req: PayloadRequest, res: Response) => {
const { q, type = 'all' } = req.query as { q: string; type: string }
if (!q || q.length < 2) {
return res.json({ docs: [], totalDocs: 0 })
}
const collections = type === 'all' ? ['posts', 'products', 'pages'] : [type]
const results = await Promise.all(
collections.map(collection =>
req.payload.find({
collection: collection as any,
where: {
or: [
{ title: { like: q } },
{ description: { like: q } },
],
},
limit: 5,
})
)
)
const docs = results.flatMap((r, i) =>
r.docs.map(doc => ({ ...doc, _collection: collections[i] }))
)
return res.json({ docs, totalDocs: docs.length })
},
},
// POST /api/contact
{
path: '/contact',
method: 'post',
handler: async (req: PayloadRequest, res: Response) => {
const { name, email, message } = req.body
if (!name || !email || !message) {
return res.status(400).json({ error: 'All fields required' })
}
// Save inquiry
await req.payload.create({
collection: 'inquiries',
data: { name, email, message, status: 'new' },
})
// Notify administrators
await emailService.send({
to: process.env.ADMIN_EMAIL!,
subject: `New inquiry from ${name}`,
text: `From: ${name} <${email}>\n\n${message}`,
})
return res.json({ success: true })
},
},
],
})
API middleware
// Logging API requests
{
path: '/admin-action',
method: 'post',
handler: async (req: PayloadRequest, res: Response) => {
// Authentication check
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' })
}
// Role check
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' })
}
// Log action
await req.payload.create({
collection: 'audit-logs',
data: {
action: 'admin-action',
user: req.user.id,
timestamp: new Date().toISOString(),
data: req.body,
},
})
// Execute action
return res.json({ success: true })
},
}
Calling custom endpoints
// From Next.js Server Action
'use server'
export async function checkoutAction(items: CartItem[]) {
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/orders/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, customerEmail: '[email protected]' }),
})
if (!response.ok) throw new Error('Checkout failed')
return response.json()
}
Timeline
Development of 3–5 custom endpoints with payment system integration and webhooks — 2–3 days.







