410 Gone vs 404: what to return for a retired API endpoint

Return 410 Gone, not 404 Not Found, when you retire an API endpoint or version. A 410 tells the caller the endpoint existed, was removed on purpose and won't come back; a 404 sends them hunting for a typo. Include a problem-details body that names the retirement date and links to the migration guide.

By , Founder · · Updated
Which status to return: 404 for a path that never existed, 410 Gone for an endpoint removed on purpose, 308 for one that moved with the same request, and 200 with Deprecation and Sunset headers before retirement.

What does each status tell the caller?

410 says "removed on purpose", 404 says "nothing here", and a redirect says "it moved". Pick the one that matches what happened:

Status What the caller learns Use it when
410 Gone This existed, and is permanently gone. A version or endpoint you retired on purpose.
404 Not Found Nothing here, and no reason given. A path that never existed, or one you won't confirm.
301 / 308 It moved. Follow the Location header. The replacement accepts exactly the same request.
200 + Sunset Still works, but not for long. Before the retirement date.

RFC 9110 defines 410 as a resource that is no longer available, where the condition is likely to be permanent. That's a retired API version exactly.

Be careful with redirects. A 308 to v2 only works if v2 accepts the same request body and returns a shape the caller can parse. Usually it doesn't, which is why you made a new version.

What should a 410 response body say?

It should say what was retired, when, and where the replacement is, so the last straggler can fix their integration without opening a ticket. Use the problem details format from RFC 9457, and keep the Link header you sent during deprecation:

HTTP/1.1 410 Gone
Content-Type: application/problem+json
Link: <https://api.example.com/docs/migrate-to-v2>; rel="deprecation"; type="text/html"

{
  "type": "https://api.example.com/problems/version-retired",
  "title": "API version retired",
  "status": 410,
  "detail": "v1 was retired on 30 June 2027. Use v2 instead.",
  "migration_guide": "https://api.example.com/docs/migrate-to-v2"
}

Keep the type URL stable and document it, so a client can match on it in code. A human reads detail; a program reads type and status. Leave out anything that changes per request, such as a timestamp, so the response can be cached and compared.

Is 404 ever the right answer for a retired endpoint?

Yes, in two cases. The first is a path that only ever existed for one customer, or that would reveal something you don't want to confirm, such as an internal admin route: there, 404 tells a stranger nothing. The second is an endpoint that never had real traffic, where no one will read the 410 anyway.

Everywhere else, 410 is the kinder answer. A caller who gets a 404 for an endpoint they used yesterday checks their base URL, their environment and their credentials before they suspect a retirement. A caller who gets a 410 with a migration link fixes the right thing first.

What about 400, 426 or 501?

None of them fits as well. 400 Bad Request blames the caller's request, when the request is fine and the endpoint is what changed. 426 Upgrade Required is about switching protocols, such as moving to TLS or a newer HTTP version, not API versions. 501 Not Implemented says the server does not support the method at all, which reads as a bug on your side.

How do you return 410 for a whole retired version?

Catch every path under the retired prefix with one handler, placed after your live routes so it never shadows them.

Express:

app.use("/v1", (req, res) => {
  res
    .status(410)
    .set("Link", '<https://api.example.com/docs/migrate-to-v2>; rel="deprecation"; type="text/html"')
    .type("application/problem+json")
    .send(JSON.stringify({
      type: "https://api.example.com/problems/version-retired",
      title: "API version retired",
      status: 410,
      detail: "v1 was retired on 30 June 2027. Use v2 instead.",
    }));
});

Phoenix:

# router.ex, after the live scopes
scope "/v1", MyAppWeb do
  match :*, "/*path", RetiredController, :gone
end

# retired_controller.ex
defmodule MyAppWeb.RetiredController do
  use MyAppWeb, :controller

  def gone(conn, _params) do
    conn
    |> put_resp_header("link", ~s(<https://api.example.com/docs/migrate-to-v2>; rel="deprecation"; type="text/html"))
    |> put_resp_content_type("application/problem+json")
    |> send_resp(410, Jason.encode!(%{
      type: "https://api.example.com/problems/version-retired",
      title: "API version retired",
      status: 410,
      detail: "v1 was retired on 30 June 2027. Use v2 instead."
    }))
  end
end

Can you return 410 for one endpoint in a version that still works?

Yes. Retiring a single endpoint, such as an old export route, inside a version that stays live is common, and 410 says exactly that: this one route is gone, the rest still works. Give that route its own handler, its own migration link, and its own Sunset date during the notice period, so clients can tell it apart from the version as a whole.

Watch the methods too. If GET /v1/reports stays but POST /v1/reports is retired, return 410 for the POST only. A 405 Method Not Allowed describes a method the resource doesn't support, and says nothing about a retirement or where to go next.

How should a client handle a 410?

Stop retrying and raise it to a person. A 410 is permanent, so a retry loop only adds load and hides the real problem in the logs. If you publish an SDK, make it treat 410 as a non-retryable error that names the migration link from the response, so the developer sees the fix in the exception message.

  • Don't put 410 in the list of statuses your HTTP client retries, the way 502 and 503 often are.
  • Alert on it: one 410 in production means a call path nobody migrated.
  • Log the type and detail fields, not only the status code.

How long should you keep returning 410?

At least as long as your notice period was. The 410 handler costs almost nothing to run, and every request it answers is a caller you can still reach. Log those requests by client: the list is your follow-up list.

One caution: caches may store a 410 by default, so don't send one for something you might bring back. If you're not sure the retirement is final, you're not ready to retire.

Sources

Where EndPointBlank fits

EndPointBlank tracks each endpoint version from its first deploy to its retirement. A version that leaves production after being deprecated is marked retired automatically. If a version marked retired is still being served in production, the portal flags it, because callers of that version are getting no warning at all.

See how version lifecycle works or start free. For the whole process, read how to deprecate an API version.

Ready to deprecate with confidence?

Sign up in under a minute. No credit card, no sales call.