Handling 405 vs 501 for Unsupported Methods

This page sits under HTTP Method Mapping Guidelines in the API Design Fundamentals & Architecture reference. Both codes say “I will not do that with this method”, and they are not interchangeable: one is about the resource, the other about the server. Getting it wrong misleads clients about whether retrying elsewhere could ever work.

The decision trigger

Request Correct response
DELETE /accounts/{id}/settings where deletion is meaningless 405 + Allow: GET, PUT, PATCH
POST /orders/{orderId} on a member resource 405 + Allow: GET, PUT, PATCH, DELETE
PROPFIND /orders on a non-WebDAV server 501
TRACE /orders where the server disables it globally 405 (understood, disallowed) or 501 (never implemented)
GET /nonexistent-path 404 — nothing exists there
PATCH /orders/{id} where patching is planned but unbuilt 405 today; the roadmap is not a status code

Spec snippet: what the specification says

RFC 9110 §15.5.6 — 405 Method Not Allowed
  "the method received in the request-line is known by the origin server but
   not supported by the target resource … The origin server MUST generate an
   Allow header field in a 405 response containing a list of the target
   resource's currently supported methods."

RFC 9110 §15.6.2 — 501 Not Implemented
  "the server does not support the functionality required to fulfill the
   request … the appropriate response when the server does not recognize the
   request method and is not capable of supporting it for any resource."

Two words settle every case: 405 is scoped to the target resource, 501 is scoped to any resource. A method you support somewhere in the API can never legitimately produce 501, because by definition the server is capable of supporting it.

Choosing between 404, 405 and 501 If no route matches the path, return 404. If the method is not recognised by the server at all, return 501. If the method is recognised but not allowed on this resource, return 405 with an Allow header. Otherwise dispatch to the handler. does the path match a route? no 404 yes is the method known to the server at all? no 501 yes allowed on THIS resource? no 405 + Allow yes dispatch the order of these checks is itself part of the contract path first, then method recognition, then per-resource permission

Step-by-step

Step 1 — Build Allow from the route table, not by hand

A hand-written Allow header drifts the moment a method is added. Derive it:

// method-not-allowed.ts — Allow generated from the registered routes
import type { Express, Request, Response, NextFunction } from "express";

const KNOWN_METHODS = new Set([
  "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT",
]);

export function methodGuard(app: Express) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!KNOWN_METHODS.has(req.method)) {
      // The server does not implement this method for ANY resource.
      return res.status(501).type("application/problem+json").json({
        type: "urn:api:errors:v1:method-not-implemented",
        title: "Method not implemented",
        status: 501,
        detail: `This server does not support the ${req.method} method.`,
      });
    }

    const allowed = methodsForPath(app, req.path);      // reads the router table
    if (allowed.length === 0) return next();            // no route: let 404 handle it
    if (allowed.includes(req.method)) return next();

    // HEAD is implied by GET; OPTIONS is always answerable.
    const advertised = [...new Set([...allowed, ...(allowed.includes("GET") ? ["HEAD"] : []), "OPTIONS"])];

    res.setHeader("Allow", advertised.join(", "));
    res.status(405).type("application/problem+json").json({
      type: "urn:api:errors:v1:method-not-allowed",
      title: "Method not allowed for this resource",
      status: 405,
      detail: `${req.method} is not supported here. Supported: ${advertised.join(", ")}.`,
      allowed_methods: advertised,
    });
  };
}

Echoing the allowed methods in the problem body as well as the header is redundant on purpose: the header is what specification-aware tooling reads, and the body is what a developer sees in a terminal.

Step 2 — Answer OPTIONS from the same source

// options-handler.ts — OPTIONS should never be a 405
app.options("*", (req, res) => {
  const allowed = methodsForPath(app, req.path);
  if (allowed.length === 0) return res.status(404).end();

  const advertised = [...new Set([...allowed, ...(allowed.includes("GET") ? ["HEAD"] : []), "OPTIONS"])];
  res.setHeader("Allow", advertised.join(", "));
  res.status(204).end();
});

OPTIONS is the discovery mechanism that makes Allow useful before a failed request rather than after it. Deriving both from the same route table guarantees they agree.

Step 3 — Know what your framework does by default

Defaults differ, and several are wrong for an API:

Framework Default for a known path, unsupported method
Express (no guard) falls through to the 404 handler
Fastify 404 unless a method-not-allowed hook is added
FastAPI / Starlette 405 with Allow
Django REST Framework 405 with Allow
Spring MVC 405 with Allow
Go net/http (manual mux) whatever the handler writes — often 404

The Express and Go rows are the common source of confusion: a POST to a path that only has a GET route returns 404, telling the client the resource does not exist when it plainly does. Adding the guard above changes it to a 405 that names the fix.

Step 4 — Document it once in the specification

# openapi.yaml — a shared 405 response, referenced from operations that need it
components:
  responses:
    MethodNotAllowed:
      description: >
        The method is not supported for this resource. The `Allow` header
        lists the methods that are.
      headers:
        Allow:
          description: Comma-separated list of supported methods.
          schema: { type: string }
          example: "GET, PUT, PATCH, DELETE, HEAD, OPTIONS"
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
paths:
  /accounts/{accountId}/settings:
    get:   { operationId: getAccountSettings }
    put:   { operationId: replaceAccountSettings }
    patch: { operationId: updateAccountSettings }
    # POST and DELETE deliberately absent — the guard returns 405 + Allow.
