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.

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

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.