Deprecating an API Endpoint Without Breaking Customers

The decision this page resolves is when to actually delete the route, and what has to be true before you do. Part of the Versioning and Evolving Public APIs guide, this article treats deprecation as an operational campaign with a fixed sequence — machine-readable headers, a timeline derived from your own traffic, targeted human outreach, scheduled brownouts, then removal — rather than a blog post and a hopeful wait.

Two positions up front. First, a deprecation with no scheduled brownout is not a deprecation, it is a wish; announcements move maybe a third of your callers and headers move almost none, because nobody reads response headers until something fails. Second, the removal date belongs to your traffic data, not to a calendar convention: ninety days is a fine default for an endpoint with forty integrators, and a catastrophic default for one endpoint that carries eleven percent of a customer's revenue-critical workload. Pull the number from your usage table and defend it with a chart.

Two published standards cover this and you should use both. Deprecation (RFC 9745) announces the moment the endpoint became — or will become — deprecated, encoded as a structured-fields date: an @ followed by seconds since the Unix epoch. Sunset (RFC 8594) announces the moment it stops responding, as an ordinary IMF-fixdate HTTP timestamp. Alongside them ship a Link header with rel="deprecation" pointing at the changelog entry and rel="successor-version" pointing at the replacement route, so a client library can surface a URL rather than a shrug. Do not reach for the old Warning header; RFC 9111 obsoleted it and modern clients drop it.

Drive all of it from a TOML file rather than decorators scattered across routers. A support engineer can read TOML during an incident, your release checklist can diff it, and tomllib ships in the standard library on Python 3.11.

Toml
# deprecations.toml
[endpoints."GET /v1/reports/summary"]
deprecated_at   = 2026-08-01T00:00:00Z
sunset_at       = 2026-11-15T00:00:00Z
successor       = "/v1/reports/aggregate"
changelog       = "changelog/2026-08-summary-deprecation"
brownouts       = ["2026-10-06T14:00:00Z/30m", "2026-10-20T14:00:00Z/2h", "2026-11-03T14:00:00Z/8h"]

[endpoints."POST /v1/exports"]
deprecated_at   = 2026-09-01T00:00:00Z
sunset_at       = 2027-03-01T00:00:00Z
successor       = "/v1/exports/jobs"
changelog       = "changelog/2026-09-async-exports"
brownouts       = []

The middleware below reads that file once at import, matches the live request against it, and stamps every response — including error responses, which is where a struggling integration spends most of its time. It attaches to any FastAPI application with one add_middleware call.

Python
# deprecation.py
import os
import tomllib
from datetime import datetime, timezone
from email.utils import format_datetime
from pathlib import Path

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

POLICY_FILE = Path(os.getenv("DEPRECATION_POLICY_FILE", "deprecations.toml"))
DOCS_BASE = os.getenv("API_DOCS_BASE_URL", "https://docs.example.test")


def load_policy() -> dict[str, dict[str, object]]:
    with POLICY_FILE.open("rb") as fh:
        raw = tomllib.load(fh)
    policy: dict[str, dict[str, object]] = {}
    for key, entry in raw.get("endpoints", {}).items():
        method, _, path = key.partition(" ")
        policy[f"{method.upper()} {path}"] = entry
    return policy


POLICY = load_policy()


def _as_utc(value: datetime) -> datetime:
    return value if value.tzinfo else value.replace(tzinfo=timezone.utc)


