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

15 KiB

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.

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