Guides

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

When an API endpoint is retired, return 410 Gone, not 404 Not Found. A 404 sends the caller hunting for a typo. A 410 tells them the endpoint existed, was removed on purpose, and won't come back.

What each status says to the caller

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 a useful 410 looks like

The body should let the last straggler 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"
}

Returning 410 for a retired version

Catch every path under the retired prefix, after your live routes.

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

How long to keep returning 410

Keep the 410 handler for at least as long as your notice period was. It 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.

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.