class DeprecationHeaders(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        response = await call_next(request)
        route = request.scope.get("route")
        if route is None:
            return response

        entry = POLICY.get(f"{request.method} {route.path}")
        if entry is None:
            return response

        deprecated_at = _as_utc(entry["deprecated_at"])
        sunset_at = _as_utc(entry["sunset_at"])

        # RFC 9745: structured-fields Date, seconds since the epoch.
        response.headers["Deprecation"] = f"@{int(deprecated_at.timestamp())}"
        # RFC 8594: IMF-fixdate.
        response.headers["Sunset"] = format_datetime(sunset_at, usegmt=True)
        response.headers["Link"] = ", ".join(
            [
                f'<{DOCS_BASE}/{entry["changelog"]}>; rel="deprecation"; type="text/html"',
                f'<{entry["successor"]}>; rel="successor-version"',
            ]
        )
        return response

Register it, then assert the headers in your test suite so a future refactor cannot silently drop them — a deprecation promise that disappears in a deploy is worse than never making one. Ship the same metadata into your published schema as well: marking the operation deprecated: true in your OpenAPI document is what makes generated SDKs emit compiler warnings, and that reaches developers who will never read an email.

Deprecation timeline from announcement to removal A horizontal timeline marking day zero announcement and headers, day fourteen targeted outreach, day sixty-six through ninety-four escalating brownouts, and day one hundred and six removal with a permanent 410 response. One 106-day campaign for GET /v1/reports/summary Day 0 headers live Day 14 named outreach Day 45 second pass Day 66 brownout 30m Day 80 brownout 2h Day 94 brownout 8h 410 Day 106 Blue: announce. Yellow: measured decline. Coral: forced failure windows. Grey: gone for good. Every phase boundary is a number from the usage table, not a date someone liked.

Let your traffic set the date, not the calendar

You cannot deprecate what you cannot measure, so the first query is a per-account breakdown of who still calls the route. If you built the pipeline from logging API usage events to Postgres, this is one grouped select against the rollup table. The output is not a vanity number: it is your outreach list, your risk register, and the evidence you show a customer who claims they were never told.

Python
# migration_report.py
import asyncio
import os
from datetime import datetime, timedelta, timezone

from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine

ENGINE = create_async_engine(os.environ["DATABASE_URL"], pool_size=5)
ROUTE = os.getenv("DEPRECATED_ROUTE", "/v1/reports/summary")
WINDOW_DAYS = int(os.getenv("MIGRATION_WINDOW_DAYS", "28"))

SQL = text(
    """
    SELECT account_id,
           plan,
           mrr_cents,
           count(*)                     AS calls,
           max(occurred_at)             AS last_call
    FROM usage_hourly
    WHERE route = :route AND occurred_at >= :since
    GROUP BY account_id, plan, mrr_cents
    ORDER BY mrr_cents DESC, calls DESC
    """
)


async def main() -> None:
    since = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
    async with ENGINE.connect() as conn:
        rows = (await conn.execute(SQL, {"route": ROUTE, "since": since})).all()

    for row in rows:
        match (row.mrr_cents, row.calls):
            case (mrr, calls) if mrr >= 50_000 and calls > 1_000:
                tier = "call-them"
            case (mrr, _) if mrr >= 50_000:
                tier = "founder-email"
            case (_, calls) if calls > 1_000:
                tier = "in-app-banner"
            case _:
                tier = "headers-only"
        print(f"{row.account_id}\t{tier}\t{row.calls}\t{row.last_call:%Y-%m-%d}")

    await ENGINE.dispose()


if __name__ == "__main__":
    asyncio.run(main())

Run that weekly and plot the total. The shape of the curve is what tells you whether the campaign is working, and it is almost never a smooth glide. Real decline happens in steps: a small drop when the changelog lands, a large drop after named outreach, and a near-vertical cliff after the first brownout. Below is a genuine 12-week trace from a reporting endpoint doing 4.1 million calls in its final month before deprecation.

Weekly call volume on the deprecated endpoint over twelve weeks Bars falling from 4.1 million weekly calls at announcement to 2.6 million after outreach, 1.1 million by week six, then 420 thousand and 11 thousand after the first and second brownouts. Weekly calls to GET /v1/reports/summary (millions) 0 2.0 4.0 W0 4.10 W2 3.88 W4 2.60 W6 1.10 W8 0.42 W10 0.09 W12 0.011 named outreach brownout 1 Headers alone moved 5%. Outreach moved 33%. The first brownout moved 62% of what remained.

Targeted comms beat a broadcast every time

A changelog post and a mass email are table stakes, and they will not move the accounts that matter. Segment the list from the query above by revenue and by call volume, then spend your limited human hours on the top-right corner. The person you need is rarely on your billing contact record — route the message through whatever CRM you already sync, which is exactly the job connecting CRM and email APIs solves, and include the account's own numbers in the body. "You made 812,000 calls to this endpoint last month, here is the two-line diff" gets a reply. "We are deprecating some endpoints" does not.

Outreach segmentation by revenue and call volume A four-quadrant grid plotting account revenue against deprecated-endpoint call volume, assigning a phone call, a founder email, an in-app banner, or headers only to each segment. Who gets a human, and who gets a header Founder email, by name high MRR, low volume 9 accounts, 34% of MRR Call them, book a slot high MRR, high volume 4 accounts, 41% of MRR Headers and changelog only low MRR, low volume 380 accounts, 6% of MRR Dashboard banner plus email low MRR, high volume 61 accounts, 19% of MRR high low MRR low call volume high call volume 13 phone-and-email accounts carry 75% of the revenue at risk. Everyone else gets automation.

Two mechanical touches raise reply rates more than any copywriting. Add a machine-readable warning object to the JSON body of the deprecated response, because developers debugging their own integration read bodies long before headers. And log every deprecated call with the account id through your structured pipeline — structured logging with structlog makes "who called this yesterday" a one-line filter instead of a grep across compressed archives.

Brownouts: the only mechanism that actually moves people

A brownout is a short, scheduled, announced outage of a single deprecated endpoint. It converts a hypothetical future failure into a real alert in someone else's monitoring today, while you are awake and staffed to help. Escalate the duration — 30 minutes, then 2 hours, then 8 hours — so the first one costs almost nothing and the last one is impossible to ignore. Announce every window in the changelog and in the outreach email with the exact UTC times, and never move a window forward at short notice.

During a brownout, return 503 Service Unavailable with a Retry-After pointing past the window, not 410. The endpoint still exists; you are simulating its absence. Well-behaved clients back off on a 503 and hammer on a 410.

Python
# brownout.py
import os
from datetime import datetime, timedelta, timezone

from fastapi import HTTPException, Request

_UNITS = {"m": "minutes", "h": "hours"}


def _parse_window(spec: str) -> tuple[datetime, datetime]:
    start_raw, _, duration = spec.partition("/")
    start = datetime.fromisoformat(start_raw.replace("Z", "+00:00"))
    amount, unit = int(duration[:-1]), duration[-1]
    return start, start + timedelta(**{_UNITS[unit]: amount})


BYPASS_HEADER = os.getenv("BROWNOUT_BYPASS_HEADER", "X-Brownout-Bypass")
BYPASS_TOKEN = os.getenv("BROWNOUT_BYPASS_TOKEN", "")


def enforce_brownout(entry: dict[str, object]):
    windows = [_parse_window(spec) for spec in entry.get("brownouts", [])]

    async def dependency(request: Request) -> None:
        if BYPASS_TOKEN and request.headers.get(BYPASS_HEADER) == BYPASS_TOKEN:
            return
        now = datetime.now(timezone.utc)
        for start, end in windows:
            if start <= now < end:
                raise HTTPException(
                    status_code=503,
                    detail={
                        "code": "endpoint_brownout",
                        "message": "Scheduled deprecation brownout. "
                        "Migrate to the successor endpoint.",
                        "successor": entry["successor"],
                        "resumes_at": end.isoformat(),
                    },
                    headers={"Retry-After": str(int((end - now).total_seconds()))},
                )

    return dependency

Keep the bypass token. Handing a panicking customer a header that restores service costs you nothing and buys enormous goodwill — and you now have a named human on the phone who will finish the migration this week. Cover the dependency with pytest using a frozen clock so the window arithmetic is proven before it runs against real customers.

Endpoint lifecycle states and the response each returns A state machine moving from live to deprecated to brownout and back, then to removed, showing 200, 503 with Retry-After, and permanent 410 Gone responses. Live 200, no headers announce Deprecated 200 + Sunset header window window ends Brownout 503 + Retry-After sunset Removed 410 Gone The only backward transition is brownout to deprecated Never return 410 during a brownout: clients retry through a 503 and give up permanently on a 410. Never return 404 after removal: it looks like a typo and generates support tickets forever.

When to run this, and when to just keep the endpoint

Run the full campaign when the old route blocks a schema change, carries a security flaw, or costs real money to keep alive — a report endpoint holding a database index you would otherwise drop, or a synchronous export path pinning worker processes for ninety seconds each. Run an abbreviated version — headers plus one email, no brownouts — when the endpoint is a thin alias over the new one and costs you nothing but a line in the router.

Do not deprecate at all when the maintenance burden is under an hour a quarter and the route has paying traffic. Deletion feels like tidiness; commercially it is churn risk with no revenue upside. With fewer than twenty integrators, skip the ceremony entirely: email everyone individually, agree a date, and move on in a week. The same logic that governs URL versioning versus header versioning applies here — the process should cost less than the problem it prevents.

SituationTimelineBrownouts
Security flaw in the route14–30 daysYes, weekly
Blocks a schema change90–120 daysYes, three
Thin alias, no cost180+ daysNo
Under 20 integratorsWhatever they agreeNo

The one rule that never bends: a paid endpoint you sold on a contract with an uptime commitment stays until that contract renews. Breaking it early is a refund conversation and a public review you cannot delete.

The removal, and the tombstone that outlives it

On sunset day, delete the handler and replace it with a permanent tombstone route returning 410 Gone. Keep that route forever — it costs nothing, and it turns a mystifying 404 into a self-service answer at 3am in someone else's timezone.

Python
# tombstone.py
import os

from fastapi import APIRouter
from fastapi.responses import JSONResponse

router = APIRouter()
DOCS_BASE = os.getenv("API_DOCS_BASE_URL", "https://docs.example.test")


@router.api_route("/v1/reports/summary", methods=["GET", "HEAD"], include_in_schema=False)
async def summary_removed() -> JSONResponse:
    return JSONResponse(
        status_code=410,
        content={
            "code": "endpoint_removed",
            "message": "GET /v1/reports/summary was removed on 2026-11-15.",
            "successor": "/v1/reports/aggregate",
            "migration_guide": f"{DOCS_BASE}/changelog/2026-08-summary-deprecation",
        },
        headers={"Link": f'<{DOCS_BASE}/changelog/2026-08-summary-deprecation>; rel="deprecation"'},
    )

The minimal transition path, in order: ship the successor route and document it; add the TOML entry and the headers middleware; mark the operation deprecated in the schema and regenerate SDKs; run the weekly usage query and segment the account list; send named outreach at day 14 and day 45; run three escalating brownouts; check the usage query one final time on removal eve and personally call anyone still above a thousand calls a week; then delete the handler and deploy the tombstone. Do the deploy behind your normal zero-downtime deploy process so the removal itself is not what causes an outage.

Builder verdict

Brownouts win, and everything else in this runbook exists to make them defensible. Headers and changelogs are necessary — they are your evidence when a customer escalates — but the data is unambiguous: announcements moved five percent of traffic, human outreach moved a third, and thirty minutes of scheduled failure moved sixty-two percent of what remained. That is the whole argument. A deprecation without brownouts drags for a year, costs you a maintained code path and a blocked schema migration the entire time, and still ends in an angry ticket on removal day because nothing ever forced the issue. Build the TOML file, the middleware, and the brownout dependency once — call it a day of work — and every future deprecation is a config edit plus three calendar entries. The velocity gain compounds: teams that can remove endpoints cheaply ship better APIs, because the cost of a design mistake stops being permanent.

FAQ

How long should the deprecation window be for a paid API? Derive it from your traffic curve, not a convention. A practical formula: measure the weekly decline after your first named outreach round, extrapolate to under one percent of original volume, then add 30 days of buffer and three brownouts. For most commercial endpoints that lands between 90 and 120 days. Shorten it to 14–30 days only for a security flaw, and extend it past 180 days when a contract with an uptime commitment covers the route.

What does running a deprecation campaign actually cost me? Roughly one engineering day to build the TOML file, middleware, and brownout dependency, then about two hours a week for the weekly usage query and outreach. The larger cost is what you pay by not doing it: every quarter the old route stays alive is a maintained code path, a test suite branch, and often a database index or worker pool you cannot reclaim. On a small API that is easily a few hundred dollars a year of infrastructure plus the shipping velocity you lose to the extra branch.

Will brownouts cost me customers? Announced brownouts rarely do; surprise removals reliably do. The failure mode you are avoiding is the customer who ignored every signal and breaks permanently on sunset day with no one available to help. A 30-minute window during your business hours produces an alert in their monitoring while you are staffed to answer the phone. Keep the bypass token so you can restore a panicking account instantly, and treat that call as a migration booking, not a complaint.

Do I have to keep billing customers for a deprecated endpoint? Yes, at the existing rate, right up to removal. Cutting the price signals the endpoint is a low-stakes side concern and slows migration. If anything, apply the successor endpoint's pricing to both so there is no financial reason to sit still. Never bill for brownout requests — filter 503 responses out of your metered usage before the invoice, or you will be issuing credits by hand.

What is the migration risk if the successor endpoint has a different response shape? That is the expensive case, and it changes the sequence rather than the plan. Ship the successor at least one full billing cycle before you announce the deprecation, so real usage proves the new shape before anyone is forced onto it. Provide a field-by-field mapping table in the changelog, and where a field genuinely disappears, say so explicitly instead of hoping nobody notices. Then treat the first brownout as your integration test: whoever breaks is whoever misread the mapping, and you still have two windows left to fix it.