Skip to content
System nominalUTC --:--:--Articles 041

Versioning a Plugin REST API Without Breaking Other People's Code

A version bump tells clients something changed but not what. How to tell additive changes from breaking ones, and how to retire a route on a schedule you publish.

Devon Parker · @dparker

APIs and integrations

Topic
API
Section
APIs & Integrations
Published
27 JUN 2026
Reading time
7 MIN
Words
1910W
Technical cover graphic for Versioning a Plugin REST API Without Breaking Other People's Code
Generated schematic — API series, REV 2.6.

Three weeks after you rename a namespace from myplugin/v1 to myplugin/v2, a customer's order sync stops returning data. The job itself is fine. It is calling a route that no longer exists, getting rest_no_route, logging the failure at 03:00 and retrying every fifteen minutes until a human notices on Friday.

You rename the namespace because the response shape changed and you want to be honest about it. Removing the old routes in the same commit feels like tidying up. That removal is the breaking change; the rename is only the announcement.

The code that broke belongs to somebody else. A cron job on a warehouse server, a partner's ERP connector, a mobile build still waiting in a store review queue. None of those clients read your changelog, and none of them redeploy on your schedule. A REST route other people call is a contract whether or not you wrote it down.

The namespace segment is a routing string, not a compatibility promise

WordPress exposes routes under /wp-json/, and the first argument to register_rest_route() is the namespace. register_rest_route( 'myplugin/v1', '/orders', $args ) produces /wp-json/myplugin/v1/orders. The namespace is matched as a string. Core does not parse the v, does not compare numbers, and carries no notion of one namespace superseding another.

A bump from v1 to v2 therefore communicates one thing: a human decided the contract changed. It says nothing about which routes moved, which fields differ, whether the old routes still answer, or how long they will. A client engineer reading only the URL learns that they should go and read the documentation.

The convention is still worth following, because the alternative is worse. A namespace like myplugin/orders gives a client no hint that a contract exists at all. Just be clear about what you are buying: a label, not a mechanism. If you want a caller to be able to tell a safe change from an unsafe one, that has to be written down somewhere, and for most integrations the response body is the only documentation anyone reads.

Registration happens per namespace, and nothing prevents the same route from existing in two of them at once.

add_action( 'rest_api_init', function () {
    $args = array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'myplugin_list_orders',
        'permission_callback' => 'myplugin_can_read_orders',
    );

    // Two namespaces, one callback. Both routes answer from the same deploy.
    register_rest_route( 'myplugin/v1', '/orders', $args );
    register_rest_route( 'myplugin/v2', '/orders', $args );
} );

Both routes go live on the same request. That is the whole trick behind running /v1/ and /v2/ side by side, and it is why a version bump does not have to be a flag day for clients.

Sharing one callback is fine until the contracts diverge, which is usually the reason you started versioning. A single callback has to satisfy both response shapes, so it grows branches and becomes the function nobody wants to edit. My rule: share the callback only while the older shape is a subset of the newer one. The moment a field changes meaning, give v1 its own function.

Adding a field is safe; changing what a field means is not

Client compatibility is not one property. It is three different questions with three different answers.

Adding a field is safe in the common case, because most JSON clients ignore members they do not recognise. Two exceptions matter. A client generated from an OpenAPI schema that sets additionalProperties: false can reject the entire payload. A mobile decoder that maps the object onto a struct with a fixed key set can throw. Neither is exotic; both turn up in integrations that touch payments or inventory.

Removing a field is breaking, and you cannot prove otherwise. Searching your own codebase tells you nothing about a customer's reporting script. The field you consider unused is sometimes the single column that finance reconciles against every month.

Keeping the name and changing the meaning is the dangerous category, because nothing fails. Rename total and the client raises a missing-key error within seconds. Leave the name and change it from ex-tax to inc-tax and the client stores wrong numbers without complaint. Every response parses. Every status code is 200. The invoice is wrong for six weeks, and by then the reconciliation is archaeology.

Type changes belong with removals: a number that becomes a string, a scalar that becomes an array, an object that becomes an array when it is empty. That last one is not hypothetical, because PHP will do it for you. An empty array encodes as [] and not {}, so a map field that is normally an object arrives as a list on the one day the map is empty. The client throws on a code path you will never reproduce with test fixtures.

This is the classification I use when reviewing a diff against a live route.

ChangeClient impactWhat it requires
Add an optional fieldSafe for tolerant clients, rejected by strict schema validatorsShip it, document it, and never make it required later without a version bump.
Add a value to an existing setSafe to store, unsafe for clients that branch on the valueTell clients a default branch is mandatory before you ship it.
Rename or remove a fieldBreaking, whether or not anyone appears to read itServe both shapes for a stated period, then bump the namespace.
Change a field's typeBreaking for typed and generated clientsDo not. Add a new field and retire the old one.
Keep the name, change the meaningSilent data corruption, no errors anywhereTreat as a removal: new name for the new meaning, old field frozen, version bump.
Change default ordering or paginationBreaking for clients that page through resultsVersion bump, or an opt-in parameter with the old behaviour as default.

