WordPress REST API Namespace Versioning: Run v1 and v2 Side-by-Side with Breaking Changes

When a plugin exposes a REST API, choosing and structuring the namespace correctly is critical for long-term maintainability. WordPress REST routes are identified by a namespace (e.g., my-plugin/v1) and a route pattern (e.g., /items/(?P<id>[\d]+)). The namespace includes a version slug — conventionally v1, v2 — that allows breaking changes to be introduced in a new namespace without removing the old one, giving API consumers time to migrate. A versioned namespace strategy means that my-plugin/v1/items and my-plugin/v2/items can coexist: v1 returns the old response shape, v2 returns the new one. WordPress core itself uses this pattern: wp/v2 is the current namespace, and if a v3 ever ships, both would coexist. Implementing clean versioning from day one — even for internal APIs — avoids the brittle, non-versioned API pattern that breaks consuming code on every plugin update.

Problem: A plugin's first REST API version (my-plugin/v1) returns a flat array of products. A new feature requires a breaking change: the response must now be paginated with metadata ({'products': [...], 'total': N, 'pages': N}). The v2 API needs to coexist with v1 until all consuming apps have migrated, and both versions need proper authentication and schema validation.

Solution: Register both namespace versions with register_rest_route(), keeping v1 unchanged. Add v2 routes with the new response shape. Share the permission callback and core business logic between versions.

<?php
// ── Shared constants ───────────────────────────────────────────────────
const MY_PLUGIN_REST_NS_V1 = 'my-plugin/v1';
const MY_PLUGIN_REST_NS_V2 = 'my-plugin/v2';

// ── Register both API versions ────────────────────────────────────────
add_action( 'rest_api_init', function () {
    // ─ v1: flat array response (DEPRECATED — keep for backwards compat)
    register_rest_route( MY_PLUGIN_REST_NS_V1, '/products', [
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'my_plugin_get_products_v1',
        'permission_callback' => 'my_plugin_api_permission',
        'args'                => [
            'per_page' => [
                'type'              => 'integer',
                'default'           => 10,
                'minimum'           => 1,
                'maximum'           => 100,
                'sanitize_callback' => 'absint',
            ],
        ],
    ] );

    // ─ v2: paginated response with metadata
    register_rest_route( MY_PLUGIN_REST_NS_V2, '/products', [
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'my_plugin_get_products_v2',
        'permission_callback' => 'my_plugin_api_permission',
        'args'                => [
            'per_page' => [
                'type'    => 'integer',
                'default' => 10,
                'minimum' => 1,
                'maximum' => 100,
            ],
            'page' => [
                'type'    => 'integer',
                'default' => 1,
                'minimum' => 1,
            ],
        ],
        'schema' => 'my_plugin_get_products_schema_v2',
    ] );

    // ─ v2: single product endpoint (new in v2)
    register_rest_route( MY_PLUGIN_REST_NS_V2, '/products/(?P<id>[\d]+)', [
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'my_plugin_get_product_v2',
        'permission_callback' => 'my_plugin_api_permission',
        'args'                => [
            'id' => [
                'type'              => 'integer',
                'validate_callback' => fn( $val ) => $val > 0,
            ],
        ],
    ] );
} );

// ── Shared permission callback ─────────────────────────────────────────
function my_plugin_api_permission(): bool {
    return current_user_can( 'edit_posts' ) || is_user_logged_in();
}

// ── v1 callback — flat array ──────────────────────────────────────────
function my_plugin_get_products_v1( WP_REST_Request $req ): WP_REST_Response {
    $items = my_plugin_query_products( [
        'per_page' => $req->get_param( 'per_page' ),
        'page'     => 1,
    ] );
    // Deprecated: add header to signal migration
    $response = new WP_REST_Response( $items['products'] );
    $response->header( 'X-API-Deprecated', 'true' );
    $response->header( 'X-API-Migrate-To', rest_url( MY_PLUGIN_REST_NS_V2 . '/products' ) );
    return $response;
}

// ── v2 callback — paginated with meta ────────────────────────────────
function my_plugin_get_products_v2( WP_REST_Request $req ): WP_REST_Response {
    $result   = my_plugin_query_products( [
        'per_page' => $req->get_param( 'per_page' ),
        'page'     => $req->get_param( 'page' ),
    ] );
    $response = new WP_REST_Response( $result );
    $response->header( 'X-WP-Total',      $result['total'] );
    $response->header( 'X-WP-TotalPages', $result['pages'] );
    return $response;
}

// ── Shared business logic ─────────────────────────────────────────────
function my_plugin_query_products( array $params ): array {
    $per_page = absint( $params['per_page'] );
    $page     = absint( $params['page'] );
    $offset   = ( $page - 1 ) * $per_page;

    global $wpdb;
    $table = $wpdb->prefix . 'my_products';

    $items = $wpdb->get_results( $wpdb->prepare(
        "SELECT id, name, price FROM {$table} ORDER BY id LIMIT %d OFFSET %d",
        $per_page, $offset
    ), ARRAY_A );
    $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );

    return [
        'products' => $items,
        'total'    => $total,
        'pages'    => max( 1, (int) ceil( $total / $per_page ) ),
    ];
}

// ── v2 JSON schema for validation and documentation ───────────────────
function my_plugin_get_products_schema_v2(): array {
    return [
        '$schema'    => 'http://json-schema.org/draft-04/schema#',
        'title'      => 'products',
        'type'       => 'object',
        'properties' => [
            'products' => [ 'type' => 'array' ],
            'total'    => [ 'type' => 'integer' ],
            'pages'    => [ 'type' => 'integer' ],
        ],
    ];
}

NOTE: WordPress discovers all registered REST namespaces automatically — hitting /wp-json/ returns a list of all namespaces and their routes. Both my-plugin/v1 and my-plugin/v2 will appear there. Adding a deprecation header (X-API-Deprecated: true) with a migration URL in v1 responses is the recommended way to signal to API consumers that they should upgrade. Set a deprecation timeline (e.g., "v1 will be removed in plugin version 3.0") in your documentation. For plugin deactivation cleanup, only remove the route registrations — WordPress routes are registered on each request, not stored in the database, so there is nothing to delete on uninstall.