What internal endpoint deprecation is and why it is harder than retiring public APIs

Deprecating an internal endpoint in a monolith means marking a route as scheduled for removal, moving its callers elsewhere, and then deleting the handler and the code paths only it uses. The steps are the same as for a public API. What differs is the evidence you start with.

A public API usually has API keys, a developer portal, and a contact address for each consumer. Internal endpoints usually have none of those. Callers include sibling services, cron jobs, admin scripts, mobile builds from two years ago, and dashboards someone wired up during an incident. Many of them authenticate with a shared service credential, or don't authenticate at all, because they sit inside the network perimeter. Nobody can produce a list of these consumers. You have to reconstruct it from traffic, which turns deprecation into an evidence-gathering exercise.

Teams take on that work for a few common reasons:

  • Maintenance cost. Every live route has to be kept working through framework upgrades.
  • Security patching. Patching a dependency is harder when old handlers still rely on it.
  • Data model refactors. A legacy endpoint can lock a table schema in place and block a refactor.

The work belongs to backend and platform engineers who can change the router and query request logs and metrics. Without both, you can neither measure who calls an endpoint nor warn them.

The core principle runs through the whole checklist:

  1. Instrument before you announce anything.
  2. Signal deprecation in-band, in the responses callers already receive.
  3. Remove the endpoint only after it has seen zero traffic for a full business cycle. That window must include month-end jobs, quarterly reports, and anything else that runs on a schedule longer than your dashboards' default range.

Step 1: Build an endpoint inventory and diff it against observed traffic

You cannot schedule removals until you know what exists and what is actually used, and those are two different lists. The router knows what is declared. The access logs know what is called. Neither is complete on its own, so the first step is to produce both and compare them.

Start by dumping every registered route template from the running application, not from documentation. Most frameworks can list their routes, and Rails' rails routes is one example. Where yours cannot, walk the router's internal table at startup and write it out. Capture method plus template, such as GET /orders/:id/invoice.

Logged paths are concrete (/orders/48213/invoice), so normalize them before diffing:

  • Strip query strings.
  • Replace numeric IDs, UUIDs, and slugs with placeholders that match your router's syntax.
  • Collapse trailing slashes and casing differences.

The more reliable option is to log the matched route template at request time. Then no regex can guess wrong.

The diff produces three buckets:

Bucket What it usually means Next action
Declared but never called Dead code, or a caller on a long schedule Extend the observation window before treating it as unused
Called but undocumented Legacy route, middleware-handled path, or a shadow consumer Find the owner and add it to the inventory
Dynamic or wildcard routes One template like /admin/*path hides many logical endpoints Break it down by the captured segment and treat each value as its own endpoint

The wildcard bucket is the one most often skipped. It is also where a "single" route can turn out to have dozens of distinct consumers.

# Usage: python -I route_diff.py routes.txt access.log
# routes.txt: one 'METHOD /template' per line, dumped from the running router
import re, sys
from collections import Counter

UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
NUM = re.compile(r"/\d+(?=/|$)")
REQ = re.compile(r'"([A-Z]+) (\S+) HTTP/[\d.]+"')  # request line in common log format

def norm_path(p):
    # Drop query string, fold case and trailing slash, then swap IDs for a placeholder
    p = p.split("?", 1)[0].lower().rstrip("/") or "/"
    return NUM.sub("/:id", UUID.sub(":id", p))

def norm_template(t):
    # Rename every :param to :id so templates and normalized logs share one placeholder
    return re.sub(r":\w+", ":id", t.lower().rstrip("/") or "/")

declared, wildcards = set(), []
for line in open(sys.argv[1]):
    if not line.strip(): continue
    method, tmpl = line.split()
    if "*" in tmpl:  # e.g. /admin/*path: keep the prefix, analyze it separately
        wildcards.append((method, tmpl.split("*")[0].lower()))
    else:
        declared.add(f"{method} {norm_template(tmpl)}")

seen, wild_hits = Counter(), Counter()
for line in open(sys.argv[2]):
    m = REQ.search(line)
    if not m: continue
    method, key = m[1], norm_path(m[2])
    pre = next((p for meth, p in wildcards if meth == method and key.startswith(p)), None)
    if pre:  # count each first captured segment as its own logical endpoint
        wild_hits[f"{method} {pre}{key[len(pre):].split('/')[0]}"] += 1
    else:
        seen[f"{method} {key}"] += 1

print("declared but never called:", *sorted(declared - seen.keys()), sep="\n  ")
print("called but undocumented:", *sorted(seen.keys() - declared), sep="\n  ")
print("wildcard breakdown:", *(f"{k}  {n}" for k, n in wild_hits.most_common()), sep="\n  ")

A standard-library Python script that normalizes logged paths and declared route templates to a shared placeholder form, then diffs them into the three buckets: declared but never called, called but undocumented, and wildcard routes broken down by their first captured segment.

Step 2: Track endpoint usage per caller, not just per request

A request count tells you an endpoint is alive. It does not tell you who to talk to before you remove it. For that, every request needs to be attributed to a caller, and the record has to outlast your slowest client.

What to record per request

Have middleware emit one structured log line per request with these fields:

  • Route template: use /orders/{id}, not the raw path, so one endpoint maps to one key.
  • HTTP method.
  • Caller identity: take this from service auth, the client certificate subject, or the API key. If none of those exist, require a caller header.
  • User agent: this separates a service's main code path from a forgotten script that uses the same credentials.
  • Timestamp.

Run the middleware after routing, so the template is resolved, and after authentication, so identity is known. Emit the line even on error responses. A caller getting 4xx or 5xx errors is still a caller.

From these logs, build a table of last-seen time per (route, method, caller). Keep raw data for more than a quarter. Monthly reconciliations and quarterly reports only call once per cycle, and a 30-day window will mark them as unused.

Trade-offs

Required caller identification adds friction. Roll it out softly:

  1. Log missing identities and report them.
  2. Warn the teams that send unidentified traffic.
  3. Reject unidentified requests only after the first two steps.

Per-caller, per-route metrics have high cardinality. Storing them as metric series costs much more than sampling or analyzing logs in batch. The cheaper options lose precision on rare callers, and rare callers are the ones that break after removal. A common compromise is to keep full-fidelity logs in cheap storage and aggregate them on a schedule.

import json
import sys
from datetime import datetime, timezone

# Placeholder request fields: map them to whatever your framework exposes.
# Assumes routing and authentication have already run.

def resolve_caller(request):
    # Prefer strong identities. Log a key ID, never the raw API key.
    if request.auth_principal:
        return "svc:" + request.auth_principal
    if request.client_cert_subject:
        return "cert:" + request.client_cert_subject
    if request.api_key_id:
        return "key:" + request.api_key_id
    # Soft rollout: record missing identity instead of rejecting the request.
    return "hdr:" + request.headers.get("X-Caller-Id", "unidentified")

def usage_logging(handler):
    def wrapped(request):
        status = 500  # Used if the handler raises before returning a response.
        try:
            response = handler(request)
            status = response.status
            return response
        finally:
            # Log errors too, because a failing caller still depends on the route.
            record = {
                "event": "endpoint_usage",
                "route": request.route_template,  # "/orders/{id}", not "/orders/42"
                "method": request.method,
                "caller": resolve_caller(request),
                "user_agent": request.headers.get("User-Agent", ""),
                "status": status,
                "ts": datetime.now(timezone.utc).isoformat(),
            }
            sys.stdout.write(json.dumps(record) + "\n")
    return wrapped

This framework-agnostic middleware sketch runs after routing and authentication and emits one JSON log line per request, including failed requests, with the route template, method, caller identity, user agent, status, and timestamp.

Once you know who calls a deprecated route, tell them in the response itself. Three headers do this:

  • Deprecation marks the endpoint as deprecated. The standardized form is a structured date, @ followed by a Unix timestamp, recording when deprecation took effect. Older drafts used Deprecation: true, and some clients and gateways still expect that form, so check what your tooling parses before choosing one.
  • Sunset gives the removal date as an HTTP-date, for example Sunset: Sat, 31 Jan 2026 23:59:59 GMT. Use exactly this format. A client that parses dates programmatically will reject ISO 8601 strings.
  • Link points to migration notes, for example Link: <>; rel="deprecation". Use an internal URL that will still resolve after the endpoint is gone.

Add these in a single middleware that reads a per-route deprecation table rather than editing each handler. The table should hold the route, deprecation date, sunset date, docs link, and owning team. The same middleware should emit a structured warning log on every hit, including the route, caller identity, user agent, and days remaining until sunset. That log feeds the per-caller dashboards from the previous step, so every deprecated call can be traced to a specific owner.

The headers have a clear limitation. Most internal HTTP clients discard response headers they don't recognize, and nobody reads raw responses from a service-to-service call. The headers help the few clients that check them and anyone debugging with curl. The log, the dashboard, and a direct message to each caller's owning team are what actually get the migration done.

// Express middleware: one deprecation table, applied before route handlers.
const DEPRECATED = {
  'GET /v1/orders': {
    deprecatedAt: new Date('2026-11-01T00:00:00Z'),
    sunsetAt: new Date('2027-01-31T23:59:59Z'),
    docs: '',
    owner: 'team-orders',
  },
};

function deprecationMiddleware(req, res, next) {
  const entry = DEPRECATED[`${req.method} ${req.path}`];
  if (!entry) return next();

  // Structured date form: '@' + Unix seconds. Swap to 'true' if your gateway expects the older draft.
  res.set('Deprecation', `@${Math.floor(entry.deprecatedAt.getTime() / 1000)}`);
  // toUTCString() yields an IMF-fixdate HTTP-date, e.g. 'Sun, 31 Jan 2027 23:59:59 GMT'.
  res.set('Sunset', entry.sunsetAt.toUTCString());
  res.set('Link', `<${entry.docs}>; rel="deprecation"`);

  // Headers are mostly ignored by internal clients; this log is what feeds per-caller dashboards.
  console.warn(JSON.stringify({
    event: 'deprecated_route_hit',
    route: `${req.method} ${req.path}`,
    caller: req.get('x-caller-service') || 'unknown', // placeholder caller-identity header
    userAgent: req.get('user-agent') || 'unknown',
    owner: entry.owner,
    daysToSunset: Math.ceil((entry.sunsetAt - Date.now()) / 86400000),
  }));
  next();
}

module.exports = { deprecationMiddleware };

// Resulting response:
// HTTP/1.1 200 OK
// Content-Type: application/json; charset=utf-8
// Deprecation: @1793491200
// Sunset: Sun, 31 Jan 2027 23:59:59 GMT
// Link: <>; rel="deprecation"

An Express middleware that reads a per-route deprecation table, sets Deprecation, Sunset, and Link headers, and logs a structured warning on every hit, followed by the response a caller would see.

Step 4: Run a written API deprecation process with owners and exit gates

Treat the deprecation as a project with a written plan. Each stage has one named owner, a date, and a gate you can check on the dashboard. Without a gate, "migrate callers" never ends.

Stage Owner Target date Exit gate
Announce Endpoint owner T Every known caller's team has an open ticket
Mark deprecated Endpoint owner T + 1 week Headers and per-call logging live in production
Migrate callers Each caller's team T + 6 weeks Zero calls from known callers for 14 consecutive days
Brownout Platform on-call T + 8 weeks No new callers surface during scheduled failures
Remove Endpoint owner T + 10 weeks Route deleted, flag cleaned up, dashboard archived

Signals move one way. Each request writes a log line with the route and the caller identity. The dashboard aggregates those lines by caller. A scheduled job opens or updates a ticket for any caller whose count is still above zero. To route that ticket, map the identity (service account, mTLS principal, or API key) to a team through the service catalog or an ownership file. A caller with no owner is a finding in its own right, so fix the catalog first.

A useful ticket names the replacement endpoint, shows a before-and-after call in that team's language, and links to the dashboard filtered to their traffic. If a team misses the migration gate and has no plan, escalate to their manager.

Do not enforce hard deadlines blindly. A team under pressure may proxy the old endpoint through its own service or copy the handler into its codebase. Either move hides the dependency from your telemetry and leaves the old logic running in a place nobody is watching. A time-boxed extension with a written date is safer.

 +-------------------+     +--------------------+     +------------------+
 | Caller request    |---->| Deprecated route   |---->| Log line:        |
 | (svc acct / mTLS  |     | (Deprecation hdr,  |     | route + caller   |
 |  / API key)       |     |  per-call logging) |     | identity         |
 +-------------------+     +--------------------+     +--------+---------+
                                                               |
                                                               v
 +-------------------+     +--------------------+     +------------------+
 | Service catalog / |<----| Scheduled job:     |<----| Dashboard:       |
 | ownership file    |     | count > 0 per      |     | calls grouped    |
 | identity -> team  |     | caller?            |     | by caller        |
 +---------+---------+     +--------------------+     +------------------+
           |
     +-----+-----------------+
     | owner found           | no owner
     v                       v
 +-------------------+     +--------------------+
 | Owner ticket:     |     | Finding: fix the   |
 | replacement,      |     | catalog first      |
 | before/after code,|     +--------------------+
 | filtered dash link|
 +---------+---------+
           |  missed gate, no plan
           v
 +-------------------+
 | Escalate to mgr / |
 | written extension |
 +-------------------+

One-way signal flow: per-call logs feed a per-caller dashboard, a scheduled job maps active callers to owners and opens tickets, and unowned callers or missed gates trigger catalog fixes or escalation.

Step 5: Brownouts, flagged removal, and consumers your metrics miss

Telemetry going quiet is not proof that nobody calls the endpoint. A brownout makes the endpoint fail on purpose for a short, announced window, so callers you never saw show up. Return 410 Gone with a body that names the endpoint, the sunset date, and the replacement, so whoever investigates learns the cause from the response itself. Start with a few minutes and lengthen each window: a ten-minute outage catches people who watch dashboards, and a full-day outage catches the nightly job. Post the schedule where on-call engineers will see it, so teams are not paged for errors you caused deliberately. Keep a kill switch that ends the brownout in seconds.

Drive both brownouts and removal from configuration. A per-route mode such as active, brownout with a time window, or removed lets you change behavior without a deploy. For one release, leave the handler code in place while the route is set to removed, so rollback is a config change. After that release, delete the code, tests, docs, dashboards, metrics, and alerts. Alerts left behind will fire on zero traffic or hide real problems.

Before deleting anything, trace dependencies. Handlers and service-layer functions are often shared, and the code you remove may also back an endpoint that is still in use.

Expect these consumers to surface late:

  • Cron jobs and batch exports
  • Scripts with hardcoded URLs
  • Mobile and desktop clients, because old versions stay installed long after an upgrade ships
  • In-process callers that never send an HTTP request

When to avoid this process, alternatives, and impact on cost and engineering time

Skip the full process when the caller set is provably closed. A route added last sprint that never shipped, or one called by a single service your team owns, can be removed in one pull request: grep, delete, deploy. Slow down for paths that run rarely, such as disaster-recovery runbooks, annual tax exports, or quarterly reconciliations. A 30-day observation window will report zero traffic for these even though they are still needed, so extend the window past one full business cycle or confirm with the owning team in writing.

Alternative When it fits Risk
Indefinite retention Trivial, isolated handler Ongoing patching, and it blocks schema and auth refactors
Immediate hard removal Closed, known caller set Silent breakage of rare or unknown callers
Facade or proxy over old route Old contract must survive a rewrite The translation layer becomes permanent debt
Versioned replacement Contract genuinely changes Two code paths to maintain until migration finishes

The process costs instrumentation, log storage, and coordination meetings. Keeping a dead endpoint costs dependency upgrades, security fixes, test maintenance, and refactors you cannot make because something might still call it.

Illustrative estimate (assumed numbers, not a benchmark)

Annual retention cost = endpoints × hours per endpoint per year × hourly rate.

Assume 40 dead endpoints, 6 hours each per year, and $100 per hour: 40 × 6 × 100 = $24,000 per year. Against that, assume a first deprecation that takes 80 hours ($8,000), with later deprecations falling to 10 hours each.

The drop in later cost comes from reuse. Once you have usage-tracking middleware, a standard Deprecation and Sunset header policy, and a route-to-owner map, each new deprecation becomes a configuration change rather than a new project.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to deprecate api endpoints safely, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.