How to Create a Custom WordPress REST API Endpoint (Complete 2026 Tutorial)

Custom WordPress REST API endpoint tutorial: a terminal window showing a curl request and JSON response from a custom WordPress route.
Custom WordPress REST API endpoint tutorial: a terminal window showing a curl request and JSON response from a custom WordPress route.

The WordPress REST API turns your website into a data source instead of just a set of pages. If you have ever wanted to feed your content into a mobile app, a headless front end, or an external dashboard, a WordPress custom API endpoint is the cleanest, most maintainable way to do it. In this tutorial you will build a real endpoint from scratch, step by step, and test it with a live request. No prior REST experience is required.

By the end you will know how to register a route, return JSON, validate and sanitize input, control permissions, handle errors, and secure everything so it is safe to ship. If you would rather strengthen your WordPress foundation first, our plugin development guide covers the workflow this tutorial builds on.

Why build a custom WordPress API endpoint?

WordPress already ships with a rich REST API: posts, pages, users, comments, media and more are all available as JSON out of the box. You reach them at a predictable URL pattern:

https://your-site.com/wp-json/wp/v2/posts

That covers the common cases, but real projects almost always need something the core does not provide. A custom endpoint helps you:

  • Expose exactly the data you need. Return three fields instead of forty, so your front end loads faster.
  • Connect WordPress to other systems. Let a CRM, an internal tool, or a partner website pull data from your site.
  • Power a headless setup. Serve content to React, Vue, or a mobile app while WordPress stays the editing tool.
  • Add business logic. Search across custom post types, calculate values, or trigger actions on request.
  • Decouple responsibilities. Keep clean, documented interfaces between your website and everything around it.

What you will build

We are going to create a small, self-contained plugin that registers a custom namespace called lwp/v1 and exposes two endpoints:

  • GET /wp-json/lwp/v1/tutorials – returns a list of your tutorials as JSON, with an optional limit parameter.
  • POST /wp-json/lwp/v1/inquiry – accepts a contact request, validates it, and confirms acceptance.

The same patterns scale to any endpoint you can imagine, so treat this as a reusable template.

Prerequisites

  • A WordPress site running version 6.0 or newer (the REST API is enabled by default).
  • Permission to add a plugin, or a child theme where you can add code.
  • A basic understanding of PHP functions and arrays.
  • A way to test requests: your browser, the command line with curl, or a tool such as Postman.

Where should the code live?

Never paste custom code into WordPress core, and avoid putting it in a parent theme, because updates will erase it. The two safe options are a child theme or, better for API work, a tiny custom plugin. A plugin keeps the API logic independent from your design, so switching themes never breaks your endpoints.

Step 1: Create a small plugin

In wp-content/plugins/ create a folder named lwp-custom-api and inside it a file named lwp-custom-api.php:

<?php
/**
 * Plugin Name: LWP Custom API
 * Description: A minimal plugin that registers custom REST API endpoints.
 * Version: 1.0.0
 * Author: LearnWPPlugins
 * License: GPL-2.0-or-later
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Never allow direct access to this file.
}

The ABSPATH check is a simple but important habit: it stops anyone from loading the file directly from the browser.

Step 2: Register your first route

Routes are registered on the rest_api_init hook so WordPress is fully loaded by the time your code runs. Add this to your plugin file:

add_action( 'rest_api_init', function () {

    register_rest_route( 'lwp/v1', '/tutorials', array(
        'methods'             => 'GET',
        'callback'            => 'lwp_get_tutorials',
        'permission_callback' => '__return_true',
    ) );

} );

The first argument, lwp/v1, is the namespace. Using v1 is a convention that lets you release a breaking change later as v2 without disturbing anyone still using the old route. The second argument is the path. Together they produce the URL: /wp-json/lwp/v1/tutorials.

Important: the namespace must contain at least one slash, and both parts must match a strict pattern. Names like lwp/v1 and lwp-tools/v1 are valid; a bare word like lwp is not.

Step 3: Write the callback and return JSON

The callback is simply a PHP function that returns your data. WordPress converts whatever you return into a JSON response automatically. Returning a WP_REST_Response object gives you explicit control over the HTTP status code and headers:

function lwp_get_tutorials( WP_REST_Request $request ) {

    $data = array(
        array(
            'title' => 'How to Use TablePress',
            'slug'  => 'tablepress-tutorial-complete-guide',
        ),
        array(
            'title' => 'How to Use Contact Form 7',
            'slug'  => 'how-to-use-contact-form-7-wordpress',
        ),
    );

    return new WP_REST_Response( $data, 200 );
}

Visit https://your-site.com/wp-json/lwp/v1/tutorials in your browser and you will see clean JSON. That is already a working custom endpoint.

Step 4: Add arguments, validation, and sanitization

Hard-coded data is only the beginning. Real endpoints accept parameters, and every parameter must be validated before you trust it. Add an args array to your route definition:

register_rest_route( 'lwp/v1', '/tutorials', array(
    'methods'             => 'GET',
    'callback'            => 'lwp_get_tutorials',
    'permission_callback' => '__return_true',
    'args'                => array(
        'limit' => array(
            'default'           => 10,
            'sanitize_callback' => 'absint',
            'validate_callback' => function ( $value ) {
                return is_numeric( $value ) && $value >= 1 && $value <= 50;
            },
        ),
    ),
) );

validate_callback decides whether the input is acceptable and returns a helpful 400 error if not. sanitize_callback cleans the value once it is accepted. Doing both is the difference between a hobby endpoint and a production one.

Reading the value inside your callback is just as simple:

$limit = (int) $request->get_param( 'limit' );

Step 5: Control who can access the endpoint

Every route should declare a permission_callback. Leaving it empty produces a warning in debug mode and, more importantly, leaves your data open. There are three common patterns:

// 1. Public endpoint - anyone may read.
'permission_callback' => '__return_true',

// 2. Logged-in users only.
'permission_callback' => function () {
    return is_user_logged_in();
},

// 3. Users with a specific capability.
'permission_callback' => function () {
    return current_user_can( 'edit_posts' );
},

Use the least privilege that solves your problem. A read-only endpoint that publishes public content can be public. Anything that writes data, changes settings, or reveals customer information must check a capability.

Step 6: Accept data with a POST route

Reading data is one half of an API; accepting it is the other. Here is a secure contact endpoint that validates an email address and a message before doing anything with them:

register_rest_route( 'lwp/v1', '/inquiry', array(
    'methods'             => 'POST',
    'callback'            => 'lwp_create_inquiry',
    'permission_callback' => '__return_true',
    'args'                => array(
        'email'   => array(
            'required'          => true,
            'validate_callback' => 'is_email',
            'sanitize_callback' => 'sanitize_email',
        ),
        'message' => array(
            'required'          => true,
            'sanitize_callback' => 'sanitize_textarea_field',
        ),
    ),
) );

function lwp_create_inquiry( WP_REST_Request $request ) {

    $email   = $request->get_param( 'email' );
    $message = $request->get_param( 'message' );

    // Store the inquiry as a private custom post, or hand it to your CRM.
    $post_id = wp_insert_post( array(
        'post_type'   => 'page',
        'post_status' => 'private',
        'post_title'  => 'Inquiry from ' . $email,
        'post_content'=> $message,
    ) );

    return new WP_REST_Response(
        array( 'accepted' => true, 'id' => $post_id ),
        201
    );
}

Notice the status code: 201 Created tells the client that something new now exists. Returning 200 for a creation is a small detail that makes your API feel professional.

Step 7: Query WordPress content safely

Most custom endpoints return content that already lives in WordPress. Use WP_Query or get_posts() rather than writing raw SQL, and always cap the number of results:

$query = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => $limit,
    'no_found_rows'  => true, // Faster when you do not need pagination.
) );

$items = array();

foreach ( $query->posts as $post ) {
    $items[] = array(
        'id'    => $post->ID,
        'title' => get_the_title( $post ),
        'url'   => get_permalink( $post ),
        'date'  => get_the_date( 'c', $post ),
    );
}

return new WP_REST_Response( $items, 200 );

Only expose the fields a client actually needs. Every extra field is extra bandwidth and, potentially, extra information you did not mean to publish.

Step 8: Handle errors the right way

Well-behaved APIs return structured errors instead of a blank page. Use WP_Error with a machine-readable code and an HTTP status:

if ( empty( $items ) ) {
    return new WP_Error(
        'lwp_no_tutorials',
        'No tutorials matched your request.',
        array( 'status' => 404 )
    );
}

The client receives a consistent JSON error object it can branch on, and you get a message you can actually debug.

Step 9: Test your endpoint

Testing early saves hours. Start in the browser, then move to the command line for authenticated routes:

# Public GET request
curl -s https://your-site.com/wp-json/lwp/v1/tutorials?limit=3

# Authenticated request using an Application Password
curl -u 'username:xxxx xxxx xxxx xxxx' \
     https://your-site.com/wp-json/lwp/v1/tutorials

# POST request with a JSON body
curl -X POST https://your-site.com/wp-json/lwp/v1/inquiry \
     -H 'Content-Type: application/json' \
     -d '{"email":"buyer@example.com","message":"Please send a quote."}'

For anything beyond simple reads, use Application Passwords. They are created on the user profile screen, are revocable at any time, and keep your real account password out of scripts. On a live site, always test over HTTPS so credentials are never sent in the clear.

Step 10: Secure and harden your API

A custom endpoint is an interface to your business, so treat it with the same care as a login form:

  • Never leave permission_callback empty. Decide deliberately who may reach each route.
  • Validate and sanitize every parameter. Trust nothing that arrives from the network.
  • Use nonces for cookie-based writes. Browser sessions need protection against cross-site request forgery.
  • Prefer Application Passwords for server-to-server calls. Store them in environment variables, not in public code.
  • Avoid leaking private data. Return IDs and titles, not unpublished drafts or user meta.
  • Rate-limit expensive routes. A lightweight transient counter stops abuse of heavy queries.
  • Keep WordPress, your theme and your plugins updated. Most incidents start with an outdated component.

Putting it together: a real-world endpoint

Here is the complete lwp_get_tutorials callback combining everything above: a validated limit, a safe query, trimmed fields, and proper error handling.

function lwp_get_tutorials( WP_REST_Request $request ) {

    $limit = (int) $request->get_param( 'limit' );

    $query = new WP_Query( array(
        'post_type'      => 'post',
        'posts_per_page' => $limit,
        'no_found_rows'  => true,
    ) );

    if ( empty( $query->posts ) ) {
        return new WP_Error(
            'lwp_no_tutorials',
            'No tutorials matched your request.',
            array( 'status' => 404 )
        );
    }

    $items = array();

    foreach ( $query->posts as $post ) {
        $items[] = array(
            'id'    => $post->ID,
            'title' => get_the_title( $post ),
            'url'   => get_permalink( $post ),
            'date'  => get_the_date( 'c', $post ),
        );
    }

    return new WP_REST_Response( $items, 200 );
}

That single function is a stable contract. A mobile app, a landing page, or a partner system can depend on it, and you can change how the data is produced behind the scenes without breaking any client.

Best practices and common pitfalls

  • Version your namespace from day one. lwp/v1 today means lwp/v2 tomorrow without a breaking change.
  • Keep endpoints focused. One route, one job. Combine concerns and every change becomes risky.
  • Return consistent shapes. If clients must guess whether they get an array or an object, you will get bug reports.
  • Document your routes. A short README with the URL, method, parameters and an example response saves everyone time.
  • Do not forget caching. Read-heavy public endpoints benefit from a short-lived cache to protect your database.
  • Watch the status codes. 200 for success, 201 for creation, 400 for bad input, 401 and 403 for auth, 404 for missing, 500 for server errors.
  • Test with real data. Empty tables hide bugs that only appear once there is content to return.

Frequently asked questions

Do I need to edit WordPress core to add an endpoint?

No, and you never should. Endpoints are added with the register_rest_route() function from your own plugin or child theme, so core stays untouched and updates never overwrite your work.

Is the WordPress REST API enabled by default?

Yes. Since WordPress 4.7 the REST API is part of core and active on every site. You only need to register your own routes; the infrastructure is already there.

Will my custom endpoint survive theme and plugin updates?

It will, as long as the code lives in a plugin or a child theme. That is exactly why this tutorial places the logic in a small dedicated plugin rather than in the active theme.

How do I remove an endpoint later?

Delete or comment out the corresponding register_rest_route() call. If you release a new version, keep the old route for a deprecation period so existing clients do not break overnight.

Can I use custom endpoints for a headless WordPress site?

Absolutely. A headless front end reads content through the API and renders it however it likes. Custom endpoints let you shape responses for your design, which keeps the front end fast and the WordPress side clean.

How do I authenticate external requests?

Use Application Passwords for server-to-server calls and nonces for requests that come from a logged-in browser session. Both are built into WordPress and require no third-party service.

Wrapping up

A WordPress custom API endpoint is one of the most useful skills you can add to your toolkit. With a single register_rest_route() call you turned a closed website into a programmable platform that other systems can build on. Register the route, validate and sanitize the input, lock down permissions, handle errors, and test with curl: repeat those five steps and you can expose anything your project needs.

If you would like help designing or scaling an integration like this for your business, our team at our website studio builds and optimizes WordPress sites for foreign trade companies. You can also browse the growing developer tools tutorials or pair this guide with our Advanced Custom Fields tutorial to structure the data your endpoints return. And if anything here is unclear, send us a message; we read every one.

Leave a Reply

Your email address will not be published. Required fields are marked *