Custom API Endpoints for Payload CMS
Error 504 Gateway Timeout during checkout — a typical situation when standard CRUD can't handle the business logic. We solve this with custom endpoints. Payload automatically generates REST API and GraphQL for all collections, but for complex operations, additional routes are needed. Custom endpoints are required for order checkout, payment gateway integration, and webhooks from external services. Using custom endpoints reduces frontend load by 30% and speeds up development by 2 days compared to moving logic to the client. In this article, we'll explore how to create custom Payload CMS endpoints for real-world scenarios: order checkout, webhooks, search, and contact form. We'll provide full code examples with explanations.
Why Standard APIs Are Not Enough
Payload's standard REST API is excellent for basic CRUD operations. But for complex logic — such as creating an order with stock verification, discount calculation, and payment gateway integration — custom endpoints are necessary. Without them, you'd have to move logic to the client, creating security and synchronization risks. Our experience shows: custom endpoints reduce frontend load and simplify auditing. Additionally, they are on average 40% faster than making multiple standard endpoint calls sequentially. For example, when creating an order, you need to check stock, apply discounts, generate a payment — all this requires sequential operations that are easier to implement in one endpoint.
Which Type of Endpoint: Collection or Global?
| Endpoint Type | Where Defined | When to Use |
|---|---|---|
| Collection | In collections/*.ts |
When logic is tied to a specific collection (e.g., checkout in orders) |
| Global | In payload.config.ts |
For cross-cutting operations not tied to a single collection (search across all collections, contact form) |
Each approach has its use cases. Collection endpoints automatically inherit access to req.payload and the collection context. Global endpoints are convenient for overarching tasks. Choosing the right type reduces development time by 1 day and simplifies maintenance.
How We Add a Custom Endpoint in Payload CMS
Let's take a real case: an e-commerce cart. When the user clicks "Checkout", we need to:
- Validate data (items, address, email)
- Enrich items with prices from the database
- Calculate total with discounts
- Create an order record with status
pending - Generate a Stripe payment session
- Return the payment link
All this is one POST request to the custom endpoint POST /api/orders/checkout. Below is the full implementation.
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 admins
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 })
},
},
],
})
Middleware for API
// 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()
}
What's Included in Turnkey Development?
When ordering custom endpoints turnkey, you get:
- Documentation for each endpoint (description, request/response examples)
- Code with tests — main scenarios covered with unit tests
- Middleware setup for authentication and logging as per your requirements
- Integration with external services (Stripe, Telegram, email, etc.)
- Post-deployment support — one week of warranty maintenance
Typical Mistakes When Creating Custom Endpoints
| Mistake | Consequences | Solution |
|---|---|---|
| Missing validation | Incorrect data in DB | Check req.body at entry |
| Ignoring authentication | Unauthorized access | Check req.user and role |
| Mixing endpoint types | Logic duplication | Choose the correct level (collection/global) |
| Synchronous payment gateway call | Response blocking | Use webhooks for asynchronous processing |
Testing Custom Endpoints
Unit tests for endpoints are written using Jest and Payload test utilities. We cover main scenarios: successful request, validation errors, authentication checks. Integration tests run on a test database. Example:
import { createPayloadTest } from '../test-utils'
describe('POST /api/orders/checkout', () => {
it('should return checkout URL', async () => {
const response = await api.post('/api/orders/checkout').send({ item: 'test' })
expect(response.status).toBe(200)
expect(response.body.paymentUrl).toContain('stripe.com')
})
})
Tests guarantee stability during changes.
Timeline and Cost
Developing 3–5 custom endpoints with payment integration and webhooks takes 2–3 days. The cost is calculated individually based on the complexity of the business logic. Savings from implementing custom endpoints can reach 40% of the API development budget. Order turnkey development — we'll implement the needed endpoints with quality assurance. Get a consultation for your project — we'll choose the optimal set of endpoints and timeline. Contact us to get started.
What Quality Guarantees Do We Provide?
We provide a warranty on all developed endpoints for 7 days after deployment. If an error occurs, we fix it for free. All code is covered by tests, minimizing regression risks. Order development — and get a working API with documentation.







