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.