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.
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:
- Safe methods (
GET,HEAD) must never modify server state. Returning cached or stale data from aGETthat actually writes creates a resource inconsistency that is invisible until audits. - Idempotent methods (
PUT,DELETE) can be retried without special coordination. Code generators should configure automatic retry for these verbs with exponential backoff. - Non-idempotent methods (
POST,PATCH) require explicit idempotency coordination — see Idempotency Key Implementation for key generation, deduplication store patterns, and theX-Idempotency-Keyheader contract.
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.
Related
- API Design Fundamentals & Architecture — parent reference covering the full contract-first design model
- When to Use PUT vs PATCH for Partial Updates — decision guide with spec snippets and SDK diffs for the PUT/PATCH boundary
- Idempotency Key Implementation — deduplication store patterns and
X-Idempotency-Keyheader contract for non-idempotent methods - Resource Modeling Best Practices — entity boundary rules and collection semantics that determine which paths carry which methods
- Statelessness & Caching Strategies —
Cache-Control,ETag, and conditional-request patterns that interact with method safety