payload

Use when working with Payload projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.

Install
npx skills add 'https://github.com/payloadcms/payload/tree/main/packages/payload/skills/payload'
Download bundle ↓
main · 9a2cdcbScanned 2026-09-17

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md

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

PropertyTypeDescription
pathstringRoute 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
customRecord<string, any>Extension point for plugins/metadata

Request Context

PropertyTypeDescription
req.userUser | nullAuthenticated user (null if not authenticated)
req.payloadPayloadPayload instance for operations (find, create...)
req.routeParamsRecord<string, any>Path parameters (e.g., :id)
req.urlstringFull request URL
req.methodstringHTTP method
req.headersHeadersRequest headers
req.json()() => Promise<any>Parse JSON body
req.text()() => Promise<string>Read body as text
req.dataanyParsed body (after addDataAndFileToRequest())
req.fileFileUploaded file (after addDataAndFileToRequest())
req.localestringRequest locale (after addLocalesToRequestFromData())
req.i18nI18ni18n instance
req.tTFunctionTranslation function

Common Patterns

Authentication Check

Custom endpoints are not authenticated by default. Check req.user to enforce authentication.

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.

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.

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

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)

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.

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.

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.

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.

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.

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.

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:

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:

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:

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:

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}.

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}.

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.

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.

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.

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

Referenced from SKILL.md