Extend the WooCommerce REST API with custom endpoints for mobile apps

How to Extend the WooCommerce REST API with Custom Endpoints for Mobile Apps

Mobile commerce is no longer a secondary channel. For most WooCommerce stores, mobile apps now drive the majority of transactions, and the quality of the API layer sitting between your store and those apps determines everything from load times to conversion rates. The default WooCommerce REST API covers standard CRUD operations for products, orders, and customers, but real-world mobile apps almost always need something more: aggregated dashboard data, custom loyalty endpoints, filtered product feeds optimized for bandwidth, or hybrid endpoints that pull from multiple resources in a single request.

This is the third article in our Custom WooCommerce Development series (3/7). In the previous article on custom product types, we built entirely new product architectures. Now we turn our attention to the API layer that exposes your store data to the outside world, specifically to mobile clients that have very different requirements from browser-based storefronts.

In this guide, we will cover every aspect of building production-grade WooCommerce REST API custom endpoints: registering routes, building proper REST controllers, handling authentication across multiple strategies, formatting responses for mobile consumption, implementing rate limiting, optimizing payloads with field selection and caching headers, versioning your endpoints, and handling errors gracefully. Every code snippet is complete and tested against WooCommerce 8.x and WordPress 6.x.

Understanding the WooCommerce REST API Architecture

Before writing any custom endpoint, you need to understand how the WooCommerce REST API is layered on top of the WordPress REST API infrastructure. WooCommerce does not reinvent the wheel. It uses register_rest_route() under the hood, extends WP_REST_Controller for its endpoint classes, and follows the same permission callback and schema patterns that WordPress core uses.

The WooCommerce REST API lives under the /wp-json/wc/v3/ namespace. When you build custom endpoints for your mobile app, you have two choices: register them under the WooCommerce namespace (if they are tightly coupled to WooCommerce data) or create your own namespace (if they aggregate data from multiple sources). For most mobile app backends, a custom namespace like /wp-json/myapp/v1/ is the better choice because it gives you full control over versioning and does not conflict with WooCommerce core updates.

The key classes and functions you will work with are:

  • register_rest_route() — the WordPress function that registers a new REST endpoint
  • WP_REST_Controller — the abstract base class for building structured REST controllers
  • WP_REST_Request — the object representing an incoming API request
  • WP_REST_Response — the object you return from your endpoint callbacks
  • WP_REST_Server — the server class that dispatches requests to the right route

The WordPress REST API Handbook is the definitive reference for these primitives. Everything we build in this guide sits on top of that foundation.

Registering Custom REST Routes for WooCommerce

The simplest way to add a WooCommerce REST API custom endpoint is with register_rest_route(). Let us start with a basic example: an endpoint that returns a mobile-optimized product feed.

Basic Route Registration

<?php
/**
 * Plugin Name: WooCustomDev Mobile API
 * Description: Custom WooCommerce REST API endpoints optimized for mobile apps.
 * Version: 1.0.0
 * Requires at least: 6.4
 * Requires PHP: 8.1
 */

defined( 'ABSPATH' ) || exit;

add_action( 'rest_api_init', 'woocustomdev_register_mobile_routes' );

/**
 * Register custom REST API routes for the mobile app.
 *
 * @return void
 */
function woocustomdev_register_mobile_routes(): void {
    // Mobile product feed -- lightweight product listing.
    register_rest_route(
        'myapp/v1',
        '/products/feed',
        array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'woocustomdev_get_product_feed',
            'permission_callback' => 'woocustomdev_check_api_access',
            'args'                => woocustomdev_get_feed_args(),
        )
    );

    // Single product detail -- enriched data for product screens.
    register_rest_route(
        'myapp/v1',
        '/products/(?P<id>[\d]+)/detail',
        array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'woocustomdev_get_product_detail',
            'permission_callback' => 'woocustomdev_check_api_access',
            'args'                => array(
                'id' => array(
                    'description'       => 'Product ID.',
                    'type'              => 'integer',
                    'required'          => true,
                    'validate_callback' => function ( $param ) {
                        return is_numeric( $param ) && (int) $param > 0;
                    },
                    'sanitize_callback' => 'absint',
                ),
            ),
        )
    );

    // Customer dashboard -- aggregated data for the app home screen.
    register_rest_route(
        'myapp/v1',
        '/customer/dashboard',
        array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'woocustomdev_get_customer_dashboard',
            'permission_callback' => 'woocustomdev_check_customer_access',
        )
    );
}

There are several things worth noting in this registration code. First, we use WP_REST_Server::READABLE instead of the string 'GET'. This is a WordPress constant that maps to the GET method and is the recommended approach. Second, every route has a permission_callback. Never use '__return_true' in production — we will build proper permission checks shortly. Third, we define argument schemas with validation and sanitization callbacks. This is not optional for production endpoints; it is your first line of defense against malformed input.

Defining Endpoint Arguments with Validation

Mobile apps send query parameters for filtering, pagination, and field selection. Here is a thorough argument definition for the product feed endpoint:

<?php
/**
 * Get argument schema for the product feed endpoint.
 *
 * @return array<string, array>
 */
function woocustomdev_get_feed_args(): array {
    return array(
        'page'     => array(
            'description'       => 'Current page of the feed.',
            'type'              => 'integer',
            'default'           => 1,
            'minimum'           => 1,
            'sanitize_callback' => 'absint',
        ),
        'per_page' => array(
            'description'       => 'Number of products per page.',
            'type'              => 'integer',
            'default'           => 20,
            'minimum'           => 1,
            'maximum'           => 100,
            'sanitize_callback' => 'absint',
        ),
        'category' => array(
            'description'       => 'Filter by product category slug.',
            'type'              => 'string',
            'sanitize_callback' => 'sanitize_title',
        ),
        'fields'   => array(
            'description'       => 'Comma-separated list of fields to include in the response.',
            'type'              => 'string',
            'sanitize_callback' => 'sanitize_text_field',
            'validate_callback' => function ( $param ) {
                $allowed = array( 'id', 'name', 'price', 'image', 'rating', 'stock_status', 'slug', 'short_description' );
                $fields  = array_map( 'trim', explode( ',', $param ) );
                foreach ( $fields as $field ) {
                    if ( ! in_array( $field, $allowed, true ) ) {
                        return new WP_Error(
                            'invalid_field',
                            sprintf( 'Field "%s" is not allowed. Allowed fields: %s', $field, implode( ', ', $allowed ) ),
                            array( 'status' => 400 )
                        );
                    }
                }
                return true;
            },
        ),
        'orderby'  => array(
            'description' => 'Sort products by this field.',
            'type'        => 'string',
            'default'     => 'date',
            'enum'        => array( 'date', 'price', 'popularity', 'rating', 'title' ),
        ),
        'order'    => array(
            'description' => 'Sort direction.',
            'type'        => 'string',
            'default'     => 'desc',
            'enum'        => array( 'asc', 'desc' ),
        ),
    );
}

The enum property is particularly useful because the REST API infrastructure automatically rejects requests with values outside the enum, returning a clear error message without you writing any validation logic. The minimum and maximum properties do the same for numeric arguments. Together with explicit validate_callback functions for complex rules, you get a robust input validation layer that mobile app developers can rely on.

Authentication Methods for Mobile Apps

Authentication is the most critical decision you will make when building WooCommerce REST API custom endpoints for mobile apps. There are three primary strategies, each with distinct tradeoffs for mobile use cases.

Application Passwords (WordPress Native)

Since WordPress 5.6, application passwords are built into core. They are the simplest authentication method and work well for server-to-server communication or admin-level mobile apps (like a store management app). The credentials are sent via HTTP Basic Authentication.

<?php
/**
 * Permission callback for API access using application passwords.
 *
 * @param WP_REST_Request $request The incoming request.
 * @return bool|WP_Error
 */
function woocustomdev_check_api_access( WP_REST_Request $request ) {
    // WordPress automatically authenticates application passwords
    // via the Authorization header. We just need to check the result.
    $user = wp_get_current_user();

    if ( 0 === $user->ID ) {
        return new WP_Error(
            'rest_not_authenticated',
            'Authentication is required to access this endpoint.',
            array( 'status' => 401 )
        );
    }

    // For read-only product endpoints, any authenticated user is fine.
    return true;
}

/**
 * Permission callback for customer-specific endpoints.
 *
 * @param WP_REST_Request $request The incoming request.
 * @return bool|WP_Error
 */
function woocustomdev_check_customer_access( WP_REST_Request $request ) {
    $user = wp_get_current_user();

    if ( 0 === $user->ID ) {
        return new WP_Error(
            'rest_not_authenticated',
            'You must be logged in to access your dashboard.',
            array( 'status' => 401 )
        );
    }

    // Ensure the user is a WooCommerce customer.
    if ( ! in_array( 'customer', $user->roles, true ) && ! current_user_can( 'manage_woocommerce' ) ) {
        return new WP_Error(
            'rest_forbidden',
            'This endpoint is only available to customers.',
            array( 'status' => 403 )
        );
    }

    return true;
}

