ADR-040: Versionless API URLs
Status
Accepted and built (2026-10-31). Amends ADR-026 and ADR-039. In the integration scratch org RunLocalTests passed TESTS_PASSED of TESTS_RAN with COVERAGE% org wide coverage. Both new date settings ship blank, so no /v1/ URL is deprecated or retired in production until I set them after the deploy.
Context
ADR-026 chose the X-API-Version header over a version in the URL, and ADR-039 built v2 on that choice. The URLs still carried /v1/ (/services/apexrest/papi/v1/projects), because the Apex REST classes were mapped there before the header existed. So a client sending X-API-Version: v2 called a URL that said v1, which reads like a mistake and would have to be explained to every new reader.
I want the URL to name the resource and the header to name the version, as ADR-026 said. I also do not want to break a client that already calls a /v1/ URL, so those URLs stay as aliases until a date I set.
Decision
The API is called at versionless URLs (/services/apexrest/papi/projects). The old /v1/ URLs keep working as aliases, answer the same way with either version header, and retire on a date I set. One router class per API reaches the existing handlers, so the 23 handler classes did not change.
- Why the version left the URL. The header already selects the contract. A URL segment that says
v1while the header saysv2is two version switches that disagree. This follows ADR-026 and does not reverse ADR-039, whose rejection of path versioning still holds: a future v3 is selected by the header and moves no URL. - The routers.
PAPIRouteris mapped to/papi/*andSAPIRouterto/sapi/*. Each reads the first route segment after the API root and calls the static method of the existing handler for the verb, for examplePAPIProjects.getProjects(). The handler runsApiRequestContext.begin()itself, so headers, the version rules, the rate limit, shaping and the log line are the same as before. The routers hold a route table and nothing else.ApiRoutingholds the two answers a router gives itself. - Why this works, and the probe. Apex REST picks the most specific
urlMapping. In the integration scratch org I deployed a throwaway class mapped to/papi/*./papi/healthand/papi/projectsreached it, while/papi/v1/healthand/papi/v1/projectsstill reached their existing classes. So the existing classes keep their/v1/mappings, which is what makes the old URLs aliases, and the router only ever sees versionless URLs and unknown ones. I then deployed the built routers to the same org and called the routes at both spellings withsf api request rest. A versionless URL and its/v1/alias returned the same body, and only the alias carried theLinkheader. With a throwaway path sunset date set, the alias answered 410 and the versionless URL, the old token URL and both old health URLs did not. - Wildcards and exact routes. Profile, projects and resume take anything after the segment, as their old
/*mappings did. Health,auth/token,ai/generateandtestimonials/feedmatch their segments and nothing after them, as their exact mappings did. The SAPI resources take anything after the segment except health, which is exact. - Unknown routes and verbs. An unknown route is 404
NOT_FOUNDin the existing error envelope, after the normal header and rate limit rules. A verb a route lacks was built as a 405 worded as the platform words it for a class without that verb, because I read what the platform returns for the old classes and copied it. The amendment below replaces that with the standard envelope. SAPI has only GET routes, soSAPIRouterhas only@HttpGetand the platform itself answers any other verb with 405. An old/v1/URL that reaches a router matched no existing class, so it is an unknown route. - One canonical path.
ApiRequestContext.canonicalPath(uri)turns any request URI into the API root and resource without the version segment, in lower case, with no query and no trailing slash./papi/v1/healthand/papi/healthboth give/papi/health. Every list that spelled/v1/now holds canonical paths and compares for equality: the sunset exceptions (token and both health checks), the unshaped bodies, the AI rate tier check, and the path the request log line records.endpointSegment()reads the trailing segment from the versionless spelling, so a handler sees the same value on both. On the platformRestRequest.requestURIstarts after/services/apexrest, so any URL put into a header or a message gets that prefix back. - The guest and the integration user. Class access is checked on the entry class, not on the classes it calls. The site guest could run
PAPIAuthTokenand nothing else (ADR-028), and the integration user could run every PAPI class exceptPAPIAuthToken. A router the guest may run could reach every PAPI handler, soPAPIRouterholds the old matrix itself. A guest reaches the token route only, and every other route answered the guest 404 as built, where the platform used to answer 403 for a class it could not run. An integration user got 404 on the token route. The amendment below makes those a 401 and a 403.Portfolio_GuestgainsPAPIRouter, andIntegration_Accessgains both routers.
The alias retirement
Two Portfolio_Setting__mdt records, Api_V1_Path_Deprecation_Date and Api_V1_Path_Sunset_Date, hold a yyyy-MM-dd date in Text Value, read as 00:00 UTC, in the same style as the two version dates. Both ship blank. Blank changes nothing, and a value that is not a date reads as blank.
- A response on an old
/v1/URL carriesLink: <versionless URL>; rel="canonical", whether or not a date is set. The target is the same request on the versionless URL, with the query string for a GET and none for a POST. I chosecanonical(RFC 6596, the preferred URI for a resource) oversuccessor-version(RFC 5829) because ADR-039 already usessuccessor-versionfor the v2 reference page, and one relation with two meanings would leave a client guessing. The target is a path on the same host. DeprecationandSunset(RFC 9745 and RFC 8594) are sent on an old URL once the matching path date is set.- From 00:00 UTC on the path sunset date an old URL answers 410
GONEin the existing envelope, with the message “URLs with /v1/ have been retired. Use /services/apexrest/papi/projects instead.” The message names the path and never echoes a query value. The versionless URL is never affected. - How it combines with ADR-039. The two retirements are independent. The version dates apply to a request served as v1 (a
v1header or none) and the path dates apply to a request on an old URL, so a request can be one, the other or both. A v2 call to an old URL meets only the path dates, and a v1 call to a versionless URL meets only the version dates. When both apply the response carries the earlier of the twoDeprecationdates and the earlier of the twoSunsetdates, because that is the day this exact request first stops working. ItsLinkholds every relation that applies. The 410 comes from either sunset. When only the version is gone the message is the ADR-039 text, unchanged. When only the path is gone it is the message above. When both are gone it is the ADR-039 text followed by the path sentence. The version and path checks run before the rate limiter, so a refused request spends none of the quota. - The three exceptions stay on their old URLs.
POST /papi/v1/auth/tokenand both/v1/healthURLs keep answering after the path sunset, with their dates andLink, as they do after the version sunset. This is the safer reading. A client that hard coded the old token URL can still get a token and read theLinkto the new one, and a monitor on an old health URL keeps working. These three are the bootstrap paths, and a 410 on them leaves a client with no way to learn the new URL. They answer until I delete the old classes, and that deletion is a separate change.
Our own callers
The API Lab (apiTester: the token path, API_PATH and the curl text), ApiLabController (AI_GENERATE_PATH, and its allowlist, which now reads a path in either spelling as the versionless one and refuses a path with a query), the PAPIHealth credential check, the Agentforce cover letter call, scripts/api-smoke.sh, the v2 specs and the Postman collection call versionless URLs. The Lab’s catalog, the Postman collection and the generated reference docs come from the specs by scripts/sync-specs.js, whose catalog now joins the v1 and v2 specs on the versionless path.
The switch and the two rollback levels
A Portfolio_Setting__mdt record, Api_Versionless_Urls, holds On or Off in Text Value and ships On. I added it so a rollback does not need a deploy with deletions.
- How it reads.
ApiRoutes.isVersionless()is false only when the value isOff(any letter case, spaces ignored). A blank value, a typo and a missing record all read asOn. I chose that default so a bad value can never turn the feature off by accident. Turning it off has to be deliberate, and the cost of a typo is that nothing changes. - Off, the routers.
PAPIRouterandSAPIRouteranswer every known route 404NOT_FOUNDin the usual envelope, with the message “Versionless URLs are switched off. Use /services/apexrest/papi/v1/projects.” (the/v1/URL for the same path). A guest asking for a route it may not reach gets the 401 of the amendment below, with the/v1/token URL, and an unknown route still gets the plain 404. The 405 for a wrong verb does not apply while Off. - Off, the old URLs. They are the only URLs, so none of them is an alias. They stop sending the
canonicalLink and the log line stops sayinglegacyPath=true. The path deprecation and sunset dates do not apply, so an old URL never answers 410 while Off, whatever date is set. The ADR-039 version headers and the version sunset are unchanged in both states. - Off, the shared path handling.
ApiRequestContextkeeps the request handling main had before this ADR (commit1c7a577d) beside the new handling: the path lists spelled with/v1/, thecontains('/papi/v1/ai/')AI check, the oldendpointSegment()and the raw path in the log line. It reads the switch once inbegin()into one flag, and four small decision methods (isUnshaped,isSunsetExempt,isAi,logPath, plusendpointSegment) pick the old or the new logic from it. So Off means the old/v1/URLs behave as on main for the exemptions, shaping, the AI tier and the log line. The token and both health checks stay out of the 410 either way. They are not exempt from the rate limiter in either build, because the old code never exempted them from it. - Removal. The switch and the old logic are temporary. After the v1 sunset on 2026-11-29, once the versionless URLs have run cleanly, I remove the switch, the old logic and their tests (
ApiLegacyLogicTest, the Off tests) in one change. The old code is marked REMOVE WITH THE SWITCH. - Callers. Every URL of our own API that Apex builds comes from one class,
ApiRoutes: the API Lab proxy (ApiLabController), the Agentforce cover letter call, and thePAPIHealthcredential check to SAPI. The API Lab page reads the switch with one cacheable Apex read,ApiLabController.getVersionlessUrls, and writes its token path and curl examples to match. Until the read returns it shows the versionless form. The live smoke script stays versionless, so it fails on its routes while the switch is Off, which is expected. - Rollback level 1, no deploy. For a fault in a router or in a caller, I edit the record in Setup and set Text Value to
Off. Every caller returns to the/v1/URLs at once, and the old classes were never changed, so they serve as they did before. This is the first thing to try. - Rollback level 2, revert the code. Off also returns the shared path handling to the old logic, so the switch covers a fault in
canonicalPath, the exemptions or the shaping too. The one thing it does not cover is a fault in the new code that runs whatever the switch says (the router classes existing, the permission set entries, the settings). For that I revert the code change itself.
Specs
The v2 specs moved to 2.1.0 with versionless server URLs and a note that the old /v1/ spellings are aliases. The v1 specs are not changed, they retire with v1. The v2 Error enum gains GONE, because a v2 call to an old URL can now get it.
Alternatives considered
| Option | Why not |
|---|---|
| Remap the 23 classes to versionless URLs | The old URLs would break at once. A class has one urlMapping, so it cannot serve both spellings. |
| A copy of every class under a new URL | 23 duplicated classes to keep in step with the originals. |
A router that also owns the /v1/ URLs |
The existing classes already own them, and a more specific mapping wins, so the old URLs cost nothing to keep. |
An HTTP redirect from /v1/ to the new URL |
A redirect on a Bearer token call is dropped by some clients, and a POST would become a GET. |
successor-version for the new URL |
ADR-039 uses it for the v2 reference. One relation with two targets is ambiguous. |
| One retirement date for version and URLs | A client can move its URLs and keep v1, or move to v2 and keep its URLs. Separate dates let me retire them one at a time. |
| Let the guest run the router unchecked | Class access is checked on the entry class only, so the guest would reach every PAPI handler. |
Implications
- Two spellings of every URL exist until the aliases go. Both are tested, and the request log line ends
legacyPath=truefor an old one, so I can see who still uses them before I set a date. - A new route needs two edits. The handler class and its row in
PAPIRouterorSAPIRouter. The route list in a router must match the spec. - A guest sees 401, on any route but the token one (404 as built, changed by the amendment below). This is a changed status for a call that was refused before.
- To retire the aliases I set the two dates (Maintenance Guide section 3.11). After the sunset I remove the
/v1/mappings from the handler classes, theLEGACY_SEGMENThandling inApiRequestContextand the two settings, in one change, and the routers then own every URL. - Not part of this decision. The v1 specs, the version dates of ADR-039 and the shape of any response.
Amendment (2026-10-31): people focused errors
I rewrote every message the API sends a person and fixed the error handling behind them, because the first walk of the routers showed callers reading a 404 “No route matches” for a route that exists and a platform 403 that names a class. Four decisions belong here and not in a new ADR, because they change the router answers this ADR set and the envelope its routes share.
- The
helpfield. TheErrorobject gains one field,help, a link to the code’s entry in the Errors section of the API reference, with the spec of the API that was called (https://ryanbumstead.com/api-lab/reference?spec=papi&version=v2#error-bad-request). Adding a field does not break a v2 client, anderrorCodevalues do not change. A browser follows the link with a GET, so the redirectingryanbumstead.comaddress is fine here. I checked the anchor on the rendered reference page in the integration scratch org: each entry is an empty anchorerror-<code in lower case with hyphens>above its heading, andc-api-referencescrolls to it and focuses the heading. Redoc gives a###heading an id of its own text, which is the same for two entries in two specs, so I did not use it. - A guest gets 401, not 404, on a real route.
SAPIRouteris granted toPortfolio_Guestand carries the same gatePAPIRouterhas. The guest is answered 401UNAUTHORIZEDwithWWW-Authenticate: Beareron every SAPI route and on every PAPI route except the token route, and the message gives the site’s token URL. An unknown route stays 404. This replaces “A guest sees 404, not 403” in the implications above, and it holds while the switch is Off too, where the message gives the/v1/token URL. The 401 and 403 run no header, version or rate rule, so a caller with no headers is told how to get a token. The integration user who asks the instance address for a token gets 403FORBIDDENand the same URL. The 405 for a wrong verb now uses the standard envelope and headers and keeps itsAllowheader. SAPI keeps the platform’s 405, because a non GET verb never reaches Apex. - The token URL is the site’s own address.
ApiSite.tokenUrlworks it out at run time and never givesryanbumstead.com, which redirects, and a client that follows a redirect can turn a POST into a GET. - No exception text, ever. An unplanned exception is a 500 with a fixed message that holds the correlation id, and the detail is logged under that id (
ApiRequestContext.serverError). A failure while the request rules run and any failure in either health check take the same road. The offset is capped at 2000, the largest SOQL offset, with a 400.
The wording applies to the old /v1/ request handling that the switch keeps, because the messages are shared. Nothing else on that Off path changed, except that raw exception text can no longer leave it. The v2 specs moved to 2.2.0 with an errorCode enum, the responses each path really sends and an Errors section. The v1 specs are not changed.