What API deprecation is, and why it is a measurement problem first

Deprecation and removal are different events. Deprecating an endpoint means announcing that it is no longer the supported path: it still works, but it receives no new features, and consumers are told to move. Removal means the route stops answering. The distance between those two events is where consumers get broken. Teams that treat them as a single step usually find out about a forgotten caller from an outage.

Teams deprecate endpoints for three common reasons. The first is to stop maintaining two code paths that do the same job. The second is to retire a contract that was a mistake, such as an unbounded list response or a field that leaks internal identifiers. The third is to unblock a schema or storage change that the old endpoint's shape prevents.

The checklist follows from one rule: you can only safely remove what you can prove nobody calls. That proof is a measurement problem, so the work runs in a fixed order. Observability comes first, signaling to consumers second, and removal last.

This checklist is for backend and platform engineers who own internal or partner-facing services. It applies when a replacement endpoint already exists, or when the current contract is unsafe enough to justify forcing a migration.

Full removal is sometimes the wrong goal. External partners, embedded devices, and third-party integrations often cannot upgrade on your schedule. In those cases, the honest choice is to keep a minimal legacy endpoint and budget for maintaining it, instead of promising a removal date you cannot enforce.

Checklist step 1: Write an endpoint deprecation policy before you need it

Whether you keep a legacy endpoint or remove it, that decision should come from rules agreed in advance, not from whoever argues hardest that week. An endpoint deprecation policy turns each sunset into an application of known terms.

The notice period is the hardest field to set. A long window means running two code paths, two test suites, and sometimes two data representations, with migrations or translation layers keeping them consistent. A short window saves you that cost by moving it onto consumers, who must reprioritize their roadmaps on your schedule. Do that a few times and teams stop trusting your notices and start pinning versions defensively. Choose a period your slowest important consumer can realistically meet, then hold to it.

A standing policy also saves engineering time. Without one, every deprecation turns into a separate negotiation over dates, approvals, and whether a change "really" breaks anyone. With one, the conversation shrinks to whether this case qualifies for an exception.

Policy field Example value Why it matters
Minimum notice period 6 months from announcement to removal Gives consumers a predictable planning horizon
Sunset approver Owning team lead plus platform representative Prevents unilateral removals and catches missed dependencies
Breaking change definition Removed fields, changed types, stricter validation, new required parameters Avoids disputes over whether notice is required
Exception process Written request with justification, reviewed by the approver Keeps extensions rare, visible, and time-boxed
Frozen-endpoint fix path Security and data-correctness fixes allowed with approver sign-off Freezing must not block urgent remediation

Checklist step 2: Inventory and attribute every caller

You cannot retire an endpoint until you know who calls it, and "who" has to mean a named owner you can contact, not an IP range. Attribute each request to a consumer using the strongest identity you already have:

  • Auth identity: the service account, OAuth client, or mTLS certificate subject on the request.
  • Client IDs issued at onboarding and carried in tokens or API keys.
  • Gateway or service mesh metadata: the source workload or namespace, which most meshes record per request.
  • A required caller-identification header, such as a service name plus version, for internal traffic with no other identity.

Treat anonymous traffic as an open risk, not as zero consumers. Unattributed calls usually come from the clients you know least about. Be careful when you close that gap: making the identification header mandatory is itself a breaking change for every existing anonymous client. Roll it out the same way you roll out any other breaking change. Log it as missing first, then warn, then reject.

To build the inventory, group access logs or request metrics by endpoint and client ID, and count calls over the window. The output is one row per consumer, with columns for endpoint, client ID, call count, and last-seen timestamp. Last-seen often matters more than volume.

Keep the window long. A two-week sample will miss some callers entirely:

  • Quarterly jobs such as reconciliation or reporting batches.
  • Disaster-recovery paths that only run during a failover.
  • Old mobile app versions still installed on devices that rarely update.

Cover at least one full business cycle. Ask the owners of DR runbooks and mobile releases directly, because their traffic may not show up in the window at all.

-- Per-consumer inventory for the endpoints being deprecated.
-- Assumes access logs are loaded into a table with one row per request:
--   access_log(request_time, method, endpoint, client_id, status)
-- client_id is whatever identity you resolved upstream: OAuth client,
-- service account, mesh source workload, or caller-identification header.

SELECT
  method,
  endpoint,
  -- Keep anonymous traffic as its own visible row instead of dropping it.
  -- It is an open risk, not zero consumers.
  COALESCE(client_id, '(anonymous)') AS consumer,
  COUNT(*)                           AS call_count,
  MIN(request_time)                  AS first_seen,
  -- Last-seen often matters more than volume: one call per quarter
  -- is still a consumer you must contact.
  MAX(request_time)                  AS last_seen
