HTTP Method Mapping Guidelines

Part of the API Design Fundamentals & Architecture reference — precise HTTP verb assignment is the foundation of predictable contracts, deterministic SDK generation, and reliable client retry logic.

Problem Framing

Ambiguous or incorrectly assigned HTTP methods cause failures across three dimensions: contract consumers misinterpret the mutation semantics, code generators emit colliding method names, and retry middleware triggers duplicate side effects on unsafe operations. A GET that writes, a POST that replaces, or a PATCH given the same operationId as a PUT — each one silently corrupts the contract before a single request leaves the client. Fixing method semantics at the spec layer costs minutes; fixing them after SDK distribution costs weeks.

HTTP Method Safety and Idempotency Decision Tree A diagram showing GET, HEAD, PUT, DELETE as safe or idempotent, POST and PATCH as unsafe/non-idempotent, and the retryability implications for generated SDK clients. RFC 9110 safety / idempotency → SDK retry policy Safe + Idempotent GET HEAD Unsafe + Idempotent PUT DELETE Unsafe + Non-Idempotent POST PATCH PATCH + Idem-Key Auto-retry +cache Auto-retry, no cache No auto-retry No auto-retry Retry with key list() / get() replace() remove() create() updatePartial() updatePartial() Generated SDK method names driven by operationId convention

Spec Definition

These OpenAPI 3.1 operation blocks capture the complete correct contract for a standard user resource. Each operationId drives the generated SDK method name; each combination of verb + path encodes the RFC 9110 safety and idempotency guarantee.

openapi: 3.1.0
info:
  title: Users API
  version: 1.0.0
paths:
  /users:
    get:
      operationId: listUsers
      summary: List user collection
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Paginated user list
    post:
      operationId: createUser
      summary: Create a new user resource
      parameters:
        - name: X-Idempotency-Key
          in: header
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserInput" }
      responses:
        "201":
          description: User created
  /users/{id}:
    get:
      operationId: getUser
      summary: Fetch a single user by ID
      responses:
        "200":
          description: User resource
        "404":
          description: Not found
    put:
      operationId: replaceUser
      summary: Full replacement of a user resource
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserInput" }
      responses:
        "200":
          description: Replaced user
    patch:
      operationId: updateUserPartial
      summary: Partial modification of a user resource
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: "#/components/schemas/UserPatch" }
      responses:
        "200":
          description: Updated user
    delete:
      operationId: deleteUser
      summary: Remove a user resource
      responses:
        "204":
          description: Deleted
        "405":
          description: Method Not Allowed
          headers:
            Allow:
              schema: { type: string, example: "GET, PUT, PATCH, DELETE" }

RFC 9110 Alignment

Method Safe Idempotent RFC 9110 Section Typical Status Codes
GET Yes Yes §9.3.1 200, 304, 404
HEAD Yes Yes §9.3.2 200, 404
POST No No §9.3.3 201, 202, 409, 422
PUT No Yes §9.3.4 200, 204, 409
DELETE No Yes §9.3.5 204, 404
PATCH No No §9.3.7 200, 409, 422
OPTIONS Yes Yes §9.3.7 200, 204

Key contract implications:

Implementation Walkthrough: Server-Side Method Routing

The routing layer is the first place method semantics become code. The example below uses Express (Node.js) but the patterns apply to any framework — the important detail is the one-to-one mapping between verb, path, and a single unambiguous controller action.

// users.router.ts
import { Router } from "express";
import { UsersController } from "./users.controller";
import { requireIdempotencyKey } from "../middleware/idempotency";
import { validateBody } from "../middleware/validate";
import { UserInputSchema, UserPatchSchema } from "./users.schema";

const router = Router();
const ctrl = new UsersController();

// Collection endpoints
router.get("/users", ctrl.list);
// requireIdempotencyKey enforces X-Idempotency-Key on POST
router.post("/users", requireIdempotencyKey, validateBody(UserInputSchema), ctrl.create);

// Item endpoints — strict method-to-action mapping
router.get("/users/:id", ctrl.get);
router.put("/users/:id", validateBody(UserInputSchema), ctrl.replace);
router.patch("/users/:id", validateBody(UserPatchSchema), ctrl.updatePartial);
router.delete("/users/:id", ctrl.remove);

// Explicit 405 for methods that reach this router but match no verb
router.all("/users/:id", (req, res) => {
  res.set("Allow", "GET, PUT, PATCH, DELETE").status(405).json({
    type: "https://api-contract.com/errors/method-not-allowed",
    title: "Method Not Allowed",
    status: 405,
    detail: `${req.method} is not supported on /users/{id}`,
  });
});