The mobile app sends application passwords in the Authorization header as a Base64-encoded username:application_password pair. This is straightforward but has a drawback: you are sending credentials with every request, which is less secure than token-based approaches for consumer-facing mobile apps.

JWT Authentication

JSON Web Tokens are the industry standard for mobile app authentication. The flow is: the user logs in once with username and password, receives a JWT, and then includes that token in the Authorization header for subsequent requests. The token has an expiration time, which limits the damage if it is compromised.

Here is a complete JWT authentication implementation for WooCommerce:

<?php
/**
 * JWT Authentication handler for the mobile API.
 */
class WooCustomDev_JWT_Auth {

    /**
     * Secret key for signing tokens.
     *
     * @var string
     */
    private string $secret_key;

    /**
     * Token expiration in seconds (default: 7 days).
     *
     * @var int
     */
    private int $token_expiration = 604800;

    /**
     * Refresh token expiration in seconds (default: 30 days).
     *
     * @var int
     */
    private int $refresh_expiration = 2592000;

    /**
     * Constructor.
     */
    public function __construct() {
        $this->secret_key = defined( 'WOOCUSTOMDEV_JWT_SECRET' )
            ? WOOCUSTOMDEV_JWT_SECRET
            : wp_salt( 'auth' );
    }

    /**
     * Initialize hooks.
     *
     * @return void
     */
    public function init(): void {
        add_action( 'rest_api_init', array( $this, 'register_auth_routes' ) );
        add_filter( 'determine_current_user', array( $this, 'authenticate_token' ), 20 );
    }

    /**
     * Register authentication endpoints.
     *
     * @return void
     */
    public function register_auth_routes(): void {
        register_rest_route(
            'myapp/v1',
            '/auth/login',
            array(
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => array( $this, 'handle_login' ),
                'permission_callback' => '__return_true',
                'args'                => array(
                    'username' => array(
                        'required'          => true,
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_user',
                    ),
                    'password' => array(
                        'required' => true,
                        'type'     => 'string',
                    ),
                ),
            )
        );

        register_rest_route(
            'myapp/v1',
            '/auth/refresh',
            array(
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => array( $this, 'handle_refresh' ),
                'permission_callback' => '__return_true',
                'args'                => array(
                    'refresh_token' => array(
                        'required' => true,
                        'type'     => 'string',
                    ),
                ),
            )
        );

        register_rest_route(
            'myapp/v1',
            '/auth/revoke',
            array(
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => array( $this, 'handle_revoke' ),
                'permission_callback' => function () {
                    return is_user_logged_in();
                },
            )
        );
    }

    /**
     * Handle login request and issue tokens.
     *
     * @param WP_REST_Request $request The login request.
     * @return WP_REST_Response|WP_Error
     */
    public function handle_login( WP_REST_Request $request ) {
        $username = $request->get_param( 'username' );
        $password = $request->get_param( 'password' );

        $user = wp_authenticate( $username, $password );

        if ( is_wp_error( $user ) ) {
            return new WP_Error(
                'invalid_credentials',
                'The username or password you entered is incorrect.',
                array( 'status' => 401 )
            );
        }

        $access_token  = $this->generate_token( $user->ID, 'access' );
        $refresh_token = $this->generate_token( $user->ID, 'refresh' );

        // Store the refresh token hash for revocation support.
        $stored_tokens   = get_user_meta( $user->ID, '_woocustomdev_refresh_tokens', true );
        $stored_tokens   = is_array( $stored_tokens ) ? $stored_tokens : array();
        $stored_tokens[] = wp_hash( $refresh_token );

        // Keep only the last 5 refresh tokens per user.
        $stored_tokens = array_slice( $stored_tokens, -5 );
        update_user_meta( $user->ID, '_woocustomdev_refresh_tokens', $stored_tokens );

        return new WP_REST_Response(
            array(
                'access_token'  => $access_token,
                'refresh_token' => $refresh_token,
                'expires_in'    => $this->token_expiration,
                'token_type'    => 'Bearer',
                'user'          => array(
                    'id'           => $user->ID,
                    'email'        => $user->user_email,
                    'display_name' => $user->display_name,
                    'role'         => $user->roles[0] ?? 'customer',
                ),
            ),
            200
        );
    }

    /**
     * Handle token refresh.
     *
     * @param WP_REST_Request $request The refresh request.
     * @return WP_REST_Response|WP_Error
     */
    public function handle_refresh( WP_REST_Request $request ) {
        $refresh_token = $request->get_param( 'refresh_token' );
        $payload       = $this->decode_token( $refresh_token );

        if ( is_wp_error( $payload ) ) {
            return $payload;
        }

        if ( 'refresh' !== ( $payload['type'] ?? '' ) ) {
            return new WP_Error(
                'invalid_token_type',
                'A refresh token is required.',
                array( 'status' => 401 )
            );
        }

        // Verify the refresh token has not been revoked.
        $stored_tokens = get_user_meta( $payload['user_id'], '_woocustomdev_refresh_tokens', true );
        $stored_tokens = is_array( $stored_tokens ) ? $stored_tokens : array();
        $token_hash    = wp_hash( $refresh_token );

        if ( ! in_array( $token_hash, $stored_tokens, true ) ) {
            return new WP_Error(
                'token_revoked',
                'This refresh token has been revoked.',
                array( 'status' => 401 )
            );
        }

        $new_access_token = $this->generate_token( $payload['user_id'], 'access' );

        return new WP_REST_Response(
            array(
                'access_token' => $new_access_token,
                'expires_in'   => $this->token_expiration,
                'token_type'   => 'Bearer',
            ),
            200
        );
    }

    /**
     * Handle token revocation (logout).
     *
     * @param WP_REST_Request $request The revoke request.
     * @return WP_REST_Response
     */
    public function handle_revoke( WP_REST_Request $request ): WP_REST_Response {
        $user_id = get_current_user_id();
        delete_user_meta( $user_id, '_woocustomdev_refresh_tokens' );

        return new WP_REST_Response(
            array( 'message' => 'All tokens have been revoked.' ),
            200
        );
    }

    /**
     * Generate a JWT token.
     *
     * @param int    $user_id   The user ID.
     * @param string $type      Token type: 'access' or 'refresh'.
     * @return string
     */
    private function generate_token( int $user_id, string $type ): string {
        $expiration = 'refresh' === $type ? $this->refresh_expiration : $this->token_expiration;
        $issued_at  = time();

        $payload = array(
            'iss'     => get_bloginfo( 'url' ),
            'iat'     => $issued_at,
            'exp'     => $issued_at + $expiration,
            'user_id' => $user_id,
            'type'    => $type,
        );

        $header  = $this->base64url_encode( wp_json_encode( array( 'alg' => 'HS256', 'typ' => 'JWT' ) ) );
        $payload = $this->base64url_encode( wp_json_encode( $payload ) );
        $signature = $this->base64url_encode(
            hash_hmac( 'sha256', "{$header}.{$payload}", $this->secret_key, true )
        );

        return "{$header}.{$payload}.{$signature}";
    }

    /**
     * Decode and validate a JWT token.
     *
     * @param string $token The JWT token.
     * @return array|WP_Error
     */
    private function decode_token( string $token ) {
        $parts = explode( '.', $token );

        if ( 3 !== count( $parts ) ) {
            return new WP_Error( 'invalid_token', 'Malformed token.', array( 'status' => 401 ) );
        }

        list( $header, $payload, $signature ) = $parts;

        // Verify signature.
        $expected_signature = $this->base64url_encode(
            hash_hmac( 'sha256', "{$header}.{$payload}", $this->secret_key, true )
        );

        if ( ! hash_equals( $expected_signature, $signature ) ) {
            return new WP_Error( 'invalid_signature', 'Token signature is invalid.', array( 'status' => 401 ) );
        }

        $decoded = json_decode( $this->base64url_decode( $payload ), true );

        if ( null === $decoded ) {
            return new WP_Error( 'invalid_payload', 'Token payload could not be decoded.', array( 'status' => 401 ) );
        }

        // Check expiration.
        if ( isset( $decoded['exp'] ) && $decoded['exp'] < time() ) {
            return new WP_Error( 'token_expired', 'Token has expired.', array( 'status' => 401 ) );
        }

        // Verify issuer.
        if ( get_bloginfo( 'url' ) !== ( $decoded['iss'] ?? '' ) ) {
            return new WP_Error( 'invalid_issuer', 'Token issuer does not match.', array( 'status' => 401 ) );
        }

        return $decoded;
    }

