Salesforce Platform Architect Portfolio

Governance, Architecture, and Living Documentation

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 v1 while the header says v2 is 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. PAPIRouter is mapped to /papi/* and SAPIRouter to /sapi/*. Each reads the first route segment after the API root and calls the static method of the existing handler for the verb, for example PAPIProjects.getProjects(). The handler runs ApiRequestContext.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. ApiRouting holds 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/health and /papi/projects reached it, while /papi/v1/health and /papi/v1/projects still 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 with sf api request rest. A versionless URL and its /v1/ alias returned the same body, and only the alias carried the Link header. 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/generate and testimonials/feed match 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_FOUND in 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, so SAPIRouter has only @HttpGet and 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/health and /papi/health both 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 platform RestRequest.requestURI starts 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 PAPIAuthToken and nothing else (ADR-028), and the integration user could run every PAPI class except PAPIAuthToken. A router the guest may run could reach every PAPI handler, so PAPIRouter holds 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_Guest gains PAPIRouter, and Integration_Access gains 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 carries Link: <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 chose canonical (RFC 6596, the preferred URI for a resource) over successor-version (RFC 5829) because ADR-039 already uses successor-version for the v2 reference page, and one relation with two meanings would leave a client guessing. The target is a path on the same host.
  • Deprecation and Sunset (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 GONE in 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 v1 header 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 two Deprecation dates and the earlier of the two Sunset dates, because that is the day this exact request first stops working. Its Link holds 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/token and both /v1/health URLs keep answering after the path sunset, with their dates and Link, 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 the Link to 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 is Off (any letter case, spaces ignored). A blank value, a typo and a missing record all read as On. 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. PAPIRouter and SAPIRouter answer every known route 404 NOT_FOUND in 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 canonical Link and the log line stops saying legacyPath=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. ApiRequestContext keeps the request handling main had before this ADR (commit 1c7a577d) beside the new handling: the path lists spelled with /v1/, the contains('/papi/v1/ai/') AI check, the old endpointSegment() and the raw path in the log line. It reads the switch once in begin() into one flag, and four small decision methods (isUnshaped, isSunsetExempt, isAi, logPath, plus endpointSegment) 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 the PAPIHealth credential 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=true for 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 PAPIRouter or SAPIRouter. 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, the LEGACY_SEGMENT handling in ApiRequestContext and 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 help field. The Error object 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, and errorCode values do not change. A browser follows the link with a GET, so the redirecting ryanbumstead.com address is fine here. I checked the anchor on the rendered reference page in the integration scratch org: each entry is an empty anchor error-<code in lower case with hyphens> above its heading, and c-api-reference scrolls 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. SAPIRouter is granted to Portfolio_Guest and carries the same gate PAPIRouter has. The guest is answered 401 UNAUTHORIZED with WWW-Authenticate: Bearer on 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 403 FORBIDDEN and the same URL. The 405 for a wrong verb now uses the standard envelope and headers and keeps its Allow header. SAPI keeps the platform’s 405, because a non GET verb never reaches Apex.
  • The token URL is the site’s own address. ApiSite.tokenUrl works it out at run time and never gives ryanbumstead.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.