/
/
Building a gallery API endpoint with the WordPress REST API

Building a gallery API endpoint with the WordPress REST API

9 min read

register_rest_route, permission_callback, and arg sanitisation — the three things a custom REST endpoint must get right. A working gallery endpoint, built properly.

A custom REST endpoint is how you expose WordPress data to anything that is not WordPress — a headless front end, a mobile app, a partner integration, a scheduled sync. A gallery is a good thing to expose this way: a front-end app can fetch a gallery’s items as JSON and render them however it likes, with WordPress as the content store. But a REST endpoint is also the single easiest place to ship a security hole, because a route with a careless permission check is a public door into your data. This is how to build a custom gallery endpoint the right way: the route, the permission callback that actually guards it, the argument sanitisation that keeps input clean, and the authentication a real client uses.

It is the REST deep-dive of our developer’s guide to building galleries, and pairs with the block-building piece — a dynamic block reads from the same kind of endpoint you are about to build.

The three jobs of an endpoint

Every custom REST route does three things, and getting any one wrong causes a different class of bug. It routes: a namespace and a path map a URL to your code. It authorises: a permission callback decides who is allowed to call it. And it handles: a callback runs, reads sanitised arguments, and returns a response. The mental model that keeps endpoints safe is to treat these as three separate responsibilities — routing is plumbing, the permission callback is the lock, and argument handling is hygiene. Most insecure endpoints fail because someone treated the lock as optional.

Registering the route

Routes are registered on the rest_api_init hook with register_rest_route(). You give it a namespace (always vendor/version, so you can ship a v2 later without breaking v1), a route pattern, and an array describing the method, the callback, the permission callback, and the arguments. Here is a read endpoint that returns a list of galleries — the same shape FotoGrids uses for its own fotogrids/v1/galleries route:

add_action( 'rest_api_init', function () {
    register_rest_route( 'myplugin/v1', '/galleries', array(
        'methods'             => WP_REST_Server::READABLE, // GET
        'callback'            => 'myplugin_get_galleries',
        'permission_callback' => 'myplugin_can_read_galleries',
        'args'                => array(
            'per_page' => array(
                'default'           => 20,
                'sanitize_callback' => 'absint',
            ),
            'page' => array(
                'default'           => 1,
                'sanitize_callback' => 'absint',
            ),
            'search' => array(
                'default'           => '',
                'sanitize_callback' => 'sanitize_text_field',
            ),
        ),
    ) );
} );

Two details matter here. WP_REST_Server::READABLE is the constant for GET — there are matching constants for the other verbs (CREATABLE for POST, EDITABLE for PUT/PATCH, DELETABLE for DELETE), and using the constant rather than a raw string is the convention. And every argument carries a sanitize_callback: absint coerces page numbers to non-negative integers, sanitize_text_field strips tags from the search string. Arguments declared this way arrive at your callback already cleaned, which is the whole point of declaring them.

The permission callback is not optional

This is the line that gets skipped and the line that matters most. permission_callback runs before your handler and decides whether the request is allowed. Omit it and modern WordPress throws a notice and treats the route as misconfigured; set it to __return_true without thinking and you have built a public endpoint — fine for genuinely public data, a serious leak for anything else. For an endpoint that should only serve logged-in users with the right capability, the callback checks exactly that:

function myplugin_can_read_galleries( WP_REST_Request $request ) {
    // Only users who can edit posts may list galleries through this route.
    return current_user_can( 'edit_posts' );
}

The permission callback receives the full request, so you can make the decision as specific as you need — check a capability, verify the user owns the gallery they are asking about, confirm a gallery is not password-protected before returning its items. FotoGrids separates this concern cleanly: its routes point permission_callback at a dedicated permissions class (a check_gallery_read method) rather than inlining the logic, which keeps the authorisation rules in one auditable place. For a public-by-design endpoint, returning true is legitimate — but make that a decision you wrote down, not a default you forgot to change.

Do this now. Open every register_rest_route call in your codebase and check each one has a deliberate permission_callback. Any route set to __return_true is serving its data to the entire internet — confirm that is intended. Any route missing the callback entirely is a bug WordPress will flag. This five-minute audit catches the most common and most damaging REST mistake before it ships.

The handler: read, work, respond

The callback receives the sanitised request, does its work, and returns either a WP_REST_Response (success) or a WP_Error (failure, with an HTTP status). Because the arguments were sanitised at registration, the handler reads them directly with $request->get_param() and trusts them:

function myplugin_get_galleries( WP_REST_Request $request ) {
    $per_page = $request->get_param( 'per_page' ); // already an int via absint
    $page     = $request->get_param( 'page' );
    $search   = $request->get_param( 'search' );

    $query = new WP_Query( array(
        'post_type'      => 'fotogrids_gallery',
        'posts_per_page' => $per_page,
        'paged'          => $page,
        's'              => $search,
    ) );

    if ( ! $query->have_posts() ) {
        return new WP_Error(
            'no_galleries',
            'No galleries found.',
            array( 'status' => 404 )
        );
    }

    $data = array_map( function ( $post ) {
        return array(
            'id'    => $post->ID,
            'title' => get_the_title( $post ),
            'link'  => get_permalink( $post ),
        );
    }, $query->posts );

    return new WP_REST_Response( $data, 200 );
}