FROM access_log
WHERE endpoint LIKE '/v1/orders%'
  -- Long window: cover at least one full business cycle so quarterly
  -- jobs and failover-only paths have a chance to appear.
  AND request_time >= TIMESTAMP '2025-10-01 00:00:00'
GROUP BY
  method,
  endpoint,
  COALESCE(client_id, '(anonymous)')
-- Oldest last_seen first: these are the infrequent callers most likely
-- to be missed by a short observation window.
ORDER BY last_seen ASC, call_count ASC;

A SQL query that groups access-log rows by endpoint and client ID to produce one row per consumer with call count and first and last seen timestamps, and keeps anonymous traffic as its own visible row.

Checklist step 3: Signal deprecation in-band and in the places consumers look

Every response from the deprecated endpoint should carry a Deprecation header with the date the deprecation took effect, and a Sunset header with the planned removal date as an HTTP-date, for example Sunset: Sat, 31 Jan 2026 23:59:59 GMT. If you host migration docs, add a Link header with rel="deprecation" pointing to them. If the docs do not exist yet, leave the Link header out. A link to a missing page tells consumers you are not prepared.

These headers are informational and change nothing on their own. Most HTTP clients, SDKs, and service meshes ignore response headers they do not recognize, and almost no consumer alerts on them. Seeing Sunset in your own responses proves only that you sent it, not that anyone read it. Treat the headers as a machine-readable record of your intent. You still need the outreach from your policy and the per-caller telemetry from step 2.

Signal in the contract as well, where engineers actually look:

  • OpenAPI: set deprecated: true on the operation. Generators and doc renderers then mark it visibly.
  • Protobuf: add option deprecated = true; inside the rpc block, or [deprecated = true] on individual fields.
  • Generated SDKs: regenerate and publish them so the flag becomes a language-level deprecation, such as an annotation or a Deprecated: doc comment. IDEs and compilers will then warn at the call site.
  • Changelog: add an entry with the sunset date and the replacement.
  • CI: make lint or build checks fail on new references to deprecated symbols. This keeps existing callers working while blocking new ones.
# 1) HTTP response from the deprecated endpoint
HTTP/1.1 200 OK
Content-Type: application/json
# RFC 9745 structured date (Unix seconds): deprecated since Fri, 01 Aug 2025 00:00:00 GMT
Deprecation: @1754006400
# RFC 8594 HTTP-date: planned removal date
Sunset: Sat, 31 Jan 2026 23:59:59 GMT
# Add a Link header with rel="deprecation" here ONLY once migration docs are hosted.

{"orderId": "ord_12345", "status": "shipped"}

# 2) OpenAPI: mark the operation, not just the docs page
paths:
  /v1/orders/{orderId}:
    get:
      operationId: getOrderV1
      deprecated: true   # generators and doc renderers flag this visibly
      description: Deprecated. Use GET /v2/orders/{orderId}. Sunset 2026-01-31.

# 3) Protobuf: deprecate the RPC and individual fields
service OrderService {
  rpc GetOrderV1(GetOrderV1Request) returns (Order) {
    option deprecated = true;  // generated stubs emit language-level deprecation
  }
  rpc GetOrder(GetOrderRequest) returns (Order);
}

message Order {
  string order_id = 1;
  string legacy_status = 2 [deprecated = true];  // replaced by status
  string status = 3;
}

An HTTP response carrying Deprecation and Sunset headers, alongside the matching deprecated flag in OpenAPI and deprecated options in a proto definition, so the signal reaches both runtime traffic and generated SDKs.

Checklist step 4: Freeze the endpoint and drive consumer migration

Once the signals are in place, freeze the endpoint. A frozen endpoint gets no new features and no new consumers. Enforce the second rule at the gateway with an allowlist of the caller identities from your inventory, and reject anything else. Without enforcement, a team that copied an old example last week becomes a migration you didn't plan for. Bug fixes go in only for security or correctness. Urgent cases use the exception path in your deprecation policy, so each exception is recorded and does not quietly extend the endpoint's life.

The freeze stops the list of consumers from growing. API consumer migration is what makes it shrink, and it requires active work from you:

  • Per-consumer dashboard: for each caller, track recent call volume, owner and migration status. A low-traffic caller that has not moved is still a blocker for removal.
  • Tickets or pull requests: open them against known callers yourself. A pull request that changes the call usually gets merged faster than a request to "please migrate."
  • Field-by-field mapping: publish exactly how each old field maps to the replacement endpoint.