The semantic-change row is the one that gets argued about in review. Someone will propose keeping the field name so clients do not have to change, and treating the new meaning as a documentation problem. That trade is defensible for a private route with one known caller you can telephone. It is not defensible for a plugin distributed to thousands of sites, where you have no idea who reads the field and no way to ask.

Deprecation is a schedule, and the header is only the announcement

A deprecation notice that lives in a changelog is a wish. The client's own logs are the surface that reaches the people who have to act, so put the signal where their tooling will see it: response headers on the routes you intend to retire.

Deprecation and Sunset are HTTP headers rather than WordPress features, and proxies, gateways and API clients already know what they mean. Send both. The first says the route is on its way out; the second carries the date.

function myplugin_list_orders_v1( WP_REST_Request $request ) {
    // One set of business rules, encoded for the v1 contract.
    $data = myplugin_build_orders_payload( $request, 'v1' );

    $response = rest_ensure_response( $data );
    $response->header( 'Deprecation', 'true' );
    $response->header( 'Sunset', 'Wed, 30 Jun 2027 23:59:59 GMT' );

    return $response;
}

Two details in that function matter more than the headers. The v1 path builds the old shape from the same data rather than filtering the v2 payload, because you cannot post-process a response into a shape that used to mean something else, and a filter that tries is where subtle bugs live for years. And myplugin_build_orders_payload() takes the contract version as an argument, so both encodings come out of one implementation of the business rules.

The other half of a deprecation is knowing who is still calling the old routes. Not a request count. A count tells you nothing, because one integration stuck in a retry loop can produce four thousand requests an hour and look like a thriving user base.

function myplugin_note_v1_use( WP_REST_Request $request ) {
    global $wpdb;

    $wpdb->insert(
        $wpdb->prefix . 'myplugin_api_calls',
        array(
            'client'     => myplugin_client_label( $request ),
            'route'      => $request->get_route(),
            'created_at' => current_time( 'mysql', true ),
        ),
        array( '%s', '%s', '%s' )
    );
}

Keyed by client, that table answers the only question that matters when the sunset date arrives: is anything still calling v1, and which integration is it? If the answer is a partner you have a phone number for, migrate them and close the window. If the answer is an on-premise ERP that took eight months to certify last time, you have a decision to make rather than a date to pass.

The headers are not sufficient on their own. A client that never updates its HTTP library will not read them, and a machine client whose logs nobody reads will not raise an alarm. If the log tells you who the callers are, contact them. If it does not, the sunset date is the only pressure you have.

Running two versions forever doubles the test surface

Dual registration takes four lines. Supporting two contracts takes everything that follows. Every behaviour change needs fixtures and assertions for both shapes. Bug fixes land in the v2 handler and get forgotten in the v1 adapter until a customer finds them. The adapter accumulates compatibility branches written by people who have since left, and each new engineer is told not to touch it.

That cost is real but bounded, as long as the window closes. It becomes unbounded when the date passes and nothing happens, because then you are maintaining two public contracts for no stated reason. A deprecation window that is never enforced is permanent complexity with a completion date stapled to it, and after the first missed deadline nobody believes the second one.

Cost warning

Two namespaces cost four lines at the routing layer and a second test suite everywhere else. Budget that suite in the release that adds the second namespace, not in the one that removes it.

The cheaper version of dual support exists and is worth aiming for: keep v1 as a projection of v2, one set of business rules with two encodings, and close the window before the next semantic change arrives. The moment the difference between the versions stops being an encoding and becomes behaviour, the projection stops working and you are maintaining two implementations whether you admit it or not.

Two limits, stated plainly. Some clients genuinely cannot migrate on your schedule, and an open-ended window is not a kindness to them; it is a way to avoid telling them no. And if the route in question is private, called only by your own front end and shipped from the same repository, all of this is overhead. Version when somebody outside your deploy pipeline depends on the response, and not before.

What to do on Monday

  1. List the routes that code outside your repository calls, and date each one's last breaking change. If you cannot date it, you do not know the contract you are maintaining.
  2. Register the current routes under a new namespace instead of renaming in place, so both answer from the same deploy.
  3. Add Deprecation and Sunset headers to anything you plan to remove, and pick a real date rather than "later".
  4. Start logging calls per client against the old version. That log is the only evidence that will let you close the window.
  5. Put the change classification into your code review checklist. A field that keeps its name and changes meaning should stop the merge.
  6. When the date arrives, remove the route and say so in the release notes. A short paragraph of noise now is cheaper than a silent 404 in somebody's nightly job.

Devon Parker · @dparker

APIs and integrations

Works through REST contract design, webhook reliability, idempotency keys, retry policy and the unglamorous edge cases that appear when two systems disagree about what happened.

Continue

Related in APIs & Integrations