API documentation that maintains itself: OpenAPI from code, used everywhere

12 min read

Hand-written API documentation drifts and costs hours every month. Generate OpenAPI from code in CI and use one file for docs, Postman, mocks and SDKs.

Documentation that can't drift: generating OpenAPI from code

On a public API I worked on, support tickets about its documentation arrived in waves. The documentation described endpoints, fields and responses that didn't match what the API actually did. Each ticket could be fixed on its own: correct the page, close the ticket. The next release made another page wrong.

The documentation was written by hand and kept separately from the code, which is how most API documentation is written, and the problem is just as common. In Postman's 2025 State of the API survey of more than 5,700 developers, architects and executives, 55% of teams named inconsistent documentation as a blocker for working together. The tickets weren't the problem. They were symptoms of a structure that guaranteed the documentation would fall behind. Instead of fixing them one by one, I built a proof of concept that generated the OpenAPI specification from the code itself. It was adopted, generation became part of the build, and the waves of documentation tickets stopped.

This article is about why hand-written API documentation eventually drifts, what it takes to generate it instead, and why a small process like this is a simple business decision: it replaces recurring manual work, brings order to how a team builds its API, and gives clients documentation they can test and trust.

Why hand-written documentation eventually drifts

Documentation kept next to the code is a second copy of the truth, and nothing keeps the two copies in sync:

  • Nothing tests it. A change to an endpoint that breaks the code fails a test. A change that makes the documentation wrong fails nothing.
  • Reviewers can't see it. A pull request that renames a field shows the code change. It doesn't show the documentation page that still uses the old name.
  • It's written by the wrong person at the wrong time. Whoever changes the endpoint knows exactly what changed, but the documentation is often updated later, by someone else, from memory.
  • The cost lands elsewhere. The developer who forgets to update the documentation never feels the consequence. The integrator who follows it does, and then the support desk.

None of this is carelessness. Any process that relies on people remembering to update a second copy will eventually produce a wrong second copy.

One source of truth, and a check

There are two consistent ways out, and both follow the same principle: one source of truth, and an automated check that the other side agrees with it.

Code first. The specification is generated from the code: routes, validation rules, types and response structures. The code is the source of truth, and the documentation can't describe an endpoint that doesn't exist.

Design first. The specification is written first, by hand, and the code is built to match it. This works well when several teams agree on an API before anyone builds it. It stays true only if tests check the running API against the specification, with a tool such as Schemathesis, which generates test requests from it. Without that check, it drifts the same way prose documentation does.

For an existing API with hand-written documentation that has already drifted, code first is usually the faster way back to the truth, because the code already is the truth.

What the code has to declare

A generator can only describe what the code states explicitly. If a controller returns an untyped array assembled on the fly, the generator can list the route and nothing more. Generated documentation is exactly as good as the code's declarations.

Two things matter most:

  • Explicit request validation. Rules for what an endpoint accepts: required fields, types, formats, allowed values.
  • Explicit response structures. A defined shape for what it returns, not whatever the model happens to contain.

In Laravel, these are form requests and API resources. They are good practice anyway, and a generator such as Scramble can read them without any annotations. A simplified example:

payments.php
// app/Http/Requests/StorePaymentRequest.php
class StorePaymentRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'amount' => ['required', 'integer', 'min:1'],
            'currency' => ['required', 'string', 'size:3'],
            'reference' => ['nullable', 'string', 'max:64'],
        ];
    }
}

// app/Http/Resources/PaymentResource.php
class PaymentResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'amount' => $this->amount,
            'currency' => $this->currency,
            'status' => $this->status->value,
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

From these, the generator knows that amount is a required integer, that reference may be null or left out, and what a payment looks like in a response. Nobody wrote that down a second time.

Getting there on an existing codebase is most of the work. Endpoints that return arrays built inline need resources. Validation scattered through controllers needs to move into request classes. Types need to be declared where they were only implied. That work is worth doing for its own sake. It also means the generated documentation starts showing its value endpoint by endpoint, not all at once.

The same structure brings order to the team. When every endpoint has a request class and a response resource, developers write the API the same way, a new developer finds validation and response shapes in the same place on every endpoint, and a reviewer sees an API change before it reaches production instead of after. The specification turns an unwritten convention into a structure that the build checks.

