OpenAPI & Interactive Docs
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. All examples use Annotated where a parameter needs one, run on Python 3.10 or newer and were executed on
Python 3.13 with TestClient (see Testing for the deprecation warning that
Starlette 1.7 prints when TestClient is used with httpx). The conditional and protected documentation examples
were also run against a Uvicorn server with curl, and the whole API of the first example was fuzzed with Schemathesis.
Every FastAPI application is also an OpenAPI document. FastAPI reads the path operations you declare — paths,
parameters, request bodies, response models, security dependencies, status codes, docstrings — and assembles an
OpenAPI 3.1.0 description from them. That one document then feeds everything else: the interactive documentation
(Swagger UI at /docs, ReDoc at /redoc), client and server code generators, API gateways, contract tests and
property-based fuzzers. This page covers how the document is built, every knob that shapes it, how to hide, protect,
extend and self-host the documentation, and how to turn the document into client libraries and tests.
How the Schema Is Generated
The application object has an openapi() method. The first time it is called, it runs
fastapi.openapi.utils.get_openapi() over the application’s routes, stores the result in app.openapi_schema and
returns it; later calls return the stored dictionary. At startup FastAPI also registers a route at /openapi.json
that returns app.openapi(), and two HTML routes (/docs, /redoc) whose pages fetch that URL in the browser.
paths, parameters, models,
dependencies, docstrings"] --> R["APIRoute objects"] R --> G["get_openapi()"] P["Pydantic
JSON Schema 2020-12"] --> G G --> C["app.openapi_schema
(cached dict)"] C --> J["GET /openapi.json"] J --> S["Swagger UI
/docs"] J --> RD["ReDoc
/redoc"] J --> T["Generators, gateways,
contract tests, fuzzers"]
The division of labour matters for knowing where to fix a wrong schema:
| OpenAPI part | Comes from | Control it with |
|---|---|---|
|
The |
|
|
Route decorators ( |
|
|
Function parameters; |
|
|
Body parameters and Pydantic models |
|
|
|
|
|
Pydantic models, as JSON Schema 2020-12 |
Model configuration, |
|
Security dependencies such as |
|
|
|
Here is the smallest useful API of the running example, the Bookshelf API, and the document FastAPI derives from it.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
id: int
title: str
author: str
year: int | None = None
BOOKS = [Book(id=1, title="Dune", author="Frank Herbert", year=1965)]
@app.get("/books")
def list_books(limit: int = 10) -> list[Book]:
return BOOKS[:limit]
@app.get("/books/{book_id}")
def get_book(book_id: int) -> Book:
return BOOKS[0]
{
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/books": {
"get": {
"summary": "List Books",
"operationId": "list_books_books_get",
"parameters": [
{"name": "limit", "in": "query", "required": false,
"schema": {"type": "integer", "default": 10, "title": "Limit"}}
],
"responses": {
"200": {"description": "Successful Response",
"content": {"application/json": {"schema": {
"type": "array", "items": {"$ref": "#/components/schemas/Book"},
"title": "Response List Books Books Get"}}}},
"422": {"description": "Validation Error",
"content": {"application/json": {"schema": {
"$ref": "#/components/schemas/HTTPValidationError"}}}}
}
}
}
},
"components": {"schemas": {"Book": {"...": "..."}, "HTTPValidationError": {"...": "..."}}}
}
Source: FastAPI: First Steps — OpenAPI
Things to notice, all produced without any configuration:
-
The
infoblock falls back to the titleFastAPIand the version0.1.0; the next section replaces them. -
The summary
List Booksis the function name with underscores turned into spaces and each word capitalized. -
The
operationIdis<function name>_<path with non-word characters replaced by underscores>_<method>. It is unique, ugly and the origin of the unwieldy method names in generated clients (Generating Clients and Operation IDs). -
A
422response with theHTTPValidationErrorschema is added to every operation that has parameters or a body, because FastAPI validates them. Operations with no input (such as/healthin the next sections) get no422. -
Bookis not inlined: it lives once undercomponents.schemasand is referenced with$ref. -
The version is
3.1.0. OpenAPI 3.1 aligns with JSON Schema 2020-12, which is why optional fields appear asanyOfwith"type": "null"rather than the 3.0-eranullable: true.
The two documentation pages are served as HTML from the same application. Both load their JavaScript and CSS from a
CDN by default (see Self-Hosting Swagger UI and ReDoc to change that), and both fetch /openapi.json in the browser:
GET /openapi.json 200 application/json
GET /docs 200 text/html; charset=utf-8 (Swagger UI)
GET /redoc 200 text/html; charset=utf-8 (ReDoc)
GET /docs/oauth2-redirect 200 text/html; charset=utf-8 (helper for OAuth2 login from Swagger UI)
Inputs declared on parameters and models also flow into the document. This second application documents the parameters and a Pydantic model field by field and protects one operation with an OAuth2 scheme:
from typing import Annotated
from fastapi import Depends, FastAPI, Query
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, Field
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
class ReviewIn(BaseModel):
rating: int = Field(ge=1, le=5, description="Stars from 1 to 5", examples=[4])
text: str = Field(default="", max_length=2000, description="Optional free text")
@app.get("/books")
def search_books(
q: Annotated[
str | None,
Query(
title="Search text",
description="Matches title and author, case-insensitive.",
examples=["dune"],
max_length=50,
),
] = None,
sort: Annotated[str | None, Query(deprecated=True)] = None,
debug: Annotated[bool, Query(include_in_schema=False)] = False,
) -> list[str]:
return []
@app.post("/books/{book_id}/reviews", status_code=201)
def add_review(
book_id: int,
review: ReviewIn,
token: Annotated[str, Depends(oauth2_scheme)],
) -> ReviewIn:
return review
Running it and reading the document shows how each declaration lands. The q parameter carries the title and
description (both on the parameter and inside its schema) and the examples; sort is marked "deprecated": true;
debug does not appear at all, although GET /books?debug=true still returns 200; ReviewIn gets the minimum,
maximum, maxLength, description and examples keywords; and add_review gets "security":
[{"OAuth2PasswordBearer": []}] while components.securitySchemes describes the password flow with
"tokenUrl": "token". That is also why Swagger UI shows an Authorize button for the application.
The functions title, description, examples, deprecated and include_in_schema exist on Query, Path,
Header, Cookie, Body, Form and Field alike; see Query Parameters,
Path Parameters and
Request Body & Models for each.
Metadata and Docs URLs
The FastAPI(…) constructor takes the fields of the OpenAPI info object and a few more. Everything in the table
is optional.
| Parameter | Type | Effect |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Top-level |
|
|
Top-level |
|
|
Top-level |
|
|
Where the document is served. Default |
|
|
Where Swagger UI is served. Default |
|
|
Where ReDoc is served. Default |
|
|
The helper page for OAuth2 login from Swagger UI. Default |
|
|
Passed to Swagger UI; see Configuring Swagger UI. |
|
|
Proxy prefix and whether it is added to |
|
|
|
|
callable |
See Path Operation Configuration and Generating Clients and Operation IDs. |
The next example fills the whole info block, declares tag metadata with an externalDocs link, moves the document
to a versioned URL and puts Swagger UI at /documentation while turning ReDoc off.
from fastapi import FastAPI
description = """
The **Bookshelf API** manages books, authors and reviews.
## Books
You can list, search and read books.
## Reviews
Readers can post reviews (_authentication required_).
"""
tags_metadata = [
{
"name": "books",
"description": "Browse and manage the **catalog**.",
},
{
"name": "reviews",
"description": "Reader reviews, one list per book.",
"externalDocs": {
"description": "Review guidelines",
"url": "https://bookshelf.example.com/guidelines",
},
},
]
app = FastAPI(
title="Bookshelf API",
summary="A small catalog of books, authors and reviews.",
description=description,
version="2.5.0",
terms_of_service="https://bookshelf.example.com/terms/",
contact={
"name": "Bookshelf Support",
"url": "https://bookshelf.example.com/contact/",
"email": "support@bookshelf.example.com",
},
license_info={"name": "Apache 2.0", "identifier": "Apache-2.0"},
openapi_tags=tags_metadata,
openapi_external_docs={
"description": "Developer portal",
"url": "https://bookshelf.example.com/developers",
},
openapi_url="/api/v1/openapi.json",
docs_url="/documentation",
redoc_url=None,
)
@app.get("/books", tags=["books"])
def list_books() -> list[str]:
return ["Dune"]
@app.get("/books/{book_id}/reviews", tags=["reviews"])
def list_reviews(book_id: int) -> list[str]:
return []
Source: FastAPI: Metadata and Docs URLs
The generated document (the paths and components parts are omitted here):
{
"openapi": "3.1.0",
"info": {
"title": "Bookshelf API",
"summary": "A small catalog of books, authors and reviews.",
"description": "\nThe **Bookshelf API** manages books, authors and reviews.\n\n## Books\n...",
"termsOfService": "https://bookshelf.example.com/terms/",
"contact": {
"name": "Bookshelf Support",
"url": "https://bookshelf.example.com/contact/",
"email": "support@bookshelf.example.com"
},
"license": {"name": "Apache 2.0", "identifier": "Apache-2.0"},
"version": "2.5.0"
},
"tags": [
{"name": "books", "description": "Browse and manage the **catalog**."},
{"name": "reviews", "description": "Reader reviews, one list per book.",
"externalDocs": {"description": "Review guidelines",
"url": "https://bookshelf.example.com/guidelines"}}
],
"externalDocs": {"description": "Developer portal",
"url": "https://bookshelf.example.com/developers"}
}
Run against that application, /openapi.json, /docs and /redoc answer 404, while /api/v1/openapi.json answers
200 with the document and /documentation serves Swagger UI.
Some behaviour to know:
-
Tags without metadata are fine. You do not have to list every tag in
openapi_tags; tags that are used on operations but not listed are shown in the UIs in an order chosen by the tool. A tag listed inopenapi_tagsbut not used by any operation still appears in the document. -
openapi_url=Noneis the master switch. Without a document the UIs have nothing to load, so/docsand/redocare not registered either. Setting onlydocs_url=Noneleaves the document and ReDoc in place. -
serversandroot_path. When the application sits behind a proxy that strips a path prefix, setroot_path(or run Uvicorn with--root-path). FastAPI then adds{"url": "/api/v1"}toserversin the served document, so "Try it out" in Swagger UI calls the right URL;root_path_in_servers=Falsestops that. An explicitserverslist replaces the automatic entry. Details are in Middleware, CORS & Proxies and Deployment. -
openapi_prefixis deprecated, replaced byroot_path, which follows the ASGI standard.
Path Operation Configuration
Everything about a single operation is configured on the decorator (@app.get, @app.post, … and the same on an
APIRouter), never on the function. The parameters are passed to the decorator, not to the function.
| Parameter | OpenAPI field | Notes |
|---|---|---|
|
|
A list of strings, or of |
|
|
Default: the function name, title-cased ( |
|
|
Default: the function docstring, dedented, interpreted as Markdown. |
|
the description of the main response |
Default |
|
key of the main response |
|
|
|
Keeps the operation working and strikes it through in the UIs. |
|
|
Must be unique across the document; FastAPI emits a |
|
(operation omitted) |
|
|
extra keys under |
See Additional Responses. |
|
|
|
|
merged into the operation object |
Deeply merged; for extensions ( |
The next application uses all of them: an Enum for tags, a Markdown docstring with a \f truncation marker, a
response_description, an explicit operation_id, a deprecated operation, one hidden operation, an extension through
openapi_extra, and a router whose include_router call marks every route deprecated.
from enum import Enum
from fastapi import APIRouter, FastAPI
from pydantic import BaseModel
class Tags(Enum):
books = "books"
authors = "authors"
class BookIn(BaseModel):
title: str
author: str
app = FastAPI()
@app.post(
"/books",
tags=[Tags.books],
summary="Add a book",
response_description="The book as stored in the catalog",
operation_id="add_book",
status_code=201,
)
def add_book(book: BookIn) -> BookIn:
"""
Add a book to the catalog.
- **title**: required, shown in search results
- **author**: required, the display name of the author
The call is *idempotent* per title and author.
\f
:param book: Internal note for Sphinx; not part of the OpenAPI document.
"""
return book
@app.get("/books/legacy", tags=[Tags.books], deprecated=True)
def list_books_legacy() -> list[BookIn]:
return []
@app.get("/health", include_in_schema=False)
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/books/audit", openapi_extra={"x-internal-owner": "catalog-team"})
def audit_books() -> list[str]:
return []
authors = APIRouter(prefix="/authors", tags=[Tags.authors])
@authors.get("")
def list_authors() -> list[str]:
return ["Frank Herbert"]
@authors.get("/search", deprecated=False)
def search_authors(q: str) -> list[str]:
return []
app.include_router(authors, deprecated=True)
The document for POST /books (the other operations are summarised below it):
"/books": {
"post": {
"tags": ["books"],
"summary": "Add a book",
"description": "Add a book to the catalog.\n\n- **title**: required, shown in search results\n- **author**: required, the display name of the author\n\nThe call is *idempotent* per title and author.",
"operationId": "add_book",
"requestBody": {"required": true, "content": {"application/json": {
"schema": {"$ref": "#/components/schemas/BookIn"}}}},
"responses": {
"201": {"description": "The book as stored in the catalog",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BookIn"}}}},
"422": {"description": "Validation Error", "content": {"application/json": {"schema": {
"$ref": "#/components/schemas/HTTPValidationError"}}}}
}
}
}
What the run showed for the other operations:
-
GET /books/legacyhas"deprecated": true;GET /books/audithas the extra key"x-internal-owner": "catalog-team"next to the generatedsummary,operationIdandresponses. -
GET /healthis absent frompaths, yetGET /healthreturns{"status": "ok"}:include_in_schema=Falsehides, it does not protect (see Conditional OpenAPI). -
Tags written as
Tags.booksappear in the document as the plain string"books"; theEnumonly keeps the spelling consistent in your code. -
The line
:param book: …after\fis missing fromdescription. The form feed cuts the docstring for OpenAPI so that tools that read docstrings for other purposes (Sphinx and friends) can keep the rest. -
Both operations in the router,
GET /authorsandGET /authors/search, are deprecated. The routesearch_authorsdeclaresdeprecated=False, and it still ends up deprecated: the value frominclude_routerwins, so a router-wide deprecation cannot be undone per route. Use the same call to addtags,prefix,dependencies,responsesandinclude_in_schemato all routes of a router; see Project Structure & Layered Architecture.
| Item | Default |
|---|---|
|
Function name, underscores to spaces, each word capitalized |
|
|
Main response description |
|
Validation response |
|
Model name in |
The class name; |
Response schema title |
|
Custom Request Schemas with openapi_extra
FastAPI documents a request body when a parameter is a Pydantic model or a Body. When the body is something else — raw bytes, a CSV upload, YAML, a vendor media type — you read it yourself from Request and describe it through
openapi_extra. The dictionary is deep-merged into the generated operation, so it can add to the document or supply
the whole requestBody.
import csv
import io
import yaml
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, ValidationError
app = FastAPI()
class BookIn(BaseModel):
title: str
author: str
@app.post(
"/books/import",
openapi_extra={
"requestBody": {
"required": True,
"content": {
"text/csv": {
"schema": {"type": "string"},
"example": "title,author\nDune,Frank Herbert\n",
}
},
}
},
)
async def import_books(request: Request) -> dict[str, int]:
raw = (await request.body()).decode()
rows = list(csv.DictReader(io.StringIO(raw)))
return {"imported": len(rows)}
@app.post(
"/books/yaml",
openapi_extra={
"requestBody": {
"required": True,
"content": {"application/x-yaml": {"schema": BookIn.model_json_schema()}},
}
},
)
async def add_book_yaml(request: Request) -> BookIn:
try:
data = yaml.safe_load(await request.body())
return BookIn.model_validate(data)
except (yaml.YAMLError, ValidationError) as exc:
raise HTTPException(status_code=422, detail="Invalid book document") from exc
The first operation documents text/csv with a plain string schema and an example; the second reuses
BookIn.model_json_schema() so that the same Pydantic model produces the JSON Schema and validates the parsed YAML,
even though FastAPI never parses the body. Run with TestClient, a two-row CSV returns {"imported": 2}, a valid
YAML body returns the validated book and a YAML body without author returns 422 with Invalid book document.
Two caveats from this approach:
-
FastAPI does no parsing or validation for such a body. If the declared schema and your code drift apart, the docs lie; keep both derived from one Pydantic model, as above.
-
model_json_schema()returns a self-contained schema. For a model with nested models it places them under$defsinside that schema, which is valid JSON Schema but is not hoisted intocomponents.schemas, so generators see a separate anonymous schema. Prefer flat models for such bodies.
For ordinary uploads, Form and UploadFile already generate a multipart/form-data schema; see
Forms & File Uploads and
Request Object & Content Type.
Request Examples
Examples make the Try it out button useful. Pydantic and FastAPI offer two levels, and both end up in the document:
-
Model level, with
json_schema_extra(orField(examples=[…])per field): one or more example values stored in the model’s JSON Schema underexamples, shared by every operation that uses the model. -
Operation level, with
openapi_examplesonBody,Query,Path,Header,CookieandForm(FastAPI 0.103.0, 2023-08-26): named examples withsummary,descriptionandvalue(orexternalValue) shown in a drop-down in Swagger UI.
from typing import Annotated
from fastapi import Body, FastAPI
from pydantic import BaseModel, ConfigDict
app = FastAPI()
class BookIn(BaseModel):
title: str
author: str
year: int | None = None
model_config = ConfigDict(
json_schema_extra={
"examples": [{"title": "Dune", "author": "Frank Herbert", "year": 1965}]
}
)
@app.post("/books")
def add_book(
book: Annotated[
BookIn,
Body(
openapi_examples={
"classic": {
"summary": "A classic",
"description": "Title, author and year.",
"value": {"title": "Dune", "author": "Frank Herbert", "year": 1965},
},
"minimal": {
"summary": "Only required fields",
"value": {"title": "Untitled", "author": "Unknown"},
},
},
),
],
) -> BookIn:
return book
The model’s schema now ends with "examples": [{"author": "Frank Herbert", "title": "Dune", "year": 1965}], and the
operation’s requestBody content gets an examples map with the keys classic and minimal, each with its summary
and value. Examples are not validated against the model by FastAPI, so a stale example is only caught by a test; see
Testing the Schema, and Testing Against It. The tutorial page above lists a few more forms, including the JSON Schema examples keyword on
fields.
Additional Responses
status_code and response_model describe the main response. Anything else the operation can answer — 401, 403,
404, 409, a different media type — is declared with the responses parameter: a dictionary keyed by status code
(or default, or a range such as 4XX) whose values are OpenAPI Response objects. A value may carry a model key
with a Pydantic model; the key is FastAPI’s, not OpenAPI’s, and is replaced with a $ref to a JSON Schema. The other
keys are copied as they are, so any key from the Response Object of the specification works (description,
headers, content, links).
responses can be set in three places, and the three are merged: on the application (FastAPI(responses=…)), on a
router (APIRouter(responses=…) or include_router(…, responses=…)) and on the decorator. The decorator wins
for the same status code.
from fastapi import APIRouter, FastAPI
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
class Book(BaseModel):
id: int
title: str
class Message(BaseModel):
message: str
common_responses = {
401: {"model": Message, "description": "Missing or invalid credentials"},
403: {"description": "Not enough privileges"},
}
app = FastAPI(responses={500: {"model": Message, "description": "Unexpected error"}})
router = APIRouter(prefix="/books", responses={429: {"description": "Rate limited"}})
@router.get(
"/{book_id}",
response_model=Book,
responses={
**common_responses,
404: {"model": Message, "description": "The book was not found"},
200: {
"description": "The book",
"content": {
"application/json": {"example": {"id": 1, "title": "Dune"}},
},
},
},
)
def get_book(book_id: int):
if book_id == 1:
return {"id": 1, "title": "Dune"}
return JSONResponse(status_code=404, content={"message": "Book not found"})
@router.get(
"/{book_id}/cover",
response_class=Response,
responses={
200: {"content": {"image/png": {}}, "description": "The cover image"},
404: {"model": Message},
},
)
def get_cover(book_id: int):
return Response(content=b"\x89PNG\r\n\x1a\n", media_type="image/png")
app.include_router(router)
Reading components and paths from the run:
GET /books/{book_id}
200 The book application/json (with the example)
500 Unexpected error application/json (from the application)
429 Rate limited - (from the router)
401 Missing or invalid credentials application/json (shared dict)
403 Not enough privileges - (shared dict)
404 The book was not found application/json
422 Validation Error application/json (added by FastAPI)
GET /books/{book_id}/cover
200 The cover image image/png
500, 429 as above
404 Not Found application/json (model without description)
422 Validation Error application/json
Points to keep in mind:
-
responsesdocuments; it does not produce. Declaring404here does not make the function return one. Return aJSONResponsewith that status (asget_bookdoes) or raiseHTTPException; see Status Codes & Error Handling. The model for the documented status is not used for validation. -
Combine, do not repeat. The
**common_responsesunpacking pattern shares predefined responses between operations; the200entry above adds a description and an example while keeping the schema fromresponse_model. -
Media types. Unless you name a different one, the extra responses use the media type of the main response class (
application/jsonby default). Withresponse_class=Response(no media type of its own) the200entry above definesimage/png, and for a model attached to an extra response FastAPI usesapplication/json. -
Defining
422yourself replaces the generated entry for the operation, which is how you document a custom validation error format; see Status Codes & Error Handling. -
Use the specification for the rest. The OpenAPI Responses Object and Response Object define what else can go in each value.
Separate Input and Output Schemas
Since Pydantic v2 the same model can have two different JSON Schemas: one for validating input, one for serializing
output. FastAPI documents both when they differ, by naming them <Model>-Input and <Model>-Output in
components.schemas (FastAPI 0.102.0, 2023-08-25). This helps generated clients: a field with a default is optional
when you send the model and always present when you receive it.
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, computed_field
app = FastAPI() # separate_input_output_schemas=True is the default
class Book(BaseModel):
title: str
year: int
@computed_field
@property
def label(self) -> str:
return f"{self.title} ({self.year})"
class Review(BaseModel):
model_config = ConfigDict(json_schema_serialization_defaults_required=True)
rating: int
tags: list[str] = []
@app.post("/books")
def add_book(book: Book) -> Book:
return book
@app.post("/reviews")
def add_review(review: Review) -> Review:
return review
single = FastAPI(separate_input_output_schemas=False)
single.post("/books")(add_book)
single.post("/reviews")(add_review)
The run (FastAPI 0.142.2, Pydantic 2.13.5) produced:
| Schema | Default application | separate_input_output_schemas=False |
|---|---|---|
|
|
Same two schemas: a computed field exists only in the output |
|
|
One schema |
Two points the run made clear. First, a plain default value is not enough on its own to split the schema with the
current stack: a model with tags: list[str] = [] and nothing else produced a single schema in a test, because
Pydantic only marks defaulted fields as required in serialization mode when
json_schema_serialization_defaults_required=True is set. The official how-to page describes the split for a model
with a defaulted field; with Pydantic 2.13.5 and FastAPI 0.142.2 the split appears when something differs between the
two modes (a computed field, different validation and serialization aliases, or that configuration flag). Second, the
switch only merges schemas that differ in required-ness; it cannot hide an output-only field.
If you already ship generated clients that expect the old single schema, separate_input_output_schemas=False keeps
the names stable while you migrate; see Where Older Guides Are Outdated, and What Changed Recently.
Callbacks and Webhooks
Both describe requests that go from your API to the client’s server, and both are documentation only: FastAPI neither registers the client’s URL nor sends the request. The difference is who knows the URL.
-
A callback belongs to one operation: the client passes a URL in the request (or body) and your API calls it back later. The URL is an OpenAPI runtime expression that references the incoming request.
-
A webhook is an event your API may send to URLs that clients registered some other way, for instance in a dashboard. It has a name, not a path. Webhooks are an OpenAPI 3.1.0 feature, supported from FastAPI 0.99.0 (2023-06-30).
from datetime import datetime
import httpx
from fastapi import APIRouter, BackgroundTasks, FastAPI
from pydantic import BaseModel, HttpUrl
app = FastAPI()
class Subscription(BaseModel):
id: str
genre: str
class ReviewEvent(BaseModel):
book_id: int
rating: int
posted_at: datetime
class EventReceived(BaseModel):
ok: bool
callback_router = APIRouter()
@callback_router.post(
"{$callback_url}/reviews/{$request.body.id}", response_model=EventReceived
)
def review_notification(body: ReviewEvent):
pass
async def notify(callback_url: str, subscription_id: str, event: ReviewEvent) -> None:
async with httpx.AsyncClient() as client:
await client.post(
f"{callback_url}/reviews/{subscription_id}",
content=event.model_dump_json(),
headers={"Content-Type": "application/json"},
)
@app.post("/subscriptions", callbacks=callback_router.routes)
def subscribe(
subscription: Subscription,
callback_url: HttpUrl,
background_tasks: BackgroundTasks,
) -> dict[str, str]:
"""Subscribe to new reviews in a genre; reviews are POSTed to the callback URL."""
event = ReviewEvent(book_id=1, rating=5, posted_at=datetime(2026, 10, 2, 9, 30))
background_tasks.add_task(notify, str(callback_url).rstrip("/"), subscription.id, event)
return {"status": "subscribed"}
@app.webhooks.post("new-review")
def new_review(body: ReviewEvent):
"""
Sent to every registered endpoint when a review is posted. Reply with any
2xx status within five seconds to acknowledge it.
"""
Source: FastAPI: OpenAPI Callbacks
Source: FastAPI: OpenAPI Webhooks
How it works:
-
Callbacks use a router as a drawing board.
callback_routerholds one or more path operations that describe the request the client must be ready to receive. The function bodies are never run (passis enough). You passcallback_router.routes, not the router itself, tocallbacks=. -
The path is a runtime expression.
{$callback_url}/reviews/{$request.body.id}is substituted from the incoming request:$callback_urlis the query parameter,$request.body.idis theidfield of the JSON body (here the subscription id). Swagger UI shows it in a Callbacks section under the operation. -
Webhooks are declared on
app.webhooks. It is anAPIRouter, so@app.webhooks.post("new-review")works like a normal path operation; the string is the event name and the docstring becomes the description. The result is a top-levelwebhookskey in the document, next topaths. -
Sending is your code.
notify()posts the event withhttpx; in the run it was replaced by anhttpx.MockTransportand the request seen wasPOST https://partner.example.org/events/reviews/sub42with the body{"book_id":1,"rating":5,"posted_at":"2026-10-02T09:30:00"}. The background task keeps the HTTP response fast; see Settings, Lifespan & Background Tasks. For delivery guarantees (retries, signing, dead letters) a queue is the right tool, notBackgroundTasks. -
Operation ids are ugly here. The run produced
review_notificationcallback_urlreviews_request_body_idpostfor the callback andnew_reviewnew_review_postfor the webhook, because both derive the id from a "path" that is not a path. Setoperation_idor agenerate_unique_id_function(Generating Clients and Operation IDs) if clients are generated from them. -
Secure webhooks yourself. The document cannot express a signature. Document the header that carries it (for instance an HMAC of the body computed with a per-client secret) in the webhook description, and have clients verify it before trusting the payload.
Conditional OpenAPI
Sometimes the documentation should not be public: an internal service, a production deployment, a regulated environment. The route parameters you already know are enough, because they accept values from settings.
from fastapi import FastAPI
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
openapi_url: str = "/openapi.json"
settings = Settings()
app = FastAPI(openapi_url=settings.openapi_url)
@app.get("/")
def root() -> dict[str, str]:
return {"message": "Hello Bookshelf"}
Source: FastAPI: Conditional OpenAPI
Starting it with an empty OPENAPI_URL (the field name of pydantic-settings is matched with the environment
variable, case-insensitively) disables the document and both UIs; the application itself keeps working:
OPENAPI_URL= uvicorn ex09_conditional:app
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/openapi.json # 404
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/docs # 404
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/ # 200
Run against a real Uvicorn process the three requests answered 404, 404 and 200.
Hiding the docs is not a security measure. The operations stay reachable, any flaw in them remains, and hiding the
UI only makes the API harder for honest developers to learn (and for you to debug). The real controls are strict
request and response models, dependencies for permissions, password hashing and OAuth2 scopes; see
Security Fundamentals and
OAuth2 Scopes & Authorization. The same goes for
include_in_schema=False.
If you want the documentation reachable only by people who have credentials — say, a staging environment that partners can read — keep the document and the UI but serve them through your own routes behind a dependency:
import secrets
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
docs_user: str
docs_password: str
settings = Settings()
basic = HTTPBasic()
def docs_access(credentials: Annotated[HTTPBasicCredentials, Depends(basic)]) -> None:
user_ok = secrets.compare_digest(credentials.username.encode(), settings.docs_user.encode())
password_ok = secrets.compare_digest(credentials.password.encode(), settings.docs_password.encode())
if not (user_ok and password_ok):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
@app.get("/books")
def list_books() -> list[str]:
return ["Dune"]
@app.get("/internal/openapi.json", include_in_schema=False, dependencies=[Depends(docs_access)])
def internal_openapi() -> dict:
return app.openapi()
@app.get("/internal/docs", include_in_schema=False, dependencies=[Depends(docs_access)])
def internal_docs():
return get_swagger_ui_html(
openapi_url="/internal/openapi.json", title=f"{app.title} - Swagger UI"
)
Source: FastAPI: HTTP Basic Auth
Setting openapi_url, docs_url and redoc_url to None removes the default routes, yet app.openapi() still
builds the document, so the two guarded routes can return it. The credentials come from settings (the environment
variables DOCS_USER and DOCS_PASSWORD; the class declares no default, so the application refuses to start without
them) and are compared with secrets.compare_digest. With curl:
curl -si http://127.0.0.1:8000/internal/docs
HTTP/1.1 401 Unauthorized
www-authenticate: Basic
content-type: application/json
{"detail":"Not authenticated"}
and with -u docs:<password> the same URL answers 200 with the Swagger UI page. The default routes
(/openapi.json, /docs, /redoc) answer 404, and /books stays public. Basic credentials over plain HTTP are
readable on the wire: terminate TLS in front of the application; see
Deployment. For a real user base use the same OAuth2 machinery as for the API
itself.
Extending the Schema with get_openapi
When a declaration cannot express what you need — a vendor extension, a global externalDocs, a change to every
operation — generate the document as FastAPI does and edit the dictionary. The process is the one from
How the Schema Is Generated: replace app.openapi with a function that builds the document once with get_openapi(), edits it, and
caches it in app.openapi_schema.
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
app = FastAPI(title="Bookshelf API", version="2.5.0")
@app.get("/books")
def list_books() -> list[str]:
return ["Dune"]
def custom_openapi() -> dict:
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(
title=app.title,
version=app.version,
openapi_version=app.openapi_version,
summary="Catalog of books, authors and reviews",
description=app.description,
routes=app.routes,
)
schema["info"]["x-logo"] = {"url": "https://bookshelf.example.com/logo.png"}
schema["externalDocs"] = {
"description": "Developer portal",
"url": "https://bookshelf.example.com/developers",
}
app.openapi_schema = schema
return app.openapi_schema
app.openapi = custom_openapi
Source: FastAPI: Extending OpenAPI
The get_openapi() parameters are title, version, openapi_version (default 3.1.0), summary, description,
routes and the optional webhooks, tags, servers, terms_of_service, contact, license_info,
separate_input_output_schemas and external_docs; pass app.routes to collect the registered operations.
The official page notes that app.routes is a lower-level route tree, that FastAPI traverses it to find the
effective path operations (including those of included routers), and that this changed in release 0.137.0; see
Where Older Guides Are Outdated, and What Changed Recently. The run produced:
{
"openapi": "3.1.0",
"info": {
"title": "Bookshelf API",
"summary": "Catalog of books, authors and reviews",
"version": "2.5.0",
"x-logo": {"url": "https://bookshelf.example.com/logo.png"}
},
"externalDocs": {"description": "Developer portal",
"url": "https://bookshelf.example.com/developers"}
}
Caching is the point of app.openapi_schema: building the document walks every route and every model, so it happens
once, and app.openapi() is app.openapi() is True afterwards. The flip side is that the document is frozen after the
first call. Anything you change later (a title, a route added at runtime) is not visible until you reset the cache
with app.openapi_schema = None; in tests, build a fresh application or reset the cache instead of relying on
order. Note that the replacement function passes its own arguments, so metadata given to FastAPI(…) must be
forwarded (the example forwards title, version, openapi_version and description). If you only add keys, call
the original method first (schema = FastAPI.openapi(app)) to avoid repeating them.
ReDoc reads vendor extensions such as x-logo from the info object, which is why the page uses it as the example.
The ReDoc documentation lists the extensions it understands.
Configuring Swagger UI
swagger_ui_parameters is a dictionary passed straight to Swagger UI’s configuration after FastAPI converts it to
JSON; keys use the JavaScript names of Swagger UI’s configuration. FastAPI’s defaults are dom_id, layout,
deepLinking (true), showExtensions (true) and showCommonExtensions (true); your dictionary updates them,
so {"deepLinking": False} turns deep linking off. swagger_ui_init_oauth is passed to ui.initOAuth(…) and
pre-fills the Authorize dialog.
from fastapi import FastAPI
app = FastAPI(
swagger_ui_parameters={
"docExpansion": "none",
"defaultModelsExpandDepth": -1,
"persistAuthorization": True,
"displayRequestDuration": True,
"filter": True,
"operationsSorter": "alpha",
"syntaxHighlight": {"theme": "obsidian"},
"deepLinking": False,
},
swagger_ui_init_oauth={
"clientId": "bookshelf-docs",
"appName": "Bookshelf API docs",
"usePkceWithAuthorizationCodeGrant": True,
},
)
@app.get("/books")
def list_books() -> list[str]:
return ["Dune"]
Source: FastAPI: Configure Swagger UI
The relevant part of the HTML served at /docs, as produced by the run:
SwaggerUIBundle({
url: '/openapi.json',
"dom_id": "#swagger-ui",
"layout": "BaseLayout",
"deepLinking": false,
"showExtensions": true,
"showCommonExtensions": true,
"docExpansion": "none",
"defaultModelsExpandDepth": -1,
"persistAuthorization": true,
"displayRequestDuration": true,
"filter": true,
"operationsSorter": "alpha",
"syntaxHighlight": {"theme": "obsidian"},
oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
presets: [ SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset ],
})
ui.initOAuth({"clientId": "bookshelf-docs", "appName": "Bookshelf API docs", "usePkceWithAuthorizationCodeGrant": true})
| Key | Effect |
|---|---|
|
|
|
How deep the Schemas section expands; |
|
Keeps the entered credentials across page reloads (stored in the browser) |
|
Shows the duration of each Try it out request |
|
Adds a filter box above the operations |
|
|
|
|
|
Lets you link to a tag or operation with a URL fragment |
The full list is in the Swagger UI configuration reference. Some options are JavaScript-only (functions, plugins,
and the presets that FastAPI sets itself); they cannot be passed from Python as JSON. For those, write your own
/docs route with get_swagger_ui_html() (next section) and add the JavaScript by hand. With persistAuthorization
on, remember that tokens end up in the browser’s storage; leave it off on shared machines.
OAuth2 from Swagger UI needs the redirect helper page (/docs/oauth2-redirect by default), which is why the route
exists even when the application has no security. Remove it with swagger_ui_oauth2_redirect_url=None only if the
docs do not log in through an OAuth2 provider.
Self-Hosting Swagger UI and ReDoc
By default /docs loads swagger-ui-bundle.js and swagger-ui.css from a CDN, and /redoc loads
redoc.standalone.js from a CDN plus web fonts from Google Fonts. That fails on an air-gapped network, behind a strict
Content Security Policy, offline during development, and in countries where the CDN is unreachable. The fix is to
serve the files yourself:
-
Download
swagger-ui-bundle.js,swagger-ui.cssandredoc.standalone.js(from theswagger-ui-distandredocdistributions, pinned to a version you have tested) into astatic/directory. -
Mount it with
StaticFiles. -
Disable the default docs routes (
docs_url=None,redoc_url=None) and re-create them withget_swagger_ui_html(),get_redoc_html()andget_swagger_ui_oauth2_redirect_html()fromfastapi.openapi.docs, pointing at the local files.
from pathlib import Path
from fastapi import FastAPI
from fastapi.openapi.docs import (
get_redoc_html,
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
from fastapi.staticfiles import StaticFiles
STATIC = Path(__file__).parent / "static"
app = FastAPI(docs_url=None, redoc_url=None)
app.mount("/static", StaticFiles(directory=STATIC), name="static")
@app.get("/docs", include_in_schema=False)
async def swagger_ui_html():
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=f"{app.title} - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
swagger_js_url="/static/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui.css",
swagger_favicon_url="/static/favicon.png",
)
@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
async def swagger_ui_redirect():
return get_swagger_ui_oauth2_redirect_html()
@app.get("/redoc", include_in_schema=False)
async def redoc_html():
return get_redoc_html(
openapi_url=app.openapi_url,
title=f"{app.title} - ReDoc",
redoc_js_url="/static/redoc.standalone.js",
redoc_favicon_url="/static/favicon.png",
with_google_fonts=False,
)
@app.get("/books")
def list_books() -> list[str]:
return ["Dune"]
Source: FastAPI: Custom Docs UI Static Assets (Self-Hosting) — Self-hosting JavaScript and CSS for docs
The test run used placeholder files in static/ and confirmed the wiring: /docs references
/static/swagger-ui.css, /static/favicon.png and /static/swagger-ui-bundle.js; /redoc references
/static/favicon.png and /static/redoc.standalone.js and no longer mentions fonts.googleapis.com; the static
files answer 200; and the /docs/oauth2-redirect helper still works. Replace the placeholders with the real files
before deploying.
Details:
-
get_swagger_ui_html()takesopenapi_url,title,swagger_js_url,swagger_css_url,swagger_favicon_url,oauth2_redirect_url,init_oauthandswagger_ui_parameters;get_redoc_html()takesopenapi_url,title,redoc_js_url,redoc_favicon_urlandwith_google_fonts. Pass the sameswagger_ui_parametershere as you would toFastAPI(…), because the constructor argument is not applied to your replacement route. -
with_google_fonts=Falseis what removes the last external request from ReDoc. -
A different CDN, instead of self-hosting, is the same recipe with absolute URLs for the three
*_urlarguments. -
Keep
include_in_schema=Falseon the replacement routes or they will document themselves. -
The OAuth2 redirect route (see Configuring Swagger UI) is registered only together with the default
/docs, so withdocs_url=Nonethe example re-creates it and passesoauth2_redirect_urlto the Swagger page. -
Behind a proxy that strips a prefix, build the URLs from
app.openapi_urlandroot_pathrather than hard-coding absolute paths; see Middleware, CORS & Proxies.
Generating Clients and Operation IDs
Because the document is standard OpenAPI 3.1, generators turn it into typed clients (SDKs). The FastAPI documentation
walks through TypeScript with Hey API’s openapi-ts, and also names the OpenAPI Generator project for many other
languages; any tool you pick must support OpenAPI 3.1.
npx @hey-api/openapi-ts -i http://localhost:8000/openapi.json -o src/client
Tags drive the client’s structure: a books tag becomes a BooksService (or equivalent), an authors tag an
AuthorsService. Method names come from the operationId, and the default id (list_books_books_get) yields
awkward names such as listBooksBooksGet. FastAPI therefore lets you replace the function that builds the id with
generate_unique_id_function, which receives each APIRoute and returns a string.
from fastapi import FastAPI
from fastapi.routing import APIRoute
from pydantic import BaseModel
def unique_id(route: APIRoute) -> str:
return f"{route.tags[0]}-{route.name}"
app = FastAPI(generate_unique_id_function=unique_id)
class Book(BaseModel):
title: str
@app.get("/books", tags=["books"])
def list_books() -> list[Book]:
return []
@app.post("/books", tags=["books"])
def create_book(book: Book) -> Book:
return book
@app.get("/authors", tags=["authors"])
def list_authors() -> list[str]:
return []
The run gave these ids:
| Path | Method | operationId |
|---|---|---|
|
GET |
|
|
POST |
|
|
GET |
|
Rules for that function:
-
The id must be unique across the document, so each function name must be unique per tag (and per module, even in different files).
-
route.tags[0]raisesIndexErrorfor an untagged route, and it raises at import time (when the decorator runs), not when the schema is built; every route needs a tag, or the function must handle an empty list. -
A route’s own
operation_idwins over the function. -
The FastAPI documentation says the function also feeds the names of any custom models FastAPI has to create, so regenerate clients after changing it.
-
The function can be set on the application, on an
APIRouterand on a single route. -
After the tag prefix is removed (a short script over
openapi.json, as the FastAPI page shows) you get the cleanest method names,listBooksinstead ofbooksListBooks, while keeping unique ids in the document itself.
To generate from a file, for example in CI where no server is running, export the document from the application object. The same helper checks that the committed file is current:
import json
import sys
from pathlib import Path
from ex13_clients import app
def main(target: str, check: bool = False) -> int:
document = json.dumps(app.openapi(), indent=2, sort_keys=True) + "\n"
path = Path(target)
if check:
return 0 if path.exists() and path.read_text() == document else 1
path.write_text(document)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1], check="--check" in sys.argv))
python export_openapi.py openapi.json writes a sorted, indented file, and python export_openapi.py openapi.json
--check exits with 0 when the file matches the application and 1 after a hand edit or a code change. (The import
in the example, ex13_clients, stands for the module that defines your app.) Commit the file, run --check in the
pipeline, and regenerate the client when it fails. This turns an accidental API change into a failed build instead
of a surprise in production.
Testing the Schema, and Testing Against It
The document is also a test oracle. The first test below checks the document the application serves: version 3.1,
unique operation ids and a tag on every operation. It passed with pytest against the application from the previous
section.
from fastapi.testclient import TestClient
from ex13_clients import app
HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
def operations(schema: dict):
for path, item in schema["paths"].items():
for method, operation in item.items():
if method in HTTP_METHODS:
yield path, method, operation
def test_openapi_document_is_served_and_tidy() -> None:
response = TestClient(app).get("/openapi.json")
assert response.status_code == 200
schema = response.json()
assert schema["openapi"].startswith("3.1")
operation_ids = [op["operationId"] for _, _, op in operations(schema)]
assert len(operation_ids) == len(set(operation_ids))
for path, method, operation in operations(schema):
assert operation.get("tags"), f"{method.upper()} {path} has no tag"
Source: FastAPI: Testing
The second use is property-based testing: Schemathesis reads /openapi.json, generates requests with Hypothesis, sends
them and checks that every response matches the declared status codes, media types and schemas. With the Bookshelf API
from the beginning of the page started with uvicorn:
schemathesis run http://127.0.0.1:8000/openapi.json --max-examples 20
Source: Schemathesis documentation
The run (Schemathesis 4.29.0, Hypothesis-driven) tested 2/2 operations and ended with 195 generated, 195 passed and
No issues found. A response that violates its schema, an undocumented status code or a 500 on generated input is
reported with a reproducible case. This is why the documented responses and examples on this page matter: the
more precisely the document describes the API, the more the fuzzer can check. Hypothesis, fixtures and the rest of the testing toolbox are covered in Testing.
Where Older Guides Are Outdated, and What Changed Recently
-
OpenAPI version. Older guides and books show an OpenAPI 3.0.2 document with
nullable: true. FastAPI emits OpenAPI 3.1.0 and JSON Schema 2020-12 since 0.99.0 (2023-06-30), so optional fields areanyOfwithnull, the license can use an SPDXidentifier,summaryexists oninfo, and webhooks exist. Generators and gateways must support 3.1. -
Examples. Older guides show a single
examplevalue. The list formexamplesfollows JSON Schema 2020-12 (0.99.0), and the named, UI-friendlyopenapi_examplesarrived in 0.103.0 (2023-08-26). -
Input and output schemas. Pydantic v2 can produce
-Inputand-Outputvariants of a model; FastAPI supports it since 0.102.0 (2023-08-25) with the switchseparate_input_output_schemas. Clients generated from older documents show renamed schemas when they regenerate. -
Routes in
get_openapi. FastAPI 0.137.0 (2026-06-14) preservesAPIRouterandAPIRouteinstances and makes the internal route list a tree; the supported way of building a custom document is stillroutes=app.routes, andget_openapi()traverses the tree. Code that loops overapp.routesexpecting every element to be a finalAPIRoutemust be revisited; the release notes for 0.137.2 (2026-06-18) mention the addediter_route_contexts(), and lettingget_openapi()do the walking avoids the question. -
Pydantic v1 era advice.
schema_extra,Configclasses and.schema()are Pydantic v1. UseConfigDictwithjson_schema_extraandmodel_json_schema(); FastAPI itself dropped Pydantic v1 support in 0.126.0 (2025-12-20) andpydantic.v1in 0.128.0 (2025-12-27).
| FastAPI | Date | Change |
|---|---|---|
0.99.0 |
2023-06-30 |
OpenAPI 3.1.0 and JSON Schema 2020-12, |
0.102.0 |
2023-08-25 |
|
0.103.0 |
2023-08-26 |
|
0.126.0, 0.128.0 |
2025-12-20, 2025-12-27 |
Pydantic v1 support, then |
0.137.0 |
2026-06-14 |
Routers and routes preserved; |
Pitfalls
-
Setting
docs_url=Noneonly removes Swagger UI; the document and ReDoc stay. Useopenapi_url=Noneto remove everything, and remember that none of it is access control. -
include_in_schema=Falseand a router-widedeprecated=Trueare one-way switches at the router level: the first hides without protecting, the second cannot be undone per route. -
Changing
title,versionor routes after the first call toapp.openapi()has no effect untilapp.openapi_schema = None. -
Duplicate
operation_idvalues produce a warning and break generators. Agenerate_unique_id_functionthat indexesroute.tags[0]raises at import time for untagged routes. -
responses=documents; it does not return anything or validate bodies. Return theJSONResponseor raise the exception yourself. -
An
openapi_extrarequest body is invisible to FastAPI: no parsing, no validation, no422. Keep the declared schema and the code in sync, preferably from one Pydantic model. -
Examples are not checked against the model. Add a test that validates them.
-
Tags in
openapi_tagsare shown in list order; a typo in a decorator’s tag creates a new, unlisted tag. -
A plain default does not split input and output schemas with Pydantic 2.13; clients generated from
-Inputand-Outputnames break when you toggle the flag or add a computed field later. Decide early and regenerate. -
The CDN is a runtime dependency of
/docsand/redoc: self-host for air-gapped or restricted networks, and pin the versions you self-host. -
swagger_ui_parametersgiven toFastAPI(…)do not reach a hand-written/docsroute; pass them toget_swagger_ui_html()as well. -
persistAuthorizationstores credentials in the browser. Do not enable it for production-like data on shared machines. -
Webhook and callback blocks document a contract; they send nothing and say nothing about signatures. Document the verification scheme and implement delivery with retries elsewhere.
-
An API whose
info.versionnever changes defeats the purpose of exporting and checkingopenapi.json; bump it with each release. -
Pin FastAPI (it is still a 0.x library): schema details such as ids, titles and the route tree have changed between minor versions, and generated clients depend on them.