export default router;
# users_router.py — FastAPI equivalent
from fastapi import APIRouter, Header, Request, Response
from .schemas import UserInput, UserPatch
from .controller import UsersController

router = APIRouter(prefix="/users")
ctrl = UsersController()

@router.get("", response_model=list[UserInput])
async def list_users(cursor: str | None = None):
    return ctrl.list(cursor)

@router.post("", status_code=201)
async def create_user(
    body: UserInput,
    x_idempotency_key: str = Header(..., alias="X-Idempotency-Key"),
):
    return ctrl.create(body, idempotency_key=x_idempotency_key)

@router.get("/{id}")
async def get_user(id: str):
    return ctrl.get(id)

@router.put("/{id}")
async def replace_user(id: str, body: UserInput):
    return ctrl.replace(id, body)

@router.patch("/{id}", response_model=UserInput)
async def update_user_partial(id: str, body: UserPatch):
    return ctrl.update_partial(id, body)

@router.delete("/{id}", status_code=204)
async def delete_user(id: str, response: Response):
    ctrl.remove(id)

The requireIdempotencyKey middleware rejects POST requests without X-Idempotency-Key, which both enforces the spec and prevents duplicate resource creation from network retries. For the full deduplication store implementation, see Generating Idempotency Keys in Node.js / Express APIs.

Implementation Walkthrough: Client-Side Retry Configuration

Generated SDK clients must encode the RFC 9110 idempotency distinctions as retry policy. The configuration below targets the Axios-based TypeScript SDK output from OpenAPI Generator and a httpx-based Python client.

// api-client.ts — retry policy aligned to HTTP method semantics
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
import axiosRetry from "axios-retry";

const IDEMPOTENT_METHODS = new Set(["get", "head", "put", "delete", "options"]);

export function buildApiClient(baseURL: string): AxiosInstance {
  const client = axios.create({ baseURL });

  axiosRetry(client, {
    retries: 3,
    retryDelay: axiosRetry.exponentialDelay,
    retryCondition: (error) => {
      const method = error.config?.method?.toLowerCase() ?? "";
      // Only retry idempotent methods or POST/PATCH with an idempotency key present
      const hasIdempotencyKey = !!error.config?.headers?.["X-Idempotency-Key"];
      const isRetryable = IDEMPOTENT_METHODS.has(method) || hasIdempotencyKey;
      const isServerError = axiosRetry.isNetworkOrIdempotentRequestError(error);
      return isRetryable && isServerError;
    },
  });

  // Attach If-None-Match for GET/HEAD; strip it for mutations
  client.interceptors.request.use((config) => {
    const method = config.method?.toLowerCase() ?? "";
    if (!["get", "head"].includes(method)) {
      delete config.headers["If-None-Match"];
      delete config.headers["Cache-Control"];
    }
    return config;
  });

  return client;
}
# api_client.py — httpx transport with idempotent-only auto-retry
import httpx
from httpx import Request, Response

IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}

class IdempotentRetryTransport(httpx.HTTPTransport):
    def __init__(self, max_retries: int = 3, **kwargs):
        super().__init__(**kwargs)
        self._max_retries = max_retries

    def handle_request(self, request: Request) -> Response:
        is_idempotent = request.method in IDEMPOTENT_METHODS
        has_idem_key = "X-Idempotency-Key" in request.headers

        for attempt in range(self._max_retries if (is_idempotent or has_idem_key) else 1):
            response = super().handle_request(request)
            if response.status_code < 500:
                return response
            if attempt == self._max_retries - 1:
                break
        return response

client = httpx.Client(
    base_url="https://api.example.com",
    transport=IdempotentRetryTransport(max_retries=3),
)

Edge-Case Handling

Bulk operations. When clients need to modify multiple resources atomically, the instinct is to send multiple PATCH requests. Prefer a single POST /users/batch-update with an explicit X-Idempotency-Key and a discriminated union body instead. This keeps individual resource endpoints idempotency-pure while giving the bulk operation a safe, retryable path. Align the resource boundary rules with Resource Modeling Best Practices before introducing batch paths.

Conditional updates with If-Match. PUT and PATCH on high-contention resources should require the client to echo an ETag via If-Match. The server returns 412 Precondition Failed if the resource has changed since the client last read it. This turns both methods into compare-and-swap operations at zero additional protocol cost. Spec the conditional header in the OpenAPI parameter list so generators can surface it as a typed argument.

