Files
convex-monorepo-payload/.claude/skills/payload/reference/ENDPOINTS.md
2026-03-27 16:43:22 -05:00

635 lines
15 KiB
Markdown

# Payload Custom API Endpoints Reference
Custom REST API endpoints extend Payload's auto-generated CRUD operations with custom logic, authentication flows, webhooks, and integrations.
## Quick Reference
### Endpoint Configuration
| Property | Type | Description |
| --------- | ------------------------------------------------- | --------------------------------------------------------------- |
| `path` | `string` | Route path after collection/global slug (e.g., `/:id/tracking`) |
| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | HTTP method (lowercase) |
| `handler` | `(req: PayloadRequest) => Promise<Response>` | Async function returning Web API Response |
| `custom` | `Record<string, any>` | Extension point for plugins/metadata |
### Request Context
| Property | Type | Description |
| ----------------- | ----------------------- | ------------------------------------------------------ |
| `req.user` | `User \| null` | Authenticated user (null if not authenticated) |
| `req.payload` | `Payload` | Payload instance for operations (find, create...) |
| `req.routeParams` | `Record<string, any>` | Path parameters (e.g., `:id`) |
| `req.url` | `string` | Full request URL |
| `req.method` | `string` | HTTP method |
| `req.headers` | `Headers` | Request headers |
| `req.json()` | `() => Promise<any>` | Parse JSON body |
| `req.text()` | `() => Promise<string>` | Read body as text |
| `req.data` | `any` | Parsed body (after `addDataAndFileToRequest()`) |
| `req.file` | `File` | Uploaded file (after `addDataAndFileToRequest()`) |
| `req.locale` | `string` | Request locale (after `addLocalesToRequestFromData()`) |
| `req.i18n` | `I18n` | i18n instance |
| `req.t` | `TFunction` | Translation function |
## Common Patterns
### Authentication Check
Custom endpoints are **not authenticated by default**. Check `req.user` to enforce authentication.
```ts
import { APIError } from 'payload'
export const authenticatedEndpoint = {
path: '/protected',
method: 'get',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
// User is authenticated
return Response.json({ message: 'Access granted' })
},
}
```
### Using Payload Operations
Use `req.payload` for database operations with access control and hooks.
```ts
export const getRelatedPosts = {
path: '/:id/related',
method: 'get',
handler: async (req) => {
const { id } = req.routeParams
// Find related posts
const posts = await req.payload.find({
collection: 'posts',
where: {
category: {
equals: id,
},
},
limit: 5,
sort: '-createdAt',
})
return Response.json(posts)
},
}
```
### Route Parameters
Access path parameters via `req.routeParams`.
```ts
export const getTrackingEndpoint = {
path: '/:id/tracking',
method: 'get',
handler: async (req) => {
const orderId = req.routeParams.id
const tracking = await getTrackingInfo(orderId)
if (!tracking) {
return Response.json({ error: 'not found' }, { status: 404 })
}
return Response.json(tracking)
},
}
```
### Request Body Handling
**Option 1: Manual JSON parsing**
```ts
export const createEndpoint = {
path: '/create',
method: 'post',
handler: async (req) => {
const data = await req.json()
const result = await req.payload.create({
collection: 'posts',
data,
})
return Response.json(result)
},
}
```
**Option 2: Using helper (handles JSON + files)**
```ts
import { addDataAndFileToRequest } from 'payload'
export const uploadEndpoint = {
path: '/upload',
method: 'post',
handler: async (req) => {
await addDataAndFileToRequest(req)
// req.data now contains parsed body
// req.file contains uploaded file (if multipart)
const result = await req.payload.create({
collection: 'media',
data: req.data,
file: req.file,
})
return Response.json(result)
},
}
```
### CORS Headers
Use `headersWithCors` helper to apply config CORS settings.
```ts
import { headersWithCors } from 'payload'
export const corsEndpoint = {
path: '/public-data',
method: 'get',
handler: async (req) => {
const data = await fetchPublicData()
return Response.json(data, {
headers: headersWithCors({
headers: new Headers(),
req,
}),
})
},
}
```
### Error Handling
Throw `APIError` with status codes for proper error responses.
```ts
import { APIError } from 'payload'
export const validateEndpoint = {
path: '/validate',
method: 'post',
handler: async (req) => {
const data = await req.json()
if (!data.email) {
throw new APIError('Email is required', 400)
}
// Validation passed
return Response.json({ valid: true })
},
}
```
### Query Parameters
Extract query params from URL.
```ts
export const searchEndpoint = {
path: '/search',
method: 'get',
handler: async (req) => {
const url = new URL(req.url)
const query = url.searchParams.get('q')
const limit = parseInt(url.searchParams.get('limit') || '10')
const results = await req.payload.find({
collection: 'posts',
where: {
title: {
contains: query,
},
},
limit,
})
return Response.json(results)
},
}
```
## Helper Functions
### addDataAndFileToRequest
Parses request body and attaches to `req.data` and `req.file`.
```ts
import { addDataAndFileToRequest } from 'payload'
export const endpoint = {
path: '/process',
method: 'post',
handler: async (req) => {
await addDataAndFileToRequest(req)
// req.data: parsed JSON or form data
// req.file: uploaded file (if multipart)
console.log(req.data) // { title: 'My Post' }
console.log(req.file) // File object or undefined
},
}
```
**Handles:**
- JSON bodies (`Content-Type: application/json`)
- Form data (`Content-Type: multipart/form-data`)
- File uploads
### addLocalesToRequestFromData
Extracts locale from request data and validates against config.
```ts
import { addLocalesToRequestFromData } from 'payload'
export const endpoint = {
path: '/translate',
method: 'post',
handler: async (req) => {
await addLocalesToRequestFromData(req)
// req.locale: validated locale string
// req.fallbackLocale: fallback locale string
const result = await req.payload.find({
collection: 'posts',
locale: req.locale,
})
return Response.json(result)
},
}
```
### headersWithCors
Applies CORS headers from Payload config.
```ts
import { headersWithCors } from 'payload'
export const endpoint = {
path: '/data',
method: 'get',
handler: async (req) => {
const data = { message: 'Hello' }
return Response.json(data, {
headers: headersWithCors({
headers: new Headers({
'Cache-Control': 'public, max-age=3600',
}),
req,
}),
})
},
}
```
## Real-World Examples
### Multi-Tenant Login Endpoint
From `examples/multi-tenant`:
```ts
import { APIError, generatePayloadCookie, headersWithCors } from 'payload'
export const externalUsersLogin = {
path: '/login-external',
method: 'post',
handler: async (req) => {
const { email, password, tenant } = await req.json()
if (!email || !password || !tenant) {
throw new APIError('Missing credentials', 400)
}
// Find user with tenant constraint
const userQuery = await req.payload.find({
collection: 'users',
where: {
and: [
{ email: { equals: email } },
{
or: [{ tenants: { equals: tenant } }, { 'tenants.tenant': { equals: tenant } }],
},
],
},
})
if (!userQuery.docs.length) {
throw new APIError('Invalid credentials', 401)
}
// Authenticate user
const result = await req.payload.login({
collection: 'users',
data: { email, password },
})
return Response.json(result, {
headers: headersWithCors({
headers: new Headers({
'Set-Cookie': generatePayloadCookie({
collectionAuthConfig: req.payload.config.collections.find((c) => c.slug === 'users')
.auth,
cookiePrefix: req.payload.config.cookiePrefix,
token: result.token,
}),
}),
req,
}),
})
},
}
```
### Webhook Handler (Stripe)
From `packages/plugin-ecommerce`:
```ts
export const webhookEndpoint = {
path: '/webhooks',
method: 'post',
handler: async (req) => {
const body = await req.text()
const signature = req.headers.get('stripe-signature')
try {
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
// Process event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(req.payload, event.data.object)
break
case 'payment_intent.failed':
await handlePaymentFailure(req.payload, event.data.object)
break
}
return Response.json({ received: true })
} catch (err) {
req.payload.logger.error(`Webhook error: ${err.message}`)
return Response.json({ error: err.message }, { status: 400 })
}
},
}
```
### Data Preview Endpoint
From `packages/plugin-import-export`:
```ts
import { addDataAndFileToRequest } from 'payload'
export const previewEndpoint = {
path: '/preview',
method: 'post',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
await addDataAndFileToRequest(req)
const { collection, where, limit = 10 } = req.data
// Validate collection exists
const collectionConfig = req.payload.config.collections.find((c) => c.slug === collection)
if (!collectionConfig) {
throw new APIError('Collection not found', 404)
}
// Preview data
const results = await req.payload.find({
collection,
where,
limit,
depth: 0,
})
return Response.json({
docs: results.docs,
totalDocs: results.totalDocs,
fields: collectionConfig.fields,
})
},
}
```
### Reindex Action Endpoint
From `packages/plugin-search`:
```ts
export const reindexEndpoint = (pluginConfig) => ({
path: '/reindex',
method: 'post',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
const { collection } = req.routeParams
// Reindex collection
const result = await reindexCollection(req.payload, collection, pluginConfig)
return Response.json({
message: `Reindexed ${result.count} documents`,
count: result.count,
})
},
})
```
## Endpoint Placement
### Collection Endpoints
Mounted at `/api/{collection-slug}/{path}`.
```ts
import type { CollectionConfig } from 'payload'
export const Orders: CollectionConfig = {
slug: 'orders',
fields: [
/* ... */
],
endpoints: [
{
path: '/:id/tracking',
method: 'get',
handler: async (req) => {
// Available at: /api/orders/:id/tracking
const orderId = req.routeParams.id
return Response.json({ orderId })
},
},
],
}
```
### Global Endpoints
Mounted at `/api/globals/{global-slug}/{path}`.
```ts
import type { GlobalConfig } from 'payload'
export const Settings: GlobalConfig = {
slug: 'settings',
fields: [
/* ... */
],
endpoints: [
{
path: '/clear-cache',
method: 'post',
handler: async (req) => {
// Available at: /api/globals/settings/clear-cache
await clearCache()
return Response.json({ message: 'Cache cleared' })
},
},
],
}
```
## Advanced Patterns
### Factory Functions
Create reusable endpoint factories for plugins.
```ts
export const createWebhookEndpoint = (config) => ({
path: '/webhook',
method: 'post',
handler: async (req) => {
const signature = req.headers.get('x-webhook-signature')
if (!verifySignature(signature, config.secret)) {
throw new APIError('Invalid signature', 401)
}
const data = await req.json()
await processWebhook(req.payload, data, config)
return Response.json({ received: true })
},
})
```
### Conditional Endpoints
Add endpoints based on config options.
```ts
export const MyCollection: CollectionConfig = {
slug: 'posts',
fields: [
/* ... */
],
endpoints: [
// Always included
{
path: '/public',
method: 'get',
handler: async (req) => Response.json({ data: [] }),
},
// Conditionally included
...(process.env.ENABLE_ANALYTICS
? [
{
path: '/analytics',
method: 'get',
handler: async (req) => Response.json({ analytics: [] }),
},
]
: []),
],
}
```
### OpenAPI Documentation
Use `custom` property for API documentation metadata.
```ts
export const endpoint = {
path: '/search',
method: 'get',
handler: async (req) => {
// Handler implementation
},
custom: {
openapi: {
summary: 'Search posts',
parameters: [
{
name: 'q',
in: 'query',
required: true,
schema: { type: 'string' },
},
],
responses: {
200: {
description: 'Search results',
content: {
'application/json': {
schema: { type: 'array' },
},
},
},
},
},
},
}
```
## Best Practices
1. **Always check authentication** - Custom endpoints are not authenticated by default
2. **Use `req.payload` for operations** - Ensures access control and hooks execute
3. **Use helpers for common tasks** - `addDataAndFileToRequest`, `headersWithCors`, etc.
4. **Throw `APIError` for errors** - Provides consistent error responses
5. **Return Web API `Response`** - Use `Response.json()` for consistent responses
6. **Validate input** - Check required fields, validate types
7. **Handle CORS** - Use `headersWithCors` for cross-origin requests
8. **Log errors** - Use `req.payload.logger` for debugging
9. **Document with `custom`** - Add OpenAPI metadata for API docs
10. **Factory pattern for reuse** - Create endpoint factories for plugins
## Resources
- REST API Overview: <https://payloadcms.com/docs/rest-api/overview>
- Custom Endpoints: <https://payloadcms.com/docs/rest-api/overview#custom-endpoints>
- Access Control: <https://payloadcms.com/docs/access-control/overview>
- Local API: <https://payloadcms.com/docs/local-api/overview>