    /**
     * Authenticate requests using Bearer token.
     *
     * @param int|false $user_id The current user ID.
     * @return int|false
     */
    public function authenticate_token( $user_id ) {
        // If already authenticated, do not override.
        if ( $user_id ) {
            return $user_id;
        }

        $auth_header = isset( $_SERVER['HTTP_AUTHORIZATION'] )
            ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) )
            : '';

        // Also check for redirected header.
        if ( empty( $auth_header ) && isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
            $auth_header = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
        }

        if ( empty( $auth_header ) || 0 !== strpos( $auth_header, 'Bearer ' ) ) {
            return $user_id;
        }

        $token   = substr( $auth_header, 7 );
        $payload = $this->decode_token( $token );

        if ( is_wp_error( $payload ) ) {
            return $user_id;
        }

        if ( 'access' !== ( $payload['type'] ?? '' ) ) {
            return $user_id;
        }

        $user = get_user_by( 'ID', $payload['user_id'] );

        if ( ! $user ) {
            return $user_id;
        }

        return $user->ID;
    }

    /**
     * Base64 URL-safe encode.
     *
     * @param string $data The data to encode.
     * @return string
     */
    private function base64url_encode( string $data ): string {
        return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
    }

    /**
     * Base64 URL-safe decode.
     *
     * @param string $data The data to decode.
     * @return string
     */
    private function base64url_decode( string $data ): string {
        return base64_decode( strtr( $data, '-_', '+/' ) );
    }
}

// Initialize JWT authentication.
$woocustomdev_jwt_auth = new WooCustomDev_JWT_Auth();
$woocustomdev_jwt_auth->init();

This implementation includes access tokens with configurable expiration, refresh tokens stored as hashes in user meta for revocation support, proper signature verification using HMAC-SHA256, and a logout endpoint that invalidates all tokens. For production deployments, you should define WOOCUSTOMDEV_JWT_SECRET in your wp-config.php as a strong random string rather than relying on the WordPress auth salt.

OAuth 1.0a (WooCommerce Built-in)

WooCommerce ships with OAuth 1.0a authentication using consumer key and consumer secret pairs. This is primarily designed for server-to-server integrations but can be used for mobile apps that act as trusted first-party clients. The WooCommerce REST API authentication documentation covers the setup process. For most mobile app scenarios, JWT is the better choice because it avoids the complexity of OAuth signature generation on the client side.

Building Custom REST Controllers

For anything beyond trivial endpoints, you should use a controller class that extends WP_REST_Controller. This gives you a structured way to organize your routes, define schemas, handle permissions, and format responses. It is how WooCommerce itself organizes its API endpoints, and following the same pattern makes your code maintainable as your API grows.

A Complete Product Feed Controller

<?php
/**
 * REST Controller for the mobile product feed.
 *
 * Provides a lightweight, optimized product listing
 * designed for mobile app consumption.
 */
class WooCustomDev_Product_Feed_Controller extends WP_REST_Controller {

    /**
     * Namespace for the API.
     *
     * @var string
     */
    protected $namespace = 'myapp/v1';

    /**
     * Base path for this controller.
     *
     * @var string
     */
    protected $rest_base = 'products/feed';

    /**
     * Allowed fields for sparse fieldsets.
     *
     * @var array<string>
     */
    private array $allowed_fields = array(
        'id',
        'name',
        'slug',
        'price',
        'regular_price',
        'sale_price',
        'on_sale',
        'image',
        'rating',
        'rating_count',
        'stock_status',
        'short_description',
        'categories',
    );

    /**
     * Register the routes for this controller.
     *
     * @return void
     */
    public function register_routes(): void {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_items' ),
                    'permission_callback' => array( $this, 'get_items_permissions_check' ),
                    'args'                => $this->get_collection_params(),
                ),
                'schema' => array( $this, 'get_public_item_schema' ),
            )
        );

        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id>[\d]+)',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_item' ),
                    'permission_callback' => array( $this, 'get_item_permissions_check' ),
                    'args'                => array(
                        'id'     => array(
                            'description' => 'Unique identifier for the product.',
                            'type'        => 'integer',
                            'required'    => true,
                        ),
                        'fields' => array(
                            'description' => 'Comma-separated list of fields to include.',
                            'type'        => 'string',
                        ),
                    ),
                ),
                'schema' => array( $this, 'get_public_item_schema' ),
            )
        );
    }

    /**
     * Check permissions for getting the collection.
     *
     * @param WP_REST_Request $request Full data about the request.
     * @return bool|WP_Error
     */
    public function get_items_permissions_check( $request ) {
        // Public endpoint for product browsing -- authentication optional.
        // Authenticated users get personalized pricing if applicable.
        return true;
    }

    /**
     * Check permissions for getting a single item.
     *
     * @param WP_REST_Request $request Full data about the request.
     * @return bool|WP_Error
     */
    public function get_item_permissions_check( $request ) {
        $product = wc_get_product( $request->get_param( 'id' ) );

        if ( ! $product || 'publish' !== $product->get_status() ) {
            return new WP_Error(
                'product_not_found',
                'The requested product does not exist.',
                array( 'status' => 404 )
            );
        }

        return true;
    }

    /**
     * Get the product feed collection.
     *
     * @param WP_REST_Request $request Full data about the request.
     * @return WP_REST_Response
     */
    public function get_items( $request ): WP_REST_Response {
        $page     = $request->get_param( 'page' );
        $per_page = $request->get_param( 'per_page' );
        $category = $request->get_param( 'category' );
        $orderby  = $request->get_param( 'orderby' );
        $order    = $request->get_param( 'order' );
        $fields   = $this->parse_fields( $request->get_param( 'fields' ) );

        $query_args = array(
            'status'   => 'publish',
            'limit'    => $per_page,
            'page'     => $page,
            'orderby'  => $this->map_orderby( $orderby ),
            'order'    => strtoupper( $order ),
            'return'   => 'objects',
        );

        if ( $category ) {
            $query_args['category'] = array( $category );
        }

        $results  = wc_get_products( $query_args );
        $products = array();

        foreach ( $results as $product ) {
            $data       = $this->prepare_item_for_response( $product, $request );
            $products[] = $this->prepare_response_for_collection( $data );
        }

        // Build total count for pagination headers.
        $count_args            = $query_args;
        $count_args['limit']   = -1;
        $count_args['return']  = 'ids';
        $count_args['page']    = 1;
        $total_products        = count( wc_get_products( $count_args ) );
        $total_pages           = (int) ceil( $total_products / $per_page );

        $response = new WP_REST_Response( $products, 200 );

        // Set pagination headers following WordPress REST API conventions.
        $response->header( 'X-WP-Total', $total_products );
        $response->header( 'X-WP-TotalPages', $total_pages );

        // Add Link headers for pagination.
        $base = add_query_arg(
            urlencode_deep( $request->get_query_params() ),
            rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) )
        );

        if ( $page > 1 ) {
            $prev_link = add_query_arg( 'page', $page - 1, $base );
            $response->link_header( 'prev', $prev_link );
        }

        if ( $page < $total_pages ) {
            $next_link = add_query_arg( 'page', $page + 1, $base );
            $response->link_header( 'next', $next_link );
        }

        return $response;
    }

    /**
     * Get a single product detail.
     *
     * @param WP_REST_Request $request Full data about the request.
     * @return WP_REST_Response|WP_Error
     */
    public function get_item( $request ) {
        $product = wc_get_product( $request->get_param( 'id' ) );

        if ( ! $product ) {
            return new WP_Error(
                'product_not_found',
                'The requested product does not exist.',
                array( 'status' => 404 )
            );
        }

        $data = $this->prepare_item_for_response( $product, $request );

        return $data;
    }

    /**
     * Prepare a single product for the REST response.
     *
     * @param WC_Product      $product The product object.
     * @param WP_REST_Request $request Full data about the request.
     * @return WP_REST_Response
     */
    public function prepare_item_for_response( $product, $request ): WP_REST_Response {
        $fields = $this->parse_fields( $request->get_param( 'fields' ) );

        $data = array();

        // Always include the ID.
        $data['id'] = $product->get_id();

        if ( in_array( 'name', $fields, true ) ) {
            $data['name'] = $product->get_name();
        }

        if ( in_array( 'slug', $fields, true ) ) {
            $data['slug'] = $product->get_slug();
        }

        if ( in_array( 'price', $fields, true ) ) {
            $data['price']         = $product->get_price();
            $data['price_display'] = html_entity_decode( wp_strip_all_tags( wc_price( $product->get_price() ) ) );
        }

        if ( in_array( 'regular_price', $fields, true ) ) {
            $data['regular_price'] = $product->get_regular_price();
        }

        if ( in_array( 'sale_price', $fields, true ) ) {
            $data['sale_price'] = $product->get_sale_price();
        }

        if ( in_array( 'on_sale', $fields, true ) ) {
            $data['on_sale'] = $product->is_on_sale();
        }

        if ( in_array( 'image', $fields, true ) ) {
            $image_id = $product->get_image_id();
            if ( $image_id ) {
                $data['image'] = array(
                    'id'        => $image_id,
                    'thumbnail' => wp_get_attachment_image_url( $image_id, 'woocommerce_thumbnail' ),
                    'medium'    => wp_get_attachment_image_url( $image_id, 'medium' ),
                    'full'      => wp_get_attachment_image_url( $image_id, 'full' ),
                );
            } else {
                $data['image'] = null;
            }
        }

        if ( in_array( 'rating', $fields, true ) ) {
            $data['rating'] = (float) $product->get_average_rating();
        }

        if ( in_array( 'rating_count', $fields, true ) ) {
            $data['rating_count'] = (int) $product->get_rating_count();
        }

        if ( in_array( 'stock_status', $fields, true ) ) {
            $data['stock_status'] = $product->get_stock_status();
        }

        if ( in_array( 'short_description', $fields, true ) ) {
            $data['short_description'] = wp_strip_all_tags( $product->get_short_description() );
        }

        if ( in_array( 'categories', $fields, true ) ) {
            $terms = get_the_terms( $product->get_id(), 'product_cat' );
            $data['categories'] = array();
            if ( $terms && ! is_wp_error( $terms ) ) {
                foreach ( $terms as $term ) {
                    $data['categories'][] = array(
                        'id'   => $term->term_id,
                        'name' => $term->name,
                        'slug' => $term->slug,
                    );
                }
            }
        }

        $response = new WP_REST_Response( $data, 200 );

        // Add ETag for client-side caching.
        $etag = md5( wp_json_encode( $data ) . $product->get_date_modified() );
        $response->header( 'ETag', '"' . $etag . '"' );

        return $response;
    }

    /**
     * Get the query params for collections.
     *
     * @return array<string, array>
     */
    public function get_collection_params(): array {
        return array(
            'page'     => array(
                'description' => 'Current page of the collection.',
                'type'        => 'integer',
                'default'     => 1,
                'minimum'     => 1,
            ),
            'per_page' => array(
                'description' => 'Maximum number of items per page.',
                'type'        => 'integer',
                'default'     => 20,
                'minimum'     => 1,
                'maximum'     => 100,
            ),
            'category' => array(
                'description'       => 'Filter by product category slug.',
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_title',
            ),
            'fields'   => array(
                'description' => 'Comma-separated list of fields to include.',
                'type'        => 'string',
            ),
            'orderby'  => array(
                'description' => 'Sort collection by product attribute.',
                'type'        => 'string',
                'default'     => 'date',
                'enum'        => array( 'date', 'price', 'popularity', 'rating', 'title' ),
            ),
            'order'    => array(
                'description' => 'Order sort attribute ascending or descending.',
                'type'        => 'string',
                'default'     => 'desc',
                'enum'        => array( 'asc', 'desc' ),
            ),
        );
    }

    /**
     * Get the item schema for the controller.
     *
     * @return array
     */
    public function get_item_schema(): array {
        return array(
            '$schema'    => 'http://json-schema.org/draft-04/schema#',
            'title'      => 'product-feed',
            'type'       => 'object',
            'properties' => array(
                'id'                => array(
                    'description' => 'Unique identifier for the product.',
                    'type'        => 'integer',
                    'context'     => array( 'view' ),
                    'readonly'    => true,
                ),
                'name'              => array(
                    'description' => 'Product name.',
                    'type'        => 'string',
                    'context'     => array( 'view' ),
                ),
                'price'             => array(
                    'description' => 'Current product price.',
                    'type'        => 'string',
                    'context'     => array( 'view' ),
                ),
                'price_display'     => array(
                    'description' => 'Formatted price string for display.',
                    'type'        => 'string',
                    'context'     => array( 'view' ),
                ),
                'image'             => array(
                    'description' => 'Product image with multiple sizes.',
                    'type'        => 'object',
                    'context'     => array( 'view' ),
                    'properties'  => array(
                        'id'        => array( 'type' => 'integer' ),
                        'thumbnail' => array( 'type' => 'string', 'format' => 'uri' ),
                        'medium'    => array( 'type' => 'string', 'format' => 'uri' ),
                        'full'      => array( 'type' => 'string', 'format' => 'uri' ),
                    ),
                ),
                'stock_status'      => array(
                    'description' => 'Stock status.',
                    'type'        => 'string',
                    'enum'        => array( 'instock', 'outofstock', 'onbackorder' ),
                    'context'     => array( 'view' ),
                ),
                'rating'            => array(
                    'description' => 'Average product rating.',
                    'type'        => 'number',
                    'context'     => array( 'view' ),
                ),
                'short_description' => array(
                    'description' => 'Product short description (plain text).',
                    'type'        => 'string',
                    'context'     => array( 'view' ),
                ),
            ),
        );
    }

    /**
     * Parse the fields parameter into an array.
     *
     * @param string|null $fields_param The raw fields parameter.
     * @return array<string>
     */
    private function parse_fields( ?string $fields_param ): array {
        if ( empty( $fields_param ) ) {
            return $this->allowed_fields;
        }

        $requested = array_map( 'trim', explode( ',', $fields_param ) );

        return array_intersect( $requested, $this->allowed_fields );
    }

    /**
     * Map the public orderby values to WC query args.
     *
     * @param string $orderby The public orderby value.
     * @return string
     */
    private function map_orderby( string $orderby ): string {
        $map = array(
            'date'       => 'date',
            'price'      => 'price',
            'popularity' => 'popularity',
            'rating'     => 'rating',
            'title'      => 'title',
        );

        return $map[ $orderby ] ?? 'date';
    }
}