Legacy proxies and x-http-method-override. Enterprise proxies and some load balancers reject PUT, PATCH, and DELETE. Accept POST /users/{id} with an X-HTTP-Method-Override: PATCH header and a 405 Allow fallback for direct PUT. Document both paths in the OpenAPI spec under separate operations with the x-internal: true extension on the override path so generators skip it.

OPTIONS and CORS preflight. OPTIONS must enumerate every supported verb in the Allow header for every path. Many gateways auto-generate this, but spec it explicitly to prevent generators from emitting a blank options() method that confuses API consumers.

DELETE with a body. RFC 9110 §9.3.5 permits request bodies on DELETE but notes that servers may reject them. Avoid relying on a DELETE body to carry resource identifiers; use path parameters exclusively. If bulk deletion is required, use POST /users/batch-delete with an idempotency key.

Validation and Testing Patterns

Two Spectral rules cover the most common spec violations: missing operationId on mutation paths, and POST/PATCH without a required X-Idempotency-Key parameter.

{
  "rules": {
    "operation-id-naming-convention": {
      "description": "operationId must follow verb-prefix + PascalCase noun convention",
      "severity": "error",
      "given": "$.paths[*][get,post,put,patch,delete]",
      "then": {
        "field": "operationId",
        "function": "pattern",
        "functionOptions": {
          "match": "^(list|get|create|replace|updatePartial|delete|search|batch)[A-Z][a-zA-Z]+$"
        }
      }
    },
    "mutation-requires-idempotency-key": {
      "description": "POST and PATCH operations must declare X-Idempotency-Key header parameter",
      "severity": "warn",
      "given": "$.paths[*][post,patch].parameters[*]",
      "then": {
        "function": "schema",
        "functionOptions": {
          "schema": {
            "if": { "properties": { "in": { "const": "header" }, "name": { "const": "X-Idempotency-Key" } }, "required": ["in", "name"] },
            "then": { "properties": { "required": { "const": true } } }
          }
        }
      }
    },
    "mutation-content-type-required": {
      "description": "POST, PUT, and PATCH must declare application/json or application/merge-patch+json requestBody",
      "severity": "error",
      "given": "$.paths[*][post,put,patch].requestBody.content",
      "then": {
        "function": "truthy"
      }
    },
    "method-not-allowed-has-allow-header": {
      "description": "405 responses must declare the Allow response header",
      "severity": "warn",
      "given": "$.paths[*][*].responses['405']",
      "then": {
        "field": "headers.Allow",
        "function": "truthy"
      }
    }
  }
}

Wire this ruleset into CI so the build fails before SDK generation triggers:

# .github/workflows/contract-lint.yml
name: Contract Lint
on: [push, pull_request]
jobs:
  spectral:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Spectral
        run: npm install -g @stoplight/spectral-cli
      - name: Lint OpenAPI contract
        run: |
          spectral lint openapi.yaml \
            --ruleset .spectral.json \
            --format github-actions

For statelessness and caching-header validation rules that complement these verb checks, see Statelessness & Caching Strategies.

SDK Generation Impact

The operationId values in the OpenAPI spec are the single source of truth for generated method names. Mismatched or colliding IDs cause generator failures or runtime type errors that are painful to untangle after SDK publication.

operationId TypeScript Python (openapi-generator) Go (oapi-codegen) Kiota retry
listUsers api.users.list() users_api.list_users() ListUsers() Auto-retry (GET)
createUser api.users.create() users_api.create_user() CreateUser() No auto-retry
replaceUser api.users.replace() users_api.replace_user() ReplaceUser() Auto-retry (PUT)
updateUserPartial api.users.updatePartial() users_api.update_user_partial() UpdateUserPartial() No auto-retry
deleteUser api.users.remove() users_api.delete_user() DeleteUser() Auto-retry (DELETE)

Kiota-specific configuration. Kiota injects retry policy from the method’s RFC 9110 idempotency property. Override for POST + idempotency key:

// kiota-client.ts
import { KiotaClientFactory } from "@microsoft/kiota-http-fetchlibrary";

const client = KiotaClientFactory.create(requestAdapter, {
  retryHandler: {
    shouldRetry: (delay, executionCount, request, response) => {
      const method = request.method.toUpperCase();
      const hasIdemKey = request.headers.has("X-Idempotency-Key");
      const idempotent = ["GET", "HEAD", "PUT", "DELETE"].includes(method);
      return (idempotent || hasIdemKey) && (response?.status ?? 0) >= 500;
    },
  },
});