Old field (GET /v1/orders) New field (GET /v2/orders) Mechanical?
customer_name customer.display_name Yes
total (float, dollars) total.amount_minor (integer cents) Yes, multiply by 100
status (free text) status (enum) No, needs per-value decisions

Where every mapping is mechanical, a compatibility shim that translates old requests to the new endpoint can carry slow consumers. Give the shim a named owner and a removal date from the start. A shim without either becomes the deprecated endpoint under a new name.

Checklist step 5: Staged enforcement, brownouts, and removal criteria

Move through enforcement in stages, and advance only when a gate condition is met:

  1. Warn (headers, docs, changelog). Gate: every remaining caller is attributed to an owning team.
  2. Alert owners directly, with their own traffic numbers. Gate: each team has a migration date.
  3. Brownout: scheduled windows that return 410 Gone or a clear deprecation error. Gate: no unexplained failures during the last window.
  4. Remove, with the route kept behind a toggle. Gate: the rollback period expires without use.

A brownout is a deliberate outage, so treat it like one. Announce it in advance, put an owner on call, and publish an exact start and end time. Brownouts show you who is still calling, but only if the job runs during the window. A one-hour window will not catch a monthly billing export or a quarterly reconciliation. Lengthen the windows over time or line them up with known batch schedules.

Define removal by observable conditions, not by a date alone. A good criterion is zero attributed traffic over an agreed window plus written confirmation from every known consumer. Traffic alone can mislead you. A low-traffic window can hide infrequent jobs. Retries and caches can mask real usage. Callers behind shared credentials or a proxy can look like one client that has already migrated.

After removal, keep a fast rollback path, such as a feature flag or a gateway route toggle (see the configuration example), for a fixed period. That way, an overlooked consumer costs you a config change instead of a redeploy.

# Route toggle for the deprecated /v1/orders API (inside the http block).
# 1 = route serves traffic, 0 = route returns 410 Gone.
# Set it to 0 for a brownout window, and back to 1 when the window ends.
# After removal, leave it at 0. During the rollback period, an overlooked consumer
# costs a one-line edit plus `nginx -t && nginx -s reload`, not a redeploy.
map $host $orders_v1_enabled {
    default 0;
}

upstream orders_v1 {
    server orders-v1.internal:8080;  # placeholder; keep this until rollback expires
}

server {
    listen 80;
    server_name api.example.com;

    location /v1/orders {
        # Clear, machine-readable error for clients that are still calling.
        default_type application/json;

        if ($orders_v1_enabled = 0) {
            return 410 '{"error":"endpoint_removed","message":"/v1/orders was removed. Use /v2/orders. Contact team-orders for help."}';
        }

        proxy_pass
    }

    location /v2/orders {
        proxy_pass
    }
}

An nginx route toggle that serves the deprecated /v1/orders route when the flag is 1 and returns 410 Gone with a JSON migration message when it is 0, so the same one-line switch handles brownouts and rollback after removal.

Alternatives, cleanup, and impact on cost and engineering time

Once that window closes without incident, the remaining question is whether full removal was the right end state at all.

Option Consumer impact Maintenance cost Risk
Versioned endpoints side by side None until the old version retires High: two code paths, test suites, on-call surfaces Behaviour drifts between versions, and the old one rarely dies
Permanent minimal legacy endpoint None Low to medium: frozen, but still needs security patches Becomes unowned attack surface
Shim or adapter over the new API Low: extra latency, subtle semantic gaps Medium: translation breaks when the new API changes Mistranslated edge cases
Do nothing None today Compounds over time Blocks schema and dependency upgrades

Illustrative estimate (assumed numbers, not a benchmark).

  • Dual path cost = hours per month × months kept. At an assumed 6 h/month for 24 months, that is 144 h.
  • Migration cost = consumers × hours per consumer + platform hours. At an assumed 8 × 6 + 40, that is 88 h.

Break-even is migration cost ÷ monthly cost, here about 15 months. Substitute your own numbers.

Post-removal cleanup:

  • Handlers, the rollback flag, and the gateway routes.
  • Schemas. In protobuf, mark removed field numbers reserved rather than reusing them.
  • SDK methods, removed in the next major release.
  • Docs and changelog entries.
  • Dashboards and alerts. Left behind, they either go silent or fire on zero traffic.

Finally, record what the deprecation revealed: how many callers were unknown, which ones lacked attribution, and which stage overran. Turn each finding into a policy rule, such as mandatory caller identity at the gateway, so the next deprecation starts with a complete inventory.

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 microservice api, 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.