// Register the controller.
add_action( 'rest_api_init', function () {
    $controller = new WooCustomDev_Product_Feed_Controller();
    $controller->register_routes();
} );

This controller demonstrates several important patterns. The get_item_schema() method defines a JSON Schema for the response, which enables automatic documentation and client-side validation. The get_collection_params() method defines reusable collection parameters. The prepare_item_for_response() method handles the field selection logic, ensuring that the mobile app only receives the data it actually needs.

Response Formatting for Mobile Consumption

Mobile apps have different response formatting requirements than web applications. Bandwidth is limited, parsing speed matters, and the client often needs data in a specific structure that maps directly to UI components. Here are the key techniques for optimizing your WooCommerce REST API custom endpoints for mobile clients.

Envelope Pattern for Consistent Responses

Wrapping all your API responses in a consistent envelope makes life easier for mobile developers. They can write generic parsing code that handles every endpoint the same way:

<?php
/**
 * Wrap a REST response in a standard envelope.
 *
 * @param mixed  $data    The response data.
 * @param int    $status  HTTP status code.
 * @param array  $meta    Additional metadata.
 * @return WP_REST_Response
 */
function woocustomdev_envelope_response( $data, int $status = 200, array $meta = array() ): WP_REST_Response {
    $envelope = array(
        'success' => $status >= 200 && $status < 300,
        'data'    => $data,
        'meta'    => array_merge(
            array(
                'timestamp' => gmdate( 'c' ),
                'version'   => 'v1',
            ),
            $meta
        ),
    );

    return new WP_REST_Response( $envelope, $status );
}

/**
 * Wrap an error in the standard envelope format.
 *
 * @param string $code    Error code.
 * @param string $message Human-readable error message.
 * @param int    $status  HTTP status code.
 * @param array  $details Additional error details.
 * @return WP_REST_Response
 */
function woocustomdev_error_response( string $code, string $message, int $status = 400, array $details = array() ): WP_REST_Response {
    $envelope = array(
        'success' => false,
        'error'   => array(
            'code'    => $code,
            'message' => $message,
            'details' => $details,
        ),
        'meta'    => array(
            'timestamp' => gmdate( 'c' ),
            'version'   => 'v1',
        ),
    );

    return new WP_REST_Response( $envelope, $status );
}

Optimized Image Handling

One of the biggest bandwidth sinks in mobile API responses is image data. Instead of returning full URLs for every image size WordPress supports, return only the sizes that the mobile app actually uses. The product feed controller above demonstrates this: it returns thumbnail, medium, and full sizes, which map to list views, detail views, and zoom views in a typical mobile app.

You can take this further by generating mobile-specific image sizes:

<?php
/**
 * Register mobile-optimized image sizes.
 *
 * @return void
 */
function woocustomdev_register_mobile_image_sizes(): void {
    // Card thumbnail: used in product grid lists.
    add_image_size( 'mobile_card', 400, 400, true );

    // Detail hero: used at the top of product detail screens.
    add_image_size( 'mobile_hero', 800, 600, false );

    // Gallery thumbnail: used in product image galleries.
    add_image_size( 'mobile_gallery_thumb', 100, 100, true );
}
add_action( 'after_setup_theme', 'woocustomdev_register_mobile_image_sizes' );

Mobile App Data Optimization

The performance gap between a well-optimized and a poorly optimized mobile API is enormous. Users on cellular connections with high latency will feel every unnecessary byte. Here are the optimization techniques that matter most for WooCommerce REST API custom endpoints.

Field Selection (Sparse Fieldsets)

We already implemented field selection in the product feed controller. The key insight is that a product listing screen in a mobile app needs perhaps 6-8 fields, while the full WooCommerce product endpoint returns 50+ fields. Allowing the mobile app to request only the fields it needs reduces payload size by 80% or more.

Here is how the mobile app uses it:

GET /wp-json/myapp/v1/products/feed?fields=id,name,price,image,rating&per_page=20