An incomplete 405 versus a complete one Without an Allow header the client learns only that the request failed and must consult documentation. With Allow and a problem body the client can immediately see which methods are supported and correct the call. 405 without Allow 405 done properly HTTP/1.1 405 Method Not Allowed Content-Length: 0 (no body, no Allow) client cannot tell which method works reads docs, or guesses, or files a ticket HTTP/1.1 405 Method Not Allowed Allow: GET, PUT, PATCH, HEAD, OPTIONS Content-Type: application/problem+json { "type": "…method-not-allowed", … } client sees the supported set immediately fix is one line, no documentation lookup

Standard compliance

Element Standard Clause Requirement
405 Method Not Allowed RFC 9110 §15.5.6 Allow header is required
501 Not Implemented RFC 9110 §15.6.2 Method unrecognised for any resource
Allow header syntax RFC 9110 §10.2.1 Comma-separated method list
OPTIONS RFC 9110 §9.3.7 Communicates supported methods
404 Not Found RFC 9110 §15.5.5 No representation exists for the target
501 is cacheable by default RFC 9110 §15.6.2 Unlike 405 — see below

The last row catches people out. 501 responses are cacheable by default, so a 501 emitted transiently — during a rolling deploy where one node lacks a route — can be stored by an intermediary and served after the deploy completes. That alone is a reason to reserve 501 for genuinely permanent conditions and to send Cache-Control: no-store if you must return it for a temporary one.

The header is what turns the rejection into something a developer can act on without opening the reference:

What the Allow header changes for the caller Without an Allow header the caller knows only that the request failed. With it, the supported method set is visible immediately and the fix takes seconds. 405 with no Allow 405 with Allow + problem body caller sees a bare failure checks the URL, the id, the base path opens the API reference files a support ticket supported methods listed inline the fix is visible at the call site no documentation lookup OPTIONS answers the same question RFC 9110 §15.5.6 makes the header mandatory — generate it from the router

Retry and safety implications

Both codes are non-retryable in the sense that repeating the identical request cannot succeed: nothing about the server’s state will change to make DELETE suddenly valid on a settings singleton. Clients should therefore treat both as terminal and not schedule a retry, which is the classification described in Retryable vs Non-Retryable Errors. The exception worth documenting is a 501 emitted during a partial deployment, which is a genuine transient — and the honest fix there is to return 503 with Retry-After instead, since that is the code that means “temporarily unable”.

SDK / codegen downstream effect

  // Generated client — the method simply does not exist for unsupported verbs
  await client.accounts.getAccountSettings({ accountId });
  await client.accounts.replaceAccountSettings({ accountId, body });
- await client.accounts.deleteAccountSettings({ accountId });   // not generated
+ // ^ compile error: the operation is absent from the specification

This is the quiet benefit of modelling method support accurately in the document: unsupported operations never appear in the SDK, so a caller cannot make the mistake at all. Runtime 405s then only arise from hand-written HTTP calls — which is a useful signal that someone bypassed the client.

Common mistakes

Mistake Correct approach
405 without an Allow header Required by the specification; generate it from the routes
404 for a known path with an unsupported method Add a method guard that returns 405
501 for a method supported elsewhere in the API Use 405; the server clearly implements it
501 during a rolling deploy Use 503 with Retry-After for transient conditions
Hand-maintained Allow lists Derive from the router so they cannot drift
OPTIONS returning 405 Answer OPTIONS from the same route table
400 for an unsupported method The request is well-formed; 405 names the actual problem

FAQ

When should an API return 501 instead of 405?

Only when the server does not implement the method for any resource — an unrecognised or exotic verb such as PROPFIND on a server that has never spoken WebDAV. In an ordinary REST API this is rare, because the methods clients send are the ones the API already supports somewhere. 405 covers the normal case: the method is perfectly well understood and used elsewhere, it just does not apply to this resource. A useful test is to ask whether the method appears anywhere in your OpenAPI document; if it does, 501 is the wrong answer.

Is the Allow header mandatory on a 405?

Yes — RFC 9110 §15.5.6 states that the origin server MUST generate an Allow header field listing the target resource’s currently supported methods. This is one of the few genuinely mandatory headers on an error response, and the reason is discoverability: without it, the client knows only that its request failed, with no indication of what would have worked. Generate the list from your router rather than writing it by hand, and remember that HEAD is implied wherever GET is supported and OPTIONS should always be listed.

Should an unknown path return 404 or 405?

404. The two codes describe different failures: 404 means no resource is identified by that URL, while 405 means a resource exists there but does not accept this method. Returning 405 for an unknown path is both misleading and mildly leaky, because it implies something exists at a URL an attacker was probing. The ordering in your middleware should therefore be path matching first, then method recognition, then per-resource permission — and frameworks whose default is to fall through to the 404 handler on a method mismatch need an explicit guard to get this right.