OpenAPI generators by stack

Most ecosystems have a mature way to generate the specification. The important difference between them is where the information comes from:

  • Laravel: Scramble infers the specification from form requests, resources and types. L5-Swagger builds it from annotations written in the code.
  • Symfony: NelmioApiDocBundle, from routes, types and attributes.
  • Spring Boot: springdoc-openapi, from controllers and types.
  • ASP.NET Core: built-in OpenAPI generation since .NET 9.
  • FastAPI: generates the specification automatically from type hints and Pydantic models.
  • NestJS: @nestjs/swagger, from decorators and DTO classes.
  • Node.js with Zod: libraries such as zod-to-openapi turn the same schemas that validate requests into the specification.
  • Go: swaggo builds it from structured comments.

Annotations and structured comments are a step forward from a separate documentation site, because they live next to the code. They are still hand-written, though, and nothing checks that an annotation matches the code under it. Where a tool can infer the specification from the types and validation that the code actually runs, prefer that. Use annotations for descriptions and examples, which only people can write.

Make it part of the build

Generation only prevents drift if it happens every time the code changes. In CI:

  1. Generate the specification from the code, and fail if the committed copy is out of date.
  2. Lint it, with a linter such as Spectral, so that an invalid specification fails the build.
  3. Compare it with the previous version using a tool such as oasdiff, and fail on breaking changes, unless they are intended.
  4. Publish it with a documentation viewer such as Redoc, Swagger UI or Scalar.
api-docs.yml
# .github/workflows/api-docs.yml (the relevant steps)
- name: Generate the OpenAPI specification
  run: php artisan scramble:export --path=api.json

# The committed file must match what the code produces
- name: Fail if the committed specification is out of date
  run: git diff --exit-code api.json

# Uses a .spectral.yaml with: extends: ["spectral:oas"]
- name: Lint the specification
  run: npx @stoplight/spectral-cli lint api.json

# Needs the main branch fetched (actions/checkout with fetch-depth: 0)
- name: Fail on breaking changes against the main branch
  run: |
    git show origin/main:api.json > api-main.json
    oasdiff breaking api-main.json api.json --fail-on ERR

Committing the generated file to the repository has one more benefit: the specification changes in the same pull request as the code, so reviewers see the API change as well as the implementation. A renamed field shows up as a diff in api.json, where nobody can miss it. That only holds if CI also checks that the committed file matches what the code produces, which is what the git diff --exit-code step does. Without it, a developer who forgets to regenerate the file brings the drift back.

One file, many uses

Once the specification is generated and checked on every change, it stops being only documentation. The same file feeds the tools that people around the API already use:

  • Postman, Insomnia and Bruno import an OpenAPI file and turn it into a collection of ready requests. QA tests against it, support reproduces a client's problem with it, and clients start from it instead of building requests by hand from the documentation.
  • A mock server such as Prism answers requests based on the specification, so the frontend team and clients can start integrating before the backend is finished.
  • Client SDKs can be generated with OpenAPI Generator for dozens of languages, so clients don't have to write the HTTP layer themselves.
  • Contract tests with Schemathesis send generated requests to the running API and report every place where it doesn't match the specification.

None of these needs a person to keep it up to date. Each one is rebuilt from the file that the build already produces.

Publishing it for your clients

A specification in the repository helps your own team. Clients, partners and integrators need more: a developer portal where they can read, search and try the API without asking anyone. Hosted platforms such as ReadMe build that portal from the same OpenAPI file. Redocly, Mintlify and Bump.sh are alternatives, and Redoc, Swagger UI or Scalar can be hosted on your own site.

What a portal like ReadMe adds for the business:

  • A first successful call without a support ticket. Every endpoint becomes a reference page with code samples and a "Try It" explorer that sends real requests from the browser.
  • The client's own API key, already filled in. With Personalized Docs, ReadMe calls a webhook on your side when a client logs in to the portal. Your API checks ReadMe's signature on the request and answers with that client's keys, so keys only ever go to ReadMe, and every example is ready to run.
  • Support that starts from facts. When the API sends its logs to ReadMe through its Metrics SDK, a logged-in client sees their own recent requests, with endpoints and status codes, under My Requests. A support conversation starts from the failed call, not from a description of it.
  • Usage you can see. The dashboard shows which pages clients read, what they search for and which calls they try, which tells product and sales which parts of the API matter and where integrators get stuck.
  • Guides next to the reference. The generated reference covers endpoints and fields. Guides, tutorials and a changelog written by people live in the same portal.