This returns a compact response that loads quickly even on slow connections. When the user taps a product, the app makes a second request for the full detail:

GET /wp-json/myapp/v1/products/feed/42?fields=id,name,price,regular_price,sale_price,on_sale,image,rating,rating_count,stock_status,short_description,categories

Pagination with Cursor Support

Offset-based pagination (using page and per_page) works for most cases, but for infinite scroll in mobile apps, cursor-based pagination is more reliable. It handles the case where new products are added between page requests without causing duplicates or gaps in the feed:

<?php
/**
 * Add cursor-based pagination support to the product feed.
 *
 * @param WP_REST_Request $request Full data about the request.
 * @return WP_REST_Response
 */
function woocustomdev_get_product_feed_cursor( WP_REST_Request $request ): WP_REST_Response {
    $per_page = $request->get_param( 'per_page' ) ?: 20;
    $cursor   = $request->get_param( 'cursor' ); // Product ID to start after.

    $query_args = array(
        'status'  => 'publish',
        'limit'   => $per_page + 1, // Fetch one extra to detect "has more".
        'orderby' => 'ID',
        'order'   => 'DESC',
        'return'  => 'objects',
    );

    if ( $cursor ) {
        // Only fetch products with an ID less than the cursor.
        $query_args['exclude'] = array(); // Reset.

        // Use a custom meta query via wc_get_products filter.
        add_filter( 'woocommerce_product_data_store_cpt_get_products_query', function ( $wp_query_args ) use ( $cursor ) {
            global $wpdb;
            $wp_query_args['where'] = isset( $wp_query_args['where'] )
                ? $wp_query_args['where']
                : '';
            // We need to hook into the posts_where to add our cursor condition.
            add_filter( 'posts_where', function ( $where ) use ( $cursor, $wpdb ) {
                $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $cursor );
                return $where;
            }, 10 );

            return $wp_query_args;
        } );
    }

    $results  = wc_get_products( $query_args );
    $has_more = count( $results ) > $per_page;

    if ( $has_more ) {
        array_pop( $results ); // Remove the extra item.
    }

    $products = array();
    foreach ( $results as $product ) {
        $products[] = array(
            'id'    => $product->get_id(),
            'name'  => $product->get_name(),
            'price' => $product->get_price(),
            'image' => wp_get_attachment_image_url( $product->get_image_id(), 'mobile_card' ),
        );
    }

    $next_cursor = ! empty( $products ) ? end( $products )['id'] : null;

    return woocustomdev_envelope_response(
        $products,
        200,
        array(
            'pagination' => array(
                'has_more'    => $has_more,
                'next_cursor' => $next_cursor,
                'per_page'    => $per_page,
            ),
        )
    );
}

Caching Headers

Proper HTTP caching headers are one of the most impactful optimizations for mobile APIs. They allow the mobile app and any intermediate proxies to serve cached responses, dramatically reducing server load and improving response times. Add these to your endpoint responses:

<?php
/**
 * Add caching headers to a REST response.
 *
 * @param WP_REST_Response $response The response object.
 * @param string           $cache_type Type of caching: 'static', 'dynamic', 'private'.
 * @param int              $max_age Cache duration in seconds.
 * @return WP_REST_Response
 */
function woocustomdev_add_cache_headers( WP_REST_Response $response, string $cache_type = 'dynamic', int $max_age = 300 ): WP_REST_Response {
    switch ( $cache_type ) {
        case 'static':
            // For data that rarely changes (categories, store info).
            $response->header( 'Cache-Control', "public, max-age={$max_age}, s-maxage={$max_age}" );
            break;

        case 'dynamic':
            // For data that changes moderately (product listings).
            $response->header( 'Cache-Control', "public, max-age={$max_age}, stale-while-revalidate=60" );
            break;

        case 'private':
            // For user-specific data (cart, orders, dashboard).
            $response->header( 'Cache-Control', "private, max-age={$max_age}, no-store" );
            break;

        case 'none':
            // For real-time data (stock levels, flash sales).
            $response->header( 'Cache-Control', 'no-cache, no-store, must-revalidate' );
            $response->header( 'Pragma', 'no-cache' );
            $response->header( 'Expires', '0' );
            break;
    }

    // Add Vary header so caches differentiate by auth state.
    $response->header( 'Vary', 'Authorization, Accept-Encoding' );

    return $response;
}

Use static caching for category lists, store metadata, and other rarely-changing data (cache for 1 hour or more). Use dynamic caching for product feeds (cache for 5 minutes). Use private caching for customer-specific data like order history and wishlists. Use none for real-time data like cart contents and stock availability during checkout.

ETag Support for Conditional Requests

ETags allow the mobile app to ask “has this data changed since I last fetched it?” without downloading the full response. This is especially valuable for product detail pages that the user may revisit:

<?php
/**
 * Handle conditional requests using ETags.
 *
 * @param WP_REST_Request  $request  The incoming request.
 * @param WP_REST_Response $response The prepared response.
 * @return WP_REST_Response
 */
function woocustomdev_handle_conditional_request( WP_REST_Request $request, WP_REST_Response $response ): WP_REST_Response {
    $etag = $response->get_headers()['ETag'] ?? null;

    if ( ! $etag ) {
        return $response;
    }

    $if_none_match = $request->get_header( 'if_none_match' );

    if ( $if_none_match && $if_none_match === $etag ) {
        // Data has not changed -- return 304 with no body.
        return new WP_REST_Response( null, 304 );
    }

    return $response;
}

Rate Limiting

Rate limiting protects your WooCommerce store from abusive API clients, runaway mobile app bugs, and denial-of-service attacks. WordPress does not include built-in rate limiting for the REST API, so you need to implement it yourself.

<?php
/**
 * Rate limiter for the mobile API.
 */
class WooCustomDev_Rate_Limiter {

    /**
     * Maximum requests per window.
     *
     * @var int
     */
    private int $max_requests;

    /**
     * Time window in seconds.
     *
     * @var int
     */
    private int $window;

    /**
     * Constructor.
     *
     * @param int $max_requests Maximum requests per window.
     * @param int $window       Time window in seconds.
     */
    public function __construct( int $max_requests = 60, int $window = 60 ) {
        $this->max_requests = $max_requests;
        $this->window       = $window;
    }

    /**
     * Initialize the rate limiter.
     *
     * @return void
     */
    public function init(): void {
        add_filter( 'rest_pre_dispatch', array( $this, 'check_rate_limit' ), 10, 3 );
    }

    /**
     * Check rate limit before dispatching the request.
     *
     * @param mixed           $result  Response to replace the requested version with.
     * @param WP_REST_Server  $server  Server instance.
     * @param WP_REST_Request $request Request used to generate the response.
     * @return mixed|WP_Error
     */
    public function check_rate_limit( $result, WP_REST_Server $server, WP_REST_Request $request ) {
        // Only rate limit our custom namespace.
        $route = $request->get_route();
        if ( 0 !== strpos( $route, '/myapp/' ) ) {
            return $result;
        }

        $identifier = $this->get_client_identifier( $request );
        $key        = 'woocustomdev_rate_' . md5( $identifier );
        $current    = get_transient( $key );

        if ( false === $current ) {
            // First request in this window.
            set_transient( $key, 1, $this->window );
            $current = 1;
        } else {
            $current = (int) $current + 1;
            // Update the count without resetting the TTL.
            // We use a direct option update to preserve the expiration.
            global $wpdb;
            $wpdb->update(
                $wpdb->options,
                array( 'option_value' => $current ),
                array( 'option_name' => '_transient_' . $key )
            );
        }

        // Add rate limit headers to all responses.
        add_filter( 'rest_post_dispatch', function ( WP_REST_Response $response ) use ( $current ) {
            $response->header( 'X-RateLimit-Limit', $this->max_requests );
            $response->header( 'X-RateLimit-Remaining', max( 0, $this->max_requests - $current ) );
            $response->header( 'X-RateLimit-Reset', time() + $this->window );
            return $response;
        } );

        if ( $current > $this->max_requests ) {
            return new WP_Error(
                'rate_limit_exceeded',
                'Too many requests. Please slow down.',
                array(
                    'status'      => 429,
                    'retry_after' => $this->window,
                )
            );
        }

        return $result;
    }

    /**
     * Get a unique identifier for the client.
     *
     * @param WP_REST_Request $request The incoming request.
     * @return string
     */
    private function get_client_identifier( WP_REST_Request $request ): string {
        // Prefer user ID for authenticated requests.
        $user_id = get_current_user_id();
        if ( $user_id ) {
            return 'user_' . $user_id;
        }

        // Fall back to IP address for anonymous requests.
        $ip = isset( $_SERVER['HTTP_X_FORWARDED_FOR'] )
            ? sanitize_text_field( wp_unslash( explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] )[0] ) )
            : ( isset( $_SERVER['REMOTE_ADDR'] )
                ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
                : 'unknown' );

        return 'ip_' . $ip;
    }
}