Request schema type safety. Generating replace() and updatePartial() as separate methods means the TypeScript compiler can enforce that PUT receives a complete UserInput while PATCH accepts a Partial<UserInput> (or explicit merge-patch schema). Declaring application/merge-patch+json in the patch operation’s requestBody.content is what triggers generators to emit the narrower type — not just the verb itself.

The safety and idempotency properties are what clients actually rely on, and they are easy to state precisely:

Method properties clients depend on Safe methods make no state change. Idempotent methods can be repeated with the same result. POST is neither, which is why it needs an idempotency key to be retried. these properties are the contract, not implementation detail GET, HEAD safe and idempotent cacheable, freely retried PUT, DELETE idempotent, not safe retry converges on one outcome PATCH neither, usually idempotent only if the patch isabsolute POST neither needs a key before automatic retry a GET with a side effect breaks caches, prefetchers and every retry policy at once

Anti-Patterns Quick Reference

Anti-pattern Correct approach
POST /createUser — verb in path POST /users — verb encoded in the method
GET /deleteUser/{id} — safe method mutates state DELETE /users/{id}
POST /users/{id}/update — redundant verb in path PUT /users/{id} or PATCH /users/{id}
Same operationId for PUT and PATCH replaceUser and updateUserPartial — distinct IDs
POST without X-Idempotency-Key Require header at middleware; enforce in Spectral rule
405 response with no Allow header Declare Allow: GET, PUT, PATCH, DELETE in every 405 response
Auto-retrying POST without key Gate retry on X-Idempotency-Key presence in client transport
DELETE body carries identifiers Path parameter only; use POST /batch-delete for bulk

Methods a resource does not support

Choosing the right method for an operation is only half the mapping; the other half is answering correctly when a client picks a method the resource does not offer. The specification is unambiguous here — a known method that this resource does not support is 405, and the response must carry an Allow header listing the methods it does — yet several popular frameworks fall through to a 404 instead, telling the client the resource does not exist when it plainly does.

The distinction matters because the two codes send clients in opposite directions. A 404 suggests a wrong URL, so the developer checks their path construction, their base URL and their id, and finds nothing wrong. A 405 with Allow: GET, PUT, PATCH names the fix immediately. Generate that header from the router rather than writing it by hand, and answer OPTIONS from the same source so the supported set is discoverable before a failed request rather than after one.

Reserve 501 for a method the server does not implement for any resource. If the verb appears anywhere in your specification, 501 is the wrong answer — and because 501 responses are cacheable by default, one emitted during a partial deploy can be stored by an intermediary and served long after the deploy completed. Full detail is in Handling 405 vs 501 for Unsupported Methods.

Method resolution order A request is matched against the path, then the method is checked for recognition, then against the methods this resource allows, and only then dispatched. Each check has its own status code when it fails. path match? no → 404 nothing exists here method known? no → 501 unsupported anywhere allowed here? no → 405 + Allow names the fix dispatch handler runs normal response path first, then method recognition, then per-resource permission a framework that returns 404 for a method mismatch sends developers to the wrong problem

FAQ

How do I enforce HTTP method mapping in CI/CD pipelines?

Use Spectral or Redocly lint rules to validate operationId naming conventions, verb-to-path alignment, and required Content-Type headers. Gate the pipeline so that non-compliant specs fail before SDK generation or deployment is triggered. The Spectral ruleset above covers the four highest-frequency violations.

Which HTTP methods should trigger automatic retries in generated SDKs?

Only idempotent methods — GET, HEAD, PUT, DELETE — should be auto-retried. POST and PATCH require an explicit X-Idempotency-Key header before the retry transport should attempt a second request. Without that key, a network hiccup on POST /orders creates a duplicate order.

How do I map PATCH vs PUT in OpenAPI for type-safe client generation?

Define separate operations with distinct operationId values (replaceUser vs updateUserPartial) and separate request schemas (UserInput vs UserPatch). Declare application/merge-patch+json as the PATCH content type — this signals generators to emit a Partial<> type in TypeScript and a dict with optional keys in Python rather than a required full-object schema.

What does the Allow response header have to do with method safety?

RFC 9110 §10.2.1 requires servers to return an Allow header in every 405 Method Not Allowed response listing every supported method for that path. Without it, generated clients cannot distinguish a permanent method rejection from a transient error, and retry logic may loop indefinitely. Spec the header explicitly on every 405 response object — some frameworks do not auto-populate it.