Returning WP_Error with a status array is how you produce a proper HTTP error — a 404 here, a 403 for a permission failure, a 400 for bad input — instead of a 200 with an empty body that a client cannot distinguish from “nothing matched”. Good error responses are what separate an endpoint a client can build against from one that fails silently.

Route parameters and validation

For a single-gallery endpoint you capture the ID from the URL with a named regex group, then both sanitise and validate it. Sanitisation cleans the value; validation rejects values that are clean but wrong — a negative ID is a valid integer and a nonsense gallery:

register_rest_route( 'myplugin/v1', '/galleries/(?P<id>\d+)/items', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'myplugin_get_gallery_items',
    'permission_callback' => 'myplugin_can_read_galleries',
    'args'                => array(
        'id' => array(
            'required'          => true,
            'sanitize_callback' => 'absint',
            'validate_callback' => function ( $param ) {
                return is_numeric( $param ) && $param > 0;
            },
        ),
    ),
) );

This mirrors FotoGrids’ own fotogrids/v1/galleries/(?P<id>\d+)/items route, which uses exactly this required + sanitize_callback + validate_callback trio on its id argument. The pattern is worth internalising: capture in the route regex, sanitise to the right type, validate the cleaned value, and let WordPress return the 400 automatically when validation fails. You never have to write “if the ID is bad, bail” inside your handler — the args layer does it before the handler runs.

Authenticating a real client

A browser calling your own site’s REST API authenticates with a nonce, sent in the X-WP-Nonce header — that is how a Gutenberg block or an admin screen talks to its endpoints. But an external client — a headless front end on another domain, a server-side sync, a mobile app — has no cookie and no nonce, and for those, Application Passwords are the built-in answer. A site admin generates an application password for a user (under Users → Profile), and the external client sends it with HTTP Basic auth over HTTPS:

GET /wp-json/myplugin/v1/galleries HTTP/1.1
Host: your-site.example
Authorization: Basic <base64 of "username:app password">

In other words: the WordPress username and the application password (the spaces in it are fine) are joined with a colon, base64-encoded, and sent in the Authorization: Basic header — standard HTTP Basic auth, which every HTTP client and request library can produce for you. The application password takes the place of the user’s real password in that header.

The application password is scoped to one user, so the request runs with that user’s capabilities — meaning your permission_callback is enforced for external clients too, exactly as it is for logged-in browser requests. It can be revoked independently of the user’s real password, which makes it safe to hand to a single integration and kill later. The one non-negotiable is HTTPS: Application Passwords travel in the request and must never cross plain HTTP. (FotoGrids licenses through Freemius and ties activations to the domain plus a per-install UUID, but that is licensing, separate from REST auth — your endpoint’s security is the permission callback plus Application Passwords, nothing FotoGrids-specific.)

Key takeaways

  • Every endpoint routes, authorises, and handles — keep the three responsibilities separate.
  • Namespace as vendor/version so you can ship a v2 without breaking v1.
  • A deliberate permission_callback is the single most important line — never leave it as a forgotten __return_true.
  • Sanitise to clean input and validate to reject clean-but-wrong input; let the args layer return the 400.
  • External clients authenticate with Application Passwords over HTTPS; the same permission callback guards them.

Do I need a permission_callback on a custom REST route?

Yes. Modern WordPress treats a route with no permission_callback as misconfigured and throws a notice. The callback decides who may call the route — set it to a real capability check for protected data, or deliberately to __return_true for genuinely public data. The danger is leaving it as a default __return_true you never meant, which exposes the endpoint to everyone.

What is the difference between sanitize_callback and validate_callback?

sanitize_callback cleans a value into the right shape — absint turns input into a non-negative integer, sanitize_text_field strips tags. validate_callback rejects values that are clean but invalid, like a negative ID or an out-of-range number. Sanitise first, validate second; when validation fails, WordPress returns a 400 before your handler runs.

How does an external app authenticate to a custom WordPress REST endpoint?

Use Application Passwords. An admin generates one for a user under Users → Profile, and the external client sends it with HTTP Basic auth over HTTPS. The request runs with that user’s capabilities, so your permission_callback still applies, and the password can be revoked without changing the user’s real password. Always use HTTPS — the credential travels in the request.

FotoGrids developer docs

Hooks, REST, and the architecture.

The full developer reference — the fotogrids/ hook namespace, the Free-to-Pro filter-gated architecture, REST route patterns, and the block APIs — in the docs.

  • Slash-namespaced action and filter reference.
  • Free/Pro architecture and licensing.
  • Custom block and REST patterns.

In this article

Share the article
Related Articles