Some of these features depend on the plan, so check them against what your clients need before choosing a platform.

The portal should be one more step in the same pipeline, not a second place where someone updates documentation by hand. After the specification is generated, linted and checked for breaking changes, a merge to the main branch uploads it with ReadMe's rdme tool:

api-docs.yml
# Runs after the steps above, only on the main branch
- name: Publish the API reference to ReadMe
  if: github.ref == 'refs/heads/main'
  uses: readmeio/rdme@v10
  with:
    rdme: openapi upload api.json --key=${{ secrets.README_API_KEY }} --confirm-overwrite

Three decisions make it work in practice:

  • What's public. Internal and admin endpoints don't belong in the client portal. Exclude them from the generated specification, or generate a separate one for partners.
  • Versions. If clients integrate against more than one version of the API, publish each to its own version in the portal (--branch), so nobody reads documentation for a version they don't use.
  • Descriptions. Write summaries and descriptions next to the code, in docblocks or attributes, so the code stays the single source of truth for the portal too.

What it costs and what it saves

Whether this is worth doing is a business question, and it can be answered with arithmetic. Here is an illustrative calculation, not measured data. Assume an API with 60 endpoints, two releases a month, and €50 an hour as the full cost of an hour of a developer's or support person's time. Replace the assumptions with your own.

With hand-written documentation, every month costs roughly:

  • Updating the documentation: 6 hours per release, so 12 hours.
  • Documentation tickets: 10 a month at about 1.5 hours each, split between support and a developer, so 15 hours.
  • Onboarding calls: 4 new integrators a month, 3 hours each to explain what the documentation doesn't, so 12 hours.

That is about 39 hours, or €1,950 a month. With generated documentation, a developer portal and a Postman collection, the same work shrinks to reviewing the api.json diff in pull requests (about an hour), the few tickets that are about the API's behaviour rather than its documentation (about 3 hours) and shorter onboarding (about 4 hours). That is about 8 hours, or €400 a month, and the difference is around €1,550 a month, or €18,600 a year.

The one-time cost is mostly in the code. Moving 60 endpoints to request and response classes at one to two hours each, plus a day or two to set up the pipeline, comes to roughly 70 to 140 hours, or €3,500 to €7,000. At €1,550 a month, that pays back in two to five months. A hosted portal's subscription comes on top, so compare it with the monthly difference before choosing one.

Some of the gains don't fit in a calculation: an integration that starts a week earlier, a client who doesn't give up after the first failed call, a developer who isn't interrupted to explain a field. In the case from the opening of this article, the clearest result wasn't a number at all. The waves of documentation tickets stopped.

The argument you'll have to make

Generating the documentation is the easy part. The prerequisites are the hard part: consistent types, request classes and response resources across the codebase. They take effort, and the effort has to be justified to engineers and to the business alike. The objections are predictable:

  • "It's extra work." It's work that validation and consistent responses require anyway, and it's done once per endpoint. Keeping hand-written documentation correct is done on every change, forever, and usually isn't done.
  • "Our documentation has explanations and examples, not just fields." Keep them. Generated reference documentation covers endpoints and fields. Guides and tutorials still need people, and descriptions can live next to the code in docblocks or attributes.
  • "We'll lose control over what's published." Nothing is published that the code doesn't do. That's the point.

Responsibility moves to the right place

The biggest change isn't technical. With generated documentation, whoever changes an endpoint also changes its documentation, in the same commit, whether they think about it or not. The integrator reading the documentation is reading what the code actually does, and the support desk stops receiving tickets about pages that describe last year's API.

Documentation that can't drift isn't written more carefully. It's documentation that nobody has to remember to update. If your API documentation keeps producing support tickets, tell me what you're working on.

Source: Postman, 2025 State of the API Report.