// Initialize rate limiting: 100 requests per minute.
$woocustomdev_rate_limiter = new WooCustomDev_Rate_Limiter( 100, 60 );
$woocustomdev_rate_limiter->init();

The rate limiter uses WordPress transients for storage, which works well for single-server deployments. For high-traffic stores with multiple application servers, replace the transient-based storage with Redis or Memcached using the WordPress object cache. The X-RateLimit-* headers follow the standard convention that mobile HTTP client libraries understand, allowing the app to implement exponential backoff automatically.

Error Handling

Good error handling is the difference between a mobile app that crashes and one that gracefully recovers. Your WooCommerce REST API custom endpoints should return structured, actionable errors that the mobile app can parse programmatically and display appropriately to users.

Structured Error Responses

<?php
/**
 * Custom error handler for the mobile API.
 *
 * Ensures all errors follow a consistent structure that mobile
 * apps can parse reliably.
 */
class WooCustomDev_Error_Handler {

    /**
     * Initialize error handling.
     *
     * @return void
     */
    public function init(): void {
        add_filter( 'rest_request_after_callbacks', array( $this, 'format_error_response' ), 10, 3 );
    }

    /**
     * Format WP_Error objects into structured error responses.
     *
     * @param WP_REST_Response|WP_Error $response Result to send to the client.
     * @param array                     $handler  Route handler used for the request.
     * @param WP_REST_Request           $request  Request used to generate the response.
     * @return WP_REST_Response|WP_Error
     */
    public function format_error_response( $response, array $handler, WP_REST_Request $request ) {
        // Only handle errors for our namespace.
        if ( 0 !== strpos( $request->get_route(), '/myapp/' ) ) {
            return $response;
        }

        if ( ! is_wp_error( $response ) ) {
            return $response;
        }

        $error_code    = $response->get_error_code();
        $error_message = $response->get_error_message();
        $error_data    = $response->get_error_data();
        $status        = isset( $error_data['status'] ) ? (int) $error_data['status'] : 500;

        $body = array(
            'success' => false,
            'error'   => array(
                'code'    => $error_code,
                'message' => $error_message,
                'status'  => $status,
            ),
            'meta'    => array(
                'timestamp'  => gmdate( 'c' ),
                'request_id' => wp_generate_uuid4(),
                'version'    => 'v1',
            ),
        );

        // Include validation details for 400 errors.
        if ( 400 === $status && ! empty( $error_data['params'] ) ) {
            $body['error']['validation'] = $error_data['params'];
        }

        // Log server errors for debugging.
        if ( $status >= 500 ) {
            error_log(
                sprintf(
                    '[WooCustomDev API] %s: %s | Request: %s %s | User: %d',
                    $error_code,
                    $error_message,
                    $request->get_method(),
                    $request->get_route(),
                    get_current_user_id()
                )
            );
        }

        return new WP_REST_Response( $body, $status );
    }
}

$woocustomdev_error_handler = new WooCustomDev_Error_Handler();
$woocustomdev_error_handler->init();

Handling WooCommerce-Specific Errors

When your endpoints interact with WooCommerce functions, you will encounter WooCommerce-specific error conditions. Here is how to handle them gracefully:

<?php
/**
 * Add an item to the customer's cart via the API.
 *
 * @param WP_REST_Request $request The request object.
 * @return WP_REST_Response
 */
function woocustomdev_add_to_cart( WP_REST_Request $request ): WP_REST_Response {
    $product_id = $request->get_param( 'product_id' );
    $quantity   = $request->get_param( 'quantity' ) ?: 1;
    $variation  = $request->get_param( 'variation' ) ?: array();

    $product = wc_get_product( $product_id );

    if ( ! $product ) {
        return woocustomdev_error_response(
            'product_not_found',
            'The requested product does not exist.',
            404
        );
    }

    if ( ! $product->is_in_stock() ) {
        return woocustomdev_error_response(
            'out_of_stock',
            sprintf( '%s is currently out of stock.', $product->get_name() ),
            409,
            array( 'product_id' => $product_id )
        );
    }

    if ( ! $product->is_purchasable() ) {
        return woocustomdev_error_response(
            'not_purchasable',
            'This product cannot be purchased.',
            403
        );
    }

    // Check stock quantity.
    if ( $product->managing_stock() && $product->get_stock_quantity() < $quantity ) {
        return woocustomdev_error_response(
            'insufficient_stock',
            sprintf(
                'Only %d units of %s are available.',
                $product->get_stock_quantity(),
                $product->get_name()
            ),
            409,
            array(
                'product_id'      => $product_id,
                'available_stock' => $product->get_stock_quantity(),
                'requested'       => $quantity,
            )
        );
    }

    // Initialize the WooCommerce cart session if needed.
    if ( null === WC()->cart ) {
        wc_load_cart();
    }

    $cart_item_key = WC()->cart->add_to_cart( $product_id, $quantity, 0, $variation );

    if ( ! $cart_item_key ) {
        return woocustomdev_error_response(
            'add_to_cart_failed',
            'Could not add the product to your cart. Please try again.',
            500
        );
    }

    return woocustomdev_envelope_response(
        array(
            'cart_item_key' => $cart_item_key,
            'product_id'    => $product_id,
            'quantity'      => $quantity,
            'cart_total'    => WC()->cart->get_cart_contents_count(),
        ),
        201
    );
}

Every error includes a machine-readable code, a human-readable message, an appropriate HTTP status code, and contextual details that help the mobile app show meaningful feedback. The 409 status code (Conflict) is particularly useful for stock-related errors because it tells the client that the request was understood but cannot be fulfilled due to a conflict with the current state of the resource.

Versioning Your API Endpoints

API versioning is non-negotiable when building mobile APIs. Unlike web apps, you cannot force mobile users to update their app instantly. Old versions of your mobile app will continue hitting your API for months or even years after a new version is released. You need a versioning strategy that lets you evolve your API without breaking existing clients.

URL-Based Versioning

The simplest and most common approach is URL-based versioning, which we have been using throughout this guide with the /myapp/v1/ namespace. When you need to make breaking changes, you create a new version:

<?php
/**
 * Register routes for multiple API versions.
 *
 * @return void
 */
function woocustomdev_register_versioned_routes(): void {
    // V1: Original API -- maintained for backwards compatibility.
    $v1_controller = new WooCustomDev_Product_Feed_Controller_V1();
    $v1_controller->register_routes();

    // V2: New API with breaking changes.
    $v2_controller = new WooCustomDev_Product_Feed_Controller_V2();
    $v2_controller->register_routes();
}
add_action( 'rest_api_init', 'woocustomdev_register_versioned_routes' );

/**
 * V2 controller with updated response format.
 */
class WooCustomDev_Product_Feed_Controller_V2 extends WooCustomDev_Product_Feed_Controller {

    /**
     * Updated namespace.
     *
     * @var string
     */
    protected $namespace = 'myapp/v2';

    /**
     * V2 response format: prices are objects instead of strings.
     *
     * @param WC_Product      $product The product object.
     * @param WP_REST_Request $request Full data about the request.
     * @return WP_REST_Response
     */
    public function prepare_item_for_response( $product, $request ): WP_REST_Response {
        $response = parent::prepare_item_for_response( $product, $request );
        $data     = $response->get_data();

        // V2 breaking change: price is now a structured object.
        if ( isset( $data['price'] ) ) {
            $data['price'] = array(
                'amount'   => (float) $product->get_price(),
                'currency' => get_woocommerce_currency(),
                'display'  => html_entity_decode( wp_strip_all_tags( wc_price( $product->get_price() ) ) ),
            );
        }

        $response->set_data( $data );

        return $response;
    }
}

Deprecation Headers

When you plan to retire an old API version, signal this to clients using deprecation headers:

<?php
/**
 * Add deprecation headers to V1 endpoints.
 *
 * @param WP_REST_Response $response The response.
 * @param WP_REST_Server   $server   The REST server.
 * @param WP_REST_Request  $request  The request.
 * @return WP_REST_Response
 */
function woocustomdev_add_deprecation_headers( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
    $route = $request->get_route();

    if ( 0 === strpos( $route, '/myapp/v1/' ) ) {
        $response->header( 'Deprecation', 'Sun, 01 Sep 2026 00:00:00 GMT' );
        $response->header( 'Sunset', 'Mon, 01 Dec 2026 00:00:00 GMT' );
        $response->header( 'Link', '<https://yourstore.com/api-docs/migration-v2>; rel="deprecation"' );
    }

    return $response;
}
add_filter( 'rest_post_dispatch', 'woocustomdev_add_deprecation_headers', 10, 3 );

The Deprecation header indicates when the API was deprecated. The Sunset header indicates when it will be removed. The Link header points to migration documentation. Well-built mobile HTTP clients can detect these headers and log warnings, giving app developers time to update their code.

Building a Customer Dashboard Endpoint

Let us put everything together with a practical example: a customer dashboard endpoint that aggregates data from multiple WooCommerce sources into a single API call. This is the kind of endpoint that makes mobile apps fast, because instead of the app making 5 separate API requests to build the home screen, it makes one:

<?php
/**
 * REST Controller for the customer dashboard.
 *
 * Aggregates order history, loyalty points, saved items,
 * and personalized recommendations into a single endpoint.
 */
class WooCustomDev_Dashboard_Controller extends WP_REST_Controller {

    /**
     * @var string
     */
    protected $namespace = 'myapp/v1';

    /**
     * @var string
     */
    protected $rest_base = 'customer/dashboard';

    /**
     * Register routes.
     *
     * @return void
     */
    public function register_routes(): void {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_dashboard' ),
                    'permission_callback' => array( $this, 'check_permissions' ),
                ),
            )
        );
    }

    /**
     * Permission check: must be an authenticated customer.
     *
     * @param WP_REST_Request $request The request.
     * @return bool|WP_Error
     */
    public function check_permissions( WP_REST_Request $request ) {
        if ( ! is_user_logged_in() ) {
            return new WP_Error(
                'rest_not_authenticated',
                'Authentication is required.',
                array( 'status' => 401 )
            );
        }

        return true;
    }

    /**
     * Get the customer dashboard data.
     *
     * @param WP_REST_Request $request The request.
     * @return WP_REST_Response
     */
    public function get_dashboard( WP_REST_Request $request ): WP_REST_Response {
        $user_id  = get_current_user_id();
        $customer = new WC_Customer( $user_id );

        $data = array(
            'customer'     => $this->get_customer_summary( $customer ),
            'recent_orders' => $this->get_recent_orders( $user_id ),
            'stats'        => $this->get_order_stats( $user_id ),
            'reorder'      => $this->get_reorder_suggestions( $user_id ),
        );

        $response = woocustomdev_envelope_response( $data, 200, array(
            'cache_ttl' => 300,
        ) );

        return woocustomdev_add_cache_headers( $response, 'private', 300 );
    }

    /**
     * Get a compact customer summary.
     *
     * @param WC_Customer $customer The customer object.
     * @return array
     */
    private function get_customer_summary( WC_Customer $customer ): array {
        return array(
            'id'           => $customer->get_id(),
            'display_name' => $customer->get_display_name(),
            'email'        => $customer->get_email(),
            'avatar_url'   => get_avatar_url( $customer->get_id(), array( 'size' => 200 ) ),
            'total_spent'  => (float) $customer->get_total_spent(),
            'order_count'  => (int) $customer->get_order_count(),
            'member_since' => get_userdata( $customer->get_id() )->user_registered,
        );
    }

    /**
     * Get the 5 most recent orders.
     *
     * @param int $user_id The user ID.
     * @return array
     */
    private function get_recent_orders( int $user_id ): array {
        $orders = wc_get_orders( array(
            'customer_id' => $user_id,
            'limit'       => 5,
            'orderby'     => 'date',
            'order'       => 'DESC',
            'status'      => array_keys( wc_get_order_statuses() ),
        ) );

        $result = array();

        foreach ( $orders as $order ) {
            $result[] = array(
                'id'           => $order->get_id(),
                'status'       => $order->get_status(),
                'status_label' => wc_get_order_status_name( $order->get_status() ),
                'total'        => (float) $order->get_total(),
                'total_display' => html_entity_decode( wp_strip_all_tags( $order->get_formatted_order_total() ) ),
                'date'         => $order->get_date_created()->format( 'c' ),
                'item_count'   => $order->get_item_count(),
                'items'        => $this->get_order_item_summaries( $order ),
            );
        }

        return $result;
    }

    /**
     * Get compact order item summaries (name + thumbnail only).
     *
     * @param WC_Order $order The order.
     * @return array
     */
    private function get_order_item_summaries( WC_Order $order ): array {
        $items = array();

        foreach ( $order->get_items() as $item ) {
            $product = $item->get_product();
            $items[] = array(
                'name'      => $item->get_name(),
                'quantity'  => $item->get_quantity(),
                'thumbnail' => $product
                    ? wp_get_attachment_image_url( $product->get_image_id(), 'woocommerce_thumbnail' )
                    : null,
            );
        }

        return $items;
    }

    /**
     * Get aggregated order statistics.
     *
     * @param int $user_id The user ID.
     * @return array
     */
    private function get_order_stats( int $user_id ): array {
        $orders = wc_get_orders( array(
            'customer_id' => $user_id,
            'status'      => array( 'wc-completed', 'wc-processing' ),
            'limit'       => -1,
            'return'      => 'ids',
        ) );

        $pending_orders = wc_get_orders( array(
            'customer_id' => $user_id,
            'status'      => array( 'wc-pending', 'wc-on-hold' ),
            'limit'       => -1,
            'return'      => 'ids',
        ) );

        return array(
            'total_orders'   => count( $orders ),
            'pending_orders' => count( $pending_orders ),
            'this_month'     => $this->count_orders_this_month( $user_id ),
        );
    }

    /**
     * Count orders placed this month.
     *
     * @param int $user_id The user ID.
     * @return int
     */
    private function count_orders_this_month( int $user_id ): int {
        $orders = wc_get_orders( array(
            'customer_id' => $user_id,
            'date_after'  => gmdate( 'Y-m-01' ),
            'status'      => array( 'wc-completed', 'wc-processing' ),
            'limit'       => -1,
            'return'      => 'ids',
        ) );

        return count( $orders );
    }

    /**
     * Get reorder suggestions based on past purchases.
     *
     * @param int $user_id The user ID.
     * @return array
     */
    private function get_reorder_suggestions( int $user_id ): array {
        // Find the most frequently purchased products.
        global $wpdb;

        $results = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT oi.order_item_name, oim.meta_value AS product_id, COUNT(*) AS purchase_count
                FROM {$wpdb->prefix}woocommerce_order_items AS oi
                INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS oim
                    ON oi.order_item_id = oim.order_item_id
                INNER JOIN {$wpdb->posts} AS p
                    ON oi.order_id = p.ID
                INNER JOIN {$wpdb->postmeta} AS pm
                    ON p.ID = pm.post_id AND pm.meta_key = '_customer_user'
                WHERE oim.meta_key = '_product_id'
                    AND pm.meta_value = %d
                    AND p.post_status IN ('wc-completed', 'wc-processing')
                GROUP BY oim.meta_value
                ORDER BY purchase_count DESC
                LIMIT 6",
                $user_id
            )
        );

        $suggestions = array();

        foreach ( $results as $row ) {
            $product = wc_get_product( (int) $row->product_id );
            if ( $product && $product->is_in_stock() && 'publish' === $product->get_status() ) {
                $suggestions[] = array(
                    'product_id'     => (int) $row->product_id,
                    'name'           => $product->get_name(),
                    'price'          => $product->get_price(),
                    'image'          => wp_get_attachment_image_url( $product->get_image_id(), 'woocommerce_thumbnail' ),
                    'purchase_count' => (int) $row->purchase_count,
                );
            }
        }

        return $suggestions;
    }
}

// Register the dashboard controller.
add_action( 'rest_api_init', function () {
    $controller = new WooCustomDev_Dashboard_Controller();
    $controller->register_routes();
} );

This single endpoint replaces what would typically be 4-5 separate API calls in a mobile app: customer profile, recent orders, order stats, and reorder suggestions. The difference in perceived performance on a mobile device is dramatic — one round trip instead of five, each with their own TCP connection setup and TLS handshake overhead.

Testing Your Custom Endpoints

Before your mobile app team starts integrating with your WooCommerce REST API custom endpoints, you need to test them thoroughly. Here is a systematic approach.

Manual Testing with cURL

Start with cURL to verify basic functionality:

# Get a JWT token.
curl -X POST https://yourstore.com/wp-json/myapp/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "testcustomer", "password": "testpass123"}'

# Fetch the product feed with field selection.
curl -X GET "https://yourstore.com/wp-json/myapp/v1/products/feed?fields=id,name,price,image&per_page=10" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE"

# Fetch the customer dashboard.
curl -X GET "https://yourstore.com/wp-json/myapp/v1/customer/dashboard" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE"

# Test rate limiting by sending rapid requests.
for i in $(seq 1 105); do
  curl -s -o /dev/null -w "%{http_code}" \
    "https://yourstore.com/wp-json/myapp/v1/products/feed?per_page=1" \
    -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE"
  echo " - Request $i"
done

Automated Testing with PHPUnit

For your test suite, use the WordPress test framework to write integration tests:

<?php
/**
 * Tests for the product feed endpoint.
 */
class Test_Product_Feed_Endpoint extends WP_Test_REST_TestCase {

    /**
     * Test that the feed returns products with correct structure.
     *
     * @return void
     */
    public function test_get_product_feed_returns_correct_structure(): void {
        // Create a test product.
        $product = new WC_Product_Simple();
        $product->set_name( 'Test Product' );
        $product->set_regular_price( '29.99' );
        $product->set_status( 'publish' );
        $product->save();

        $request  = new WP_REST_Request( 'GET', '/myapp/v1/products/feed' );
        $response = rest_do_request( $request );
        $data     = $response->get_data();

        $this->assertSame( 200, $response->get_status() );
        $this->assertNotEmpty( $data );
        $this->assertArrayHasKey( 'id', $data[0] );
        $this->assertArrayHasKey( 'name', $data[0] );
    }

    /**
     * Test field selection filters response fields.
     *
     * @return void
     */
    public function test_field_selection_limits_response(): void {
        $product = new WC_Product_Simple();
        $product->set_name( 'Field Test Product' );
        $product->set_regular_price( '19.99' );
        $product->set_status( 'publish' );
        $product->save();

        $request = new WP_REST_Request( 'GET', '/myapp/v1/products/feed' );
        $request->set_param( 'fields', 'id,name,price' );

        $response = rest_do_request( $request );
        $data     = $response->get_data();

        $this->assertArrayHasKey( 'id', $data[0] );
        $this->assertArrayHasKey( 'name', $data[0] );
        $this->assertArrayNotHasKey( 'image', $data[0] );
        $this->assertArrayNotHasKey( 'rating', $data[0] );
    }

    /**
     * Test that the dashboard requires authentication.
     *
     * @return void
     */
    public function test_dashboard_requires_authentication(): void {
        $request  = new WP_REST_Request( 'GET', '/myapp/v1/customer/dashboard' );
        $response = rest_do_request( $request );

        $this->assertSame( 401, $response->get_status() );
    }

    /**
     * Test pagination headers.
     *
     * @return void
     */
    public function test_pagination_headers_present(): void {
        // Create enough products to trigger pagination.
        for ( $i = 0; $i < 25; $i++ ) {
            $product = new WC_Product_Simple();
            $product->set_name( 'Pagination Product ' . $i );
            $product->set_regular_price( '10.00' );
            $product->set_status( 'publish' );
            $product->save();
        }

        $request = new WP_REST_Request( 'GET', '/myapp/v1/products/feed' );
        $request->set_param( 'per_page', 10 );

        $response = rest_do_request( $request );

        $this->assertNotEmpty( $response->get_headers()['X-WP-Total'] );
        $this->assertNotEmpty( $response->get_headers()['X-WP-TotalPages'] );
    }
}

Security Considerations

Building WooCommerce REST API custom endpoints for mobile apps introduces attack surfaces that do not exist in a traditional web-only WooCommerce store. Here are the security practices you should implement.

Input Validation at Every Layer

Never trust input from a mobile app. Even if your mobile app validates data on the client side, someone can bypass the app entirely and call your API with raw HTTP requests. The argument schemas and validation callbacks we defined earlier are your first defense. Add additional server-side validation in your callback functions for business logic rules.

CORS Configuration

If your mobile app is a hybrid app (using a WebView), you may need to configure CORS. Be specific about allowed origins rather than using wildcards:

<?php
/**
 * Configure CORS for the mobile API.
 *
 * @return void
 */
function woocustomdev_configure_cors(): void {
    // Only apply to our API namespace.
    if ( false === strpos( $_SERVER['REQUEST_URI'] ?? '', '/wp-json/myapp/' ) ) {
        return;
    }

    $allowed_origins = array(
        'https://app.yourstore.com',
        'capacitor://localhost',  // Capacitor/Ionic apps.
        'ionic://localhost',
    );

    $origin = isset( $_SERVER['HTTP_ORIGIN'] )
        ? sanitize_url( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) )
        : '';

    if ( in_array( $origin, $allowed_origins, true ) ) {
        header( 'Access-Control-Allow-Origin: ' . $origin );
        header( 'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS' );
        header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-App-Version' );
        header( 'Access-Control-Max-Age: 86400' );
    }

    // Handle preflight requests.
    if ( 'OPTIONS' === ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
        http_response_code( 204 );
        exit;
    }
}
add_action( 'init', 'woocustomdev_configure_cors', 1 );

Request Signing for High-Security Endpoints

For sensitive operations like placing orders or updating payment methods, consider implementing request signing. The mobile app signs each request with a shared secret, and your API verifies the signature before processing:

<?php
/**
 * Verify a signed request from the mobile app.
 *
 * @param WP_REST_Request $request The incoming request.
 * @return bool|WP_Error
 */
function woocustomdev_verify_request_signature( WP_REST_Request $request ) {
    $signature = $request->get_header( 'x_request_signature' );
    $timestamp = $request->get_header( 'x_request_timestamp' );

    if ( ! $signature || ! $timestamp ) {
        return new WP_Error(
            'missing_signature',
            'Request signature is required for this endpoint.',
            array( 'status' => 401 )
        );
    }

    // Reject requests older than 5 minutes to prevent replay attacks.
    if ( abs( time() - (int) $timestamp ) > 300 ) {
        return new WP_Error(
            'expired_request',
            'Request timestamp is too old.',
            array( 'status' => 401 )
        );
    }

    $body     = $request->get_body();
    $expected = hash_hmac( 'sha256', $timestamp . $body, WOOCUSTOMDEV_APP_SECRET );

    if ( ! hash_equals( $expected, $signature ) ) {
        return new WP_Error(
            'invalid_signature',
            'Request signature is invalid.',
            array( 'status' => 401 )
        );
    }

    return true;
}

Performance Tips for Production Deployments

When your mobile app is live with thousands of concurrent users, API performance becomes critical. Here are the optimizations that have the most impact on real-world WooCommerce installations.

Object Caching for Expensive Queries

The customer dashboard endpoint runs several database queries. Cache the results using the WordPress object cache:

<?php
/**
 * Get cached order stats for a customer.
 *
 * @param int $user_id The user ID.
 * @return array
 */
function woocustomdev_get_cached_order_stats( int $user_id ): array {
    $cache_key   = 'woocustomdev_order_stats_' . $user_id;
    $cache_group = 'woocustomdev_dashboard';
    $cached      = wp_cache_get( $cache_key, $cache_group );

    if ( false !== $cached ) {
        return $cached;
    }

    // Run the expensive queries.
    $stats = woocustomdev_compute_order_stats( $user_id );

    // Cache for 10 minutes.
    wp_cache_set( $cache_key, $stats, $cache_group, 600 );

    return $stats;
}

// Invalidate the cache when an order status changes.
add_action( 'woocommerce_order_status_changed', function ( $order_id ) {
    $order   = wc_get_order( $order_id );
    $user_id = $order->get_customer_id();
    if ( $user_id ) {
        wp_cache_delete( 'woocustomdev_order_stats_' . $user_id, 'woocustomdev_dashboard' );
    }
} );

Avoid N+1 Query Problems

When building the product feed, be careful not to run individual queries for each product. Use batch operations where possible. The wc_get_products() function handles this well for basic product data, but if you need custom meta, prefetch it in bulk:

<?php
/**
 * Prefetch product meta for a batch of product IDs.
 *
 * @param array<int> $product_ids Product IDs to prefetch.
 * @return void
 */
function woocustomdev_prefetch_product_meta( array $product_ids ): void {
    if ( empty( $product_ids ) ) {
        return;
    }

    // This WordPress function primes the metadata cache for all given IDs
    // in a single database query instead of one query per product.
    update_meta_cache( 'post', $product_ids );
}

Putting It All Together

Let us recap the architecture we have built. Our WooCommerce REST API custom endpoints system consists of:

  1. JWT Authentication — a complete login, token refresh, and revocation system that mobile apps can integrate with standard HTTP libraries.
  2. REST Controllers — structured classes extending WP_REST_Controller with proper schemas, validation, and permission checks.
  3. Field Selection — allowing mobile clients to request only the data they need, reducing payload sizes by up to 80%.
  4. Pagination — both offset-based and cursor-based, with proper Link headers and total count headers.
  5. Caching — HTTP cache headers, ETags, and server-side object caching for expensive queries.
  6. Rate Limiting — per-user and per-IP rate limiting with standard X-RateLimit headers.
  7. Error Handling — consistent error envelopes with machine-readable codes and human-readable messages.
  8. Versioning — URL-based versioning with deprecation headers for smooth migrations.
  9. Security — input validation, CORS configuration, and request signing for sensitive operations.

This is a production-ready architecture that scales from a small WooCommerce store with a few hundred customers to a high-traffic store handling thousands of concurrent mobile app sessions. The patterns we covered follow WordPress and WooCommerce conventions, which means they integrate cleanly with the broader ecosystem of plugins, caching layers, and hosting infrastructure.

For a deeper understanding of how these API patterns fit into the broader WooCommerce plugin architecture, see our guide on WooCommerce plugin architecture. The official WooCommerce REST API documentation is also essential reading for understanding how the core endpoints are structured and how your custom endpoints should behave alongside them.

In the next article in this series, we will build on this API foundation to implement custom WooCommerce payment gateways that work seamlessly with mobile checkout flows. The authentication and error handling patterns from this article will carry forward directly.

Facebook
Twitter
LinkedIn
Pinterest
WhatsApp

Related Posts

Leave a Reply

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