WordPress REST API Development: Complete Guide to Custom Endpoints 2025

Learn how to build custom WordPress REST API endpoints for headless WordPress, mobile apps, and third-party integrations. Includes authentication, security, and real-world examples.

Fysal Yaqoob
12 min
#WordPress#REST API#Headless CMS#API Development#Web Services#Authentication
WordPress REST API Development: Complete Guide to Custom Endpoints 2025 - Featured image for Development guide

After building REST APIs for dozens of WordPress projects—from headless CMS implementations to mobile app backends—I've learned that WordPress's REST API is far more powerful than most developers realize. In this guide, I'll show you how to create production-ready custom endpoints that are secure, performant, and maintainable.

Why Use WordPress REST API?

The WordPress REST API transforms WordPress from a traditional CMS into a flexible application platform. Here's why it matters:

Headless WordPress: Decouple your frontend (React, Vue, Next.js) from WordPress backend Mobile Apps: Build iOS and Android apps that consume WordPress data Third-Party Integrations: Connect WordPress to external services and platforms JavaScript Applications: Access WordPress data from single-page applications Microservices Architecture: Use WordPress as one service in a larger ecosystem

I recently built a news aggregation platform serving 500,000+ monthly users where React/Next.js frontend consumed WordPress data via custom REST endpoints. Page load times dropped from 2.8s to 0.9s compared to traditional WordPress.

Understanding WordPress REST API Basics

WordPress includes a comprehensive REST API out of the box (since version 4.7). The base endpoint is:

https://yoursite.com/wp-json/

Built-In Endpoints

WordPress provides default endpoints for:

  • Posts: /wp/v2/posts
  • Pages: /wp/v2/pages
  • Categories: /wp/v2/categories
  • Tags: /wp/v2/tags
  • Users: /wp/v2/users
  • Media: /wp/v2/media
  • Comments: /wp/v2/comments

These work immediately—no configuration needed. But for custom functionality, you'll need custom endpoints.

Creating Your First Custom REST API Endpoint

Let's start with a simple custom endpoint that returns website statistics.

Step 1: Register the Custom Route

Create a custom plugin or add this to your theme's functions.php:

<?php
/**
 * Plugin Name: Custom REST API Endpoints
 * Description: Adds custom REST API endpoints for website statistics
 * Version: 1.0.0
 */

add_action('rest_api_init', function() {
    register_rest_route('custom/v1', '/stats', array(
        'methods'  => 'GET',
        'callback' => 'get_site_statistics',
        'permission_callback' => '__return_true' // Public endpoint
    ));
});

function get_site_statistics() {
    $stats = array(
        'total_posts'    => wp_count_posts('post')->publish,
        'total_pages'    => wp_count_posts('page')->publish,
        'total_comments' => wp_count_comments()->approved,
        'total_users'    => count_users()['total_users'],
        'wordpress_version' => get_bloginfo('version'),
        'site_name'      => get_bloginfo('name'),
        'timestamp'      => current_time('mysql')
    );

    return new WP_REST_Response($stats, 200);
}

Test it:

curl https://yoursite.com/wp-json/custom/v1/stats

Response:

{
    "total_posts": 156,
    "total_pages": 12,
    "total_comments": 423,
    "total_users": 8,
    "wordpress_version": "6.7",
    "site_name": "My WordPress Site",
    "timestamp": "2025-12-14 10:30:00"
}

Step 2: Understanding Route Registration

The register_rest_route() function takes three parameters:

  1. Namespace (custom/v1): Groups your endpoints and enables versioning
  2. Route (/stats): The URL path
  3. Arguments (array): Configuration options

Key arguments:

  • methods: HTTP methods (GET, POST, PUT, DELETE, PATCH)
  • callback: Function to execute
  • permission_callback: Who can access this endpoint
  • args: Validate request parameters

Accepting Request Parameters

Let's create an endpoint that accepts parameters to filter posts:

add_action('rest_api_init', function() {
    register_rest_route('custom/v1', '/posts/filter', array(
        'methods'  => 'GET',
        'callback' => 'get_filtered_posts',
        'permission_callback' => '__return_true',
        'args' => array(
            'category' => array(
                'required' => false,
                'type' => 'string',
                'description' => 'Filter by category slug',
                'sanitize_callback' => 'sanitize_text_field'
            ),
            'limit' => array(
                'required' => false,
                'type' => 'integer',
                'default' => 10,
                'minimum' => 1,
                'maximum' => 100,
                'description' => 'Number of posts to return'
            ),
            'search' => array(
                'required' => false,
                'type' => 'string',
                'description' => 'Search posts by keyword',
                'sanitize_callback' => 'sanitize_text_field'
            )
        )
    ));
});

function get_filtered_posts($request) {
    $category = $request->get_param('category');
    $limit = $request->get_param('limit');
    $search = $request->get_param('search');

    $args = array(
        'post_type' => 'post',
        'posts_per_page' => $limit,
        'post_status' => 'publish'
    );

    // Add category filter
    if ($category) {
        $args['category_name'] = $category;
    }

    // Add search filter
    if ($search) {
        $args['s'] = $search;
    }

    $query = new WP_Query($args);
    $posts = array();

    if ($query->have_posts()) {
        while ($query->have_posts()) {
            $query->the_post();
            $posts[] = array(
                'id' => get_the_ID(),
                'title' => get_the_title(),
                'excerpt' => get_the_excerpt(),
                'permalink' => get_permalink(),
                'date' => get_the_date('c'), // ISO 8601 format
                'featured_image' => get_the_post_thumbnail_url(get_the_ID(), 'large'),
                'categories' => wp_get_post_categories(get_the_ID(), array('fields' => 'names'))
            );
        }
        wp_reset_postdata();
    }

    return new WP_REST_Response(array(
        'posts' => $posts,
        'total' => $query->found_posts,
        'pages' => $query->max_num_pages
    ), 200);
}

Test it:

# Get 5 posts from "wordpress" category
curl "https://yoursite.com/wp-json/custom/v1/posts/filter?category=wordpress&limit=5"

# Search for posts containing "plugin"
curl "https://yoursite.com/wp-json/custom/v1/posts/filter?search=plugin"

Handling POST Requests (Creating Data)

Let's create an endpoint that accepts form submissions:

add_action('rest_api_init', function() {
    register_rest_route('custom/v1', '/contact', array(
        'methods'  => 'POST',
        'callback' => 'handle_contact_form',
        'permission_callback' => '__return_true',
        'args' => array(
            'name' => array(
                'required' => true,
                'type' => 'string',
                'sanitize_callback' => 'sanitize_text_field',
                'validate_callback' => function($param) {
                    return !empty($param) && strlen($param) <= 100;
                }
            ),
            'email' => array(
                'required' => true,
                'type' => 'string',
                'sanitize_callback' => 'sanitize_email',
                'validate_callback' => function($param) {
                    return is_email($param);
                }
            ),
            'message' => array(
                'required' => true,
                'type' => 'string',
                'sanitize_callback' => 'sanitize_textarea_field',
                'validate_callback' => function($param) {
                    return !empty($param) && strlen($param) <= 5000;
                }
            )
        )
    ));
});

function handle_contact_form($request) {
    $name = $request->get_param('name');
    $email = $request->get_param('email');
    $message = $request->get_param('message');

    // Save to database
    $post_id = wp_insert_post(array(
        'post_title' => sprintf('Contact from %s', $name),
        'post_content' => $message,
        'post_type' => 'contact_submission', // Register this custom post type first
        'post_status' => 'publish',
        'meta_input' => array(
            'contact_name' => $name,
            'contact_email' => $email,
            'submission_date' => current_time('mysql')
        )
    ));

    if (is_wp_error($post_id)) {
        return new WP_Error(
            'submission_failed',
            'Failed to save contact form',
            array('status' => 500)
        );
    }

    // Send email notification (optional)
    wp_mail(
        get_option('admin_email'),
        'New Contact Form Submission',
        sprintf("Name: %s\nEmail: %s\n\nMessage:\n%s", $name, $email, $message)
    );

    return new WP_REST_Response(array(
        'success' => true,
        'message' => 'Thank you for your message. We will respond soon.',
        'submission_id' => $post_id
    ), 201);
}

Test with cURL:

curl -X POST https://yoursite.com/wp-json/custom/v1/contact \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "john@example.com",
    "message": "This is a test message"
  }'

Authentication and Security

CRITICAL: Never use 'permission_callback' => '__return_true' for endpoints that modify data or return sensitive information.

register_rest_route('custom/v1', '/private-data', array(
    'methods'  => 'GET',
    'callback' => 'get_private_data',
    'permission_callback' => function() {
        return is_user_logged_in();
    }
));

Method 2: Check User Capabilities

register_rest_route('custom/v1', '/admin-only', array(
    'methods'  => 'GET',
    'callback' => 'get_admin_data',
    'permission_callback' => function() {
        return current_user_can('manage_options');
    }
));

WordPress 5.6+ supports Application Passwords for REST API authentication.

Enable in WordPress:

  1. Go to Users → Your Profile
  2. Scroll to "Application Passwords"
  3. Create a new application password
  4. Use Basic Authentication in your requests

Example request:

curl -X GET https://yoursite.com/wp-json/wp/v2/posts \
  --user "username:xxxx xxxx xxxx xxxx xxxx xxxx"

Method 4: Custom API Key Authentication

For third-party integrations, implement custom API key authentication:

function validate_api_key($request) {
    $api_key = $request->get_header('X-API-Key');

    if (!$api_key) {
        return false;
    }

    // Check against stored API keys (store in options or custom table)
    $valid_keys = get_option('custom_api_keys', array());

    return in_array($api_key, $valid_keys);
}

register_rest_route('custom/v1', '/secure-endpoint', array(
    'methods'  => 'POST',
    'callback' => 'handle_secure_request',
    'permission_callback' => 'validate_api_key'
));

Usage:

curl -X POST https://yoursite.com/wp-json/custom/v1/secure-endpoint \
  -H "X-API-Key: your-secret-api-key-here" \
  -H "Content-Type: application/json" \
  -d '{"data": "value"}'

Error Handling Best Practices

Always return proper HTTP status codes and error messages:

function get_post_by_id($request) {
    $post_id = $request->get_param('id');
    $post = get_post($post_id);

    if (!$post) {
        return new WP_Error(
            'post_not_found',
            'Post not found',
            array('status' => 404)
        );
    }

    if ($post->post_status !== 'publish') {
        return new WP_Error(
            'post_not_published',
            'This post is not publicly available',
            array('status' => 403)
        );
    }

    return new WP_REST_Response(array(
        'id' => $post->ID,
        'title' => $post->post_title,
        'content' => apply_filters('the_content', $post->post_content)
    ), 200);
}

Real-World Example: WooCommerce Product Search API

Here's a practical endpoint for searching WooCommerce products:

add_action('rest_api_init', function() {
    register_rest_route('shop/v1', '/products/search', array(
        'methods'  => 'GET',
        'callback' => 'search_products',
        'permission_callback' => '__return_true',
        'args' => array(
            'query' => array(
                'required' => true,
                'type' => 'string',
                'sanitize_callback' => 'sanitize_text_field'
            ),
            'min_price' => array(
                'required' => false,
                'type' => 'number'
            ),
            'max_price' => array(
                'required' => false,
                'type' => 'number'
            ),
            'in_stock' => array(
                'required' => false,
                'type' => 'boolean',
                'default' => false
            )
        )
    ));
});

function search_products($request) {
    $query = $request->get_param('query');
    $min_price = $request->get_param('min_price');
    $max_price = $request->get_param('max_price');
    $in_stock = $request->get_param('in_stock');

    $args = array(
        'post_type' => 'product',
        'posts_per_page' => 20,
        's' => $query,
        'post_status' => 'publish'
    );

    // Price filter
    if ($min_price || $max_price) {
        $args['meta_query'] = array('relation' => 'AND');

        if ($min_price) {
            $args['meta_query'][] = array(
                'key' => '_price',
                'value' => $min_price,
                'compare' => '>=',
                'type' => 'NUMERIC'
            );
        }

        if ($max_price) {
            $args['meta_query'][] = array(
                'key' => '_price',
                'value' => $max_price,
                'compare' => '<=',
                'type' => 'NUMERIC'
            );
        }
    }

    // Stock filter
    if ($in_stock) {
        $args['meta_query'][] = array(
            'key' => '_stock_status',
            'value' => 'instock'
        );
    }

    $query_obj = new WP_Query($args);
    $products = array();

    if ($query_obj->have_posts()) {
        while ($query_obj->have_posts()) {
            $query_obj->the_post();
            $product = wc_get_product(get_the_ID());

            $products[] = array(
                'id' => $product->get_id(),
                'name' => $product->get_name(),
                'price' => $product->get_price(),
                'regular_price' => $product->get_regular_price(),
                'sale_price' => $product->get_sale_price(),
                'on_sale' => $product->is_on_sale(),
                'stock_status' => $product->get_stock_status(),
                'image' => wp_get_attachment_url($product->get_image_id()),
                'permalink' => get_permalink($product->get_id()),
                'short_description' => $product->get_short_description()
            );
        }
        wp_reset_postdata();
    }

    return new WP_REST_Response(array(
        'products' => $products,
        'total' => $query_obj->found_posts
    ), 200);
}

Test it:

curl "https://yoursite.com/wp-json/shop/v1/products/search?query=laptop&min_price=500&max_price=1500&in_stock=true"

Performance Optimization

1. Add Caching

function get_cached_posts($request) {
    $cache_key = 'custom_posts_' . md5(serialize($request->get_params()));
    $cached = wp_cache_get($cache_key);

    if ($cached !== false) {
        return new WP_REST_Response($cached, 200);
    }

    // Expensive query here
    $posts = get_posts(array(/* ... */));

    // Cache for 5 minutes
    wp_cache_set($cache_key, $posts, '', 300);

    return new WP_REST_Response($posts, 200);
}

2. Limit Response Fields

// Only return necessary fields
$posts[] = array(
    'id' => get_the_ID(),
    'title' => get_the_title(),
    'permalink' => get_permalink()
    // Don't include large fields like 'content' unless needed
);

3. Pagination

'args' => array(
    'page' => array(
        'default' => 1,
        'type' => 'integer'
    ),
    'per_page' => array(
        'default' => 10,
        'maximum' => 100,
        'type' => 'integer'
    )
)

Testing Your REST API

Using cURL

# GET request
curl https://yoursite.com/wp-json/custom/v1/endpoint

# POST request with JSON data
curl -X POST https://yoursite.com/wp-json/custom/v1/endpoint \
  -H "Content-Type: application/json" \
  -d '{"key": "value"}'

# With authentication
curl https://yoursite.com/wp-json/wp/v2/posts \
  --user "username:application-password"

Using Postman

  1. Install Postman
  2. Create new request
  3. Set method (GET, POST, etc.)
  4. Enter URL: https://yoursite.com/wp-json/custom/v1/endpoint
  5. Add headers if needed (Content-Type, X-API-Key, etc.)
  6. Add request body for POST/PUT requests
  7. Click Send

Using JavaScript (Fetch API)

// GET request
fetch('https://yoursite.com/wp-json/custom/v1/posts/filter?limit=5')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

// POST request
fetch('https://yoursite.com/wp-json/custom/v1/contact', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    name: 'John Doe',
    email: 'john@example.com',
    message: 'Hello from JavaScript'
  })
})
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

Common Issues and Solutions

Issue 1: 404 Errors on Custom Routes

Cause: Permalinks not flushed after registering routes

Solution:

// After registering routes, flush permalinks once
register_activation_hook(__FILE__, 'flush_rewrite_rules');
register_deactivation_hook(__FILE__, 'flush_rewrite_rules');

Or manually: Go to Settings → Permalinks and click "Save Changes"

Issue 2: CORS Errors in JavaScript

Cause: Cross-Origin Resource Sharing not enabled

Solution:

add_action('rest_api_init', function() {
    remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
    add_filter('rest_pre_serve_request', function($value) {
        header('Access-Control-Allow-Origin: *');
        header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key');
        return $value;
    });
}, 15);

Issue 3: Authentication Not Working

Cause: WordPress nonce verification failing

Solution: Use Application Passwords instead of cookie authentication for external apps

Issue 4: Slow API Responses

Solutions:

  • Implement caching (WP Object Cache or transients)
  • Limit query complexity
  • Use pagination
  • Add database indexes for custom queries
  • Use 'fields' => 'ids' in WP_Query when you only need IDs

Security Checklist

Always validate and sanitize inputUse proper permission callbacksImplement rate limiting for public endpointsNever expose sensitive data without authenticationUse HTTPS in productionValidate API keys securelyLog API access for monitoringSet proper CORS headersHandle errors gracefully without exposing system details

Frequently Asked Questions

Can I disable the default WordPress REST API?

Yes, but not recommended. To disable specific endpoints:

add_filter('rest_endpoints', function($endpoints) {
    if (isset($endpoints['/wp/v2/users'])) {
        unset($endpoints['/wp/v2/users']);
    }
    return $endpoints;
});

How do I version my API?

Use namespaces:

  • custom/v1 for version 1
  • custom/v2 for version 2

This allows you to maintain backwards compatibility while releasing new versions.

Can I use the REST API with mobile apps?

Absolutely! The WordPress REST API is perfect for mobile apps. Use Application Passwords for authentication.

How do I test authentication locally?

Use Postman or cURL with the --user flag for Basic Authentication.

What's the difference between REST API and AJAX?

  • REST API: Standard HTTP endpoints, stateless, can be used by any client
  • AJAX: WordPress-specific, uses admin-ajax.php, stateful (uses nonces)

Use REST API for modern applications, AJAX for traditional WordPress plugins.

Next Steps

Now that you understand WordPress REST API development, try building:

  1. Headless WordPress site with Next.js or React
  2. Mobile app consuming WordPress data
  3. Third-party integrations (Zapier, Make, custom services)
  4. Progressive Web App (PWA) with WordPress backend
  5. Microservices architecture with WordPress as one service

The REST API opens endless possibilities for WordPress beyond traditional websites.

Resources


About the Author: I'm Fysal Yaqoob, a WordPress developer with 10+ years of experience building REST APIs for headless WordPress, mobile apps, and enterprise integrations. I've implemented REST API solutions for 100+ clients worldwide. Get in touch if you need help with your WordPress API project.

Fysal Yaqoob

Fysal Yaqoob

Expert WordPress & Shopify Developer

Senior full-stack developer with 10+ years experience specializing in WordPress, Shopify, and headless CMS solutions. Delivering custom themes, plugins, e-commerce stores, and scalable web applications.

10+ Years500+ Projects100+ Agencies
🎮

Practice: WordPress Developer Games

Take a break and level up your WordPress skills with our interactive developer games!

All LevelsVaries
Play Now