Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

API reference

Publish ASP.NET Core API documentation from build-time OpenAPI

Generate an ASP.NET Core API's OpenAPI document on every build, turn its XML comments and JWT auth into a page per operation beside your guides, and fail CI when the committed spec goes stale.

By 22 min read

To publish ASP.NET Core API documentation as a site of its own, have the build write your app's OpenAPI document to a file, then hand that file to Blume. In .NET 10, Microsoft.AspNetCore.OpenApi builds an OpenAPI 3.1 document from your endpoints and XML doc comments, and Microsoft.Extensions.ApiDescription.Server writes it to disk when you build. Blume turns each operation in it into a page, with its schemas, auth, code samples, and a Try it panel, next to the guides you write in Markdown.

By the end of this guide, you have a minimal API with JWT bearer auth whose doc comments describe every operation, a build that exports openapi.json into a Blume project, and a GitHub Actions workflow that fails when the committed spec no longer matches the code. It was tested with the .NET 10.0.401 SDK and ASP.NET Core 10.0.12.

Where the built-in OpenAPI support stops

The webapi template in .NET 10 adds OpenAPI support and nothing to read it with. app.MapOpenApi() serves the document as JSON at /openapi/v1.json, only in the Development environment, and the template includes no Swagger UI or Scalar page. You can add either as a separate package for your own team. A docs site does a different job:

MapOpenApi in the appA Blume site
ServesThe OpenAPI JSON, from the running appA page per operation, as static files on any host
AvailableIn Development, while the app runsAlways, whether the API is up or not
ContentThe referenceGuides and the reference, on one site with one search
UpdatesOn every requestWhen the build writes a new spec and the site rebuilds

Keep MapOpenApi() for local tools. The build-time export below doesn't use it: it generates the same document without the endpoint, so it works with the endpoint switched off in production.

Set up the two projects

The API and its docs live in one repository, as two folders:

acme/
├── AcmeApi/       the ASP.NET Core API
├── docs-site/     the Blume docs, and the openapi.json the build writes
├── .github/workflows/docs.yml
├── .gitignore
└── global.json    pins the .NET SDK

Create them:

mkdir acme && cd acme
git init
dotnet new webapi -o AcmeApi
dotnet new gitignore
dotnet new globaljson --sdk-version 10.0.401 --roll-forward latestPatch
npx blume init docs-site --template docs --yes
  • dotnet new webapi writes a minimal API, which is the template's default. --use-controllers gives you controllers instead, and a note below covers what changes. It already references Microsoft.AspNetCore.OpenApi and calls AddOpenApi().
  • dotnet new gitignore ignores bin/ and obj/. global.json pins the SDK's feature band, so CI builds the spec with the same SDK line as your machine.
  • blume init writes docs-site/ with a docs/ content folder, a blume.config.ts, and a package.json, then installs Blume. Its own .gitignore keeps node_modules/, .blume/, and dist/ out of Git.

Generate the OpenAPI document at build time

Add the build-time generator and the JWT bearer handler, pinned:

cd AcmeApi
dotnet add package Microsoft.Extensions.ApiDescription.Server --version 10.0.12
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 10.0.12

Then set up the project file:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);CS1573;CS1591</NoWarn>
    <OpenApiDocumentsDirectory>../docs-site</OpenApiDocumentsDirectory>
    <OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
    <PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.12">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

</Project>
PropertyWhat it does here
GenerateDocumentationFileTurns on XML doc comments. The source generator in Microsoft.AspNetCore.OpenApi reads them into the document.
NoWarnSilences the compiler's warnings for public members without comments (CS1591) and for parameters without a <param> (CS1573), which you'll leave out on purpose below.
OpenApiDocumentsDirectoryWrites the document into docs-site/, relative to the project file. The default is obj/.
OpenApiGenerateDocumentsOptionsNames the file openapi.json. The default is the project name, AcmeApi.json.

The generator runs as part of the build, because OpenApiGenerateDocumentsOnBuild defaults to true once the package is referenced. To get the document, it runs your app's Program.cs, app.Run() included, with a mock server in place of Kestrel, then asks the OpenAPI service for the document. Nothing listens on a port, and no request goes out.

The document reads better with the code in the next two sections in place, so add those files before you build.

Describe the API in C#

Everything a reader sees on an operation page comes from the C# code: XML doc comments for the prose, the handler's parameters and return types for the shapes, and data annotations for the limits. Start with the request and response types. A <summary> on a property becomes its description, and an <example>, written as JSON, becomes its example:

using System.ComponentModel.DataAnnotations;

namespace AcmeApi.Messages;

public enum Channel
{
    Email,
    Sms,
}

public enum MessageStatus
{
    Scheduled,
    Queued,
    Sent,
    Delivered,
    Failed,
    Canceled,
}

/// <summary>A message to send.</summary>
public sealed record SendMessageRequest
{
    /// <summary>How to deliver the message.</summary>
    /// <example>"Email"</example>
    public required Channel Channel { get; init; }

    /// <summary>An email address, or a phone number in E.164 format.</summary>
    /// <example>"ada@example.com"</example>
    [MaxLength(320)]
    public required string To { get; init; }

    /// <summary>The ID of the template to send.</summary>
    /// <example>"welcome"</example>
    [MaxLength(64)]
    public required string Template { get; init; }

    /// <summary>Values for the template's variables.</summary>
    /// <example>{ "firstName": "Ada" }</example>
    public Dictionary<string, string>? Variables { get; init; }

    /// <summary>When to send the message. Leave it out to send right away.</summary>
    /// <example>"2026-11-01T09:00:00Z"</example>
    public DateTimeOffset? SendAt { get; init; }
}

/// <summary>A message and where it is in delivery.</summary>
public sealed record Message
{
    /// <summary>The message ID.</summary>
    /// <example>"msg_8f2k"</example>
    public required string Id { get; init; }

    /// <summary>How the message is delivered.</summary>
    public required Channel Channel { get; init; }

    /// <summary>Who the message goes to.</summary>
    /// <example>"ada@example.com"</example>
    public required string To { get; init; }

    /// <summary>Where the message is in delivery.</summary>
    public required MessageStatus Status { get; init; }

    /// <summary>When the API accepted the message.</summary>
    /// <example>"2026-11-01T08:30:00Z"</example>
    public required DateTimeOffset CreatedAt { get; init; }

    /// <summary>When a scheduled message goes out.</summary>
    public DateTimeOffset? SendAt { get; init; }
}

/// <summary>A page of messages, newest first.</summary>
public sealed record MessageList
{
    /// <summary>The messages on this page.</summary>
    public required IReadOnlyList<Message> Items { get; init; }
}

An in-memory store stands in for your database:

using System.Collections.Concurrent;

namespace AcmeApi.Messages;

// Stands in for your database.
public sealed class MessageStore
{
    private readonly ConcurrentDictionary<string, Message> _messages = new();

    public Message Add(SendMessageRequest request)
    {
        var message = new Message
        {
            Id = $"msg_{Guid.NewGuid().ToString("N")[..8]}",
            Channel = request.Channel,
            To = request.To,
            Status = request.SendAt is null ? MessageStatus.Queued : MessageStatus.Scheduled,
            CreatedAt = DateTimeOffset.UtcNow,
            SendAt = request.SendAt,
        };
        _messages[message.Id] = message;
        return message;
    }

    public Message? Find(string id) => _messages.GetValueOrDefault(id);

    public IReadOnlyList<Message> List(MessageStatus? status, int limit) =>
        _messages.Values
            .Where(message => status is null || message.Status == status)
            .OrderByDescending(message => message.CreatedAt)
            .Take(limit)
            .ToList();

    public void Update(Message message) => _messages[message.Id] = message;
}

The handlers are static methods rather than lambdas, because a lambda can't carry an XML doc comment. RequireAuthorization() on the group makes every message endpoint need a token:

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

namespace AcmeApi.Messages;

public static class MessageEndpoints
{
    public static void MapMessageEndpoints(this IEndpointRouteBuilder app)
    {
        var messages = app.MapGroup("/messages")
            .WithTags("Messages")
            .RequireAuthorization();

        messages.MapPost("/", SendMessage)
            .WithName(nameof(SendMessage))
            .ProducesValidationProblem();
        messages.MapGet("/", ListMessages).WithName(nameof(ListMessages));
        messages.MapGet("/{id}", GetMessage).WithName(nameof(GetMessage));
        messages.MapDelete("/{id}", CancelMessage).WithName(nameof(CancelMessage));
    }

    /// <summary>Send a message</summary>
    /// <remarks>
    /// Queues an email or SMS for delivery and returns it right away, with the
    /// ID you use to check on it. Set <c>sendAt</c> to schedule it instead.
    /// </remarks>
    /// <param name="request">The message to send.</param>
    /// <response code="202">The message is queued or scheduled.</response>
    /// <response code="400">The request body failed validation.</response>
    public static Accepted<Message> SendMessage(SendMessageRequest request, MessageStore store)
    {
        var message = store.Add(request);
        return TypedResults.Accepted($"/messages/{message.Id}", message);
    }

    /// <summary>List messages</summary>
    /// <remarks>Returns your most recent messages, newest first.</remarks>
    /// <param name="status">Only return messages with this status.</param>
    /// <param name="limit">How many messages to return, from 1 to 100.</param>
    /// <response code="200">A page of messages.</response>
    public static Ok<MessageList> ListMessages(
        MessageStatus? status,
        MessageStore store,
        [Range(1, 100)] int limit = 20)
    {
        return TypedResults.Ok(new MessageList { Items = store.List(status, limit) });
    }

    /// <summary>Get a message</summary>
    /// <remarks>Returns a message and its delivery status.</remarks>
    /// <param name="id" example="msg_8f2k">The message ID, from the response to Send a message.</param>
    /// <response code="200">The message.</response>
    /// <response code="404">No message has that ID.</response>
    public static Results<Ok<Message>, NotFound> GetMessage(string id, MessageStore store)
    {
        return store.Find(id) is { } message ? TypedResults.Ok(message) : TypedResults.NotFound();
    }

    /// <summary>Cancel a scheduled message</summary>
    /// <remarks>
    /// Stops a scheduled message from going out. A message that's already
    /// queued or sent can't be canceled.
    /// </remarks>
    /// <param name="id" example="msg_8f2k">The ID of the message to cancel.</param>
    /// <response code="204">The message is canceled.</response>
    /// <response code="404">No message has that ID.</response>
    /// <response code="409">The message isn't scheduled, so it can't be canceled.</response>
    public static Results<NoContent, NotFound, Conflict<ProblemDetails>> CancelMessage(string id, MessageStore store)
    {
        if (store.Find(id) is not { } message)
        {
            return TypedResults.NotFound();
        }
        if (message.Status != MessageStatus.Scheduled)
        {
            return TypedResults.Conflict(new ProblemDetails
            {
                Title = "Only a scheduled message can be canceled.",
                Status = StatusCodes.Status409Conflict,
            });
        }
        store.Update(message with { Status = MessageStatus.Canceled });
        return TypedResults.NoContent();
    }
}

One public endpoint, so readers can check that they can reach the API:

using Microsoft.AspNetCore.Http.HttpResults;

namespace AcmeApi;

/// <summary>Whether the API is up.</summary>
public sealed record ApiStatus
{
    /// <summary>Whether the API is accepting messages.</summary>
    /// <example>true</example>
    public required bool AcceptingMessages { get; init; }
}

public static class StatusEndpoints
{
    public static void MapStatusEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/status", GetStatus)
            .WithName(nameof(GetStatus))
            .WithTags("Status");
    }

    /// <summary>Get the API status</summary>
    /// <remarks>
    /// Reports whether the API is accepting messages. It needs no token, so
    /// call it first to check that you can reach the API.
    /// </remarks>
    /// <response code="200">The API's status.</response>
    public static Ok<ApiStatus> GetStatus() =>
        TypedResults.Ok(new ApiStatus { AcceptingMessages = true });
}

Where each piece shows up

In C#In the specOn the docs site
.WithTags("Messages")The operation's tagIts sidebar group, and the URL segment /api/messages/
.WithName(nameof(SendMessage))operationIdThe page URL: SendMessage becomes send-message
<summary> on the handlersummaryThe page title and sidebar label
<remarks>descriptionThe paragraph under the title, and the page's meta description
<param>The parameter's or request body's descriptionThe parameter row, or the text above the body's schema
example on a <param>The parameter's exampleThe path in the code samples, like /messages/msg_8f2k
<response code="404">That response's descriptionThe text beside the status code
The return type, like Results<Ok<Message>, NotFound>, and ProducesValidationProblem()responses, with their schemasThe Responses section and the Response panel
required, [MaxLength], [Range], a default valuerequired, maxLength, minimum, maximum, default"required", "max length 320", and "min 1 · max 100 · default: 20" in the schema rows
<example> on a propertyThe property's examplesRequest samples, Try it's starting values, and response examples

A <response> tag only describes a status code the endpoint already declares through its return type or metadata. Adding one for a code the endpoint never returns changes nothing.

Give every operation a name

Minimal APIs write no operationId unless the endpoint has a name, and Blume builds each page's URL from the tag and the operation ID. Without WithName, it falls back to the method and path, so the pages land at /api/messages/post-messages and /api/messages/get-messages-id. Keep the names unique: the .NET build doesn't check, a repeated name goes into the spec twice, and Blume warns BLUME_OPENAPI_DUPLICATE_OPERATION_ID and gives the second page a URL with its HTTP method added when the two share a tag, like get-message-get. Without WithTags, every operation is tagged with the assembly name, AcmeApi, and lands in one sidebar group.

With controllers, set the name on the route attribute, as in [HttpGet("{id}", Name = "GetMessage")]. The tag defaults to the controller's name, and the same XML comments work on action methods.

Doc comment gotchas

  • Don't document injected services. A <param> on MessageStore store becomes the request body's description, replacing the one on request. Leave service parameters undocumented, which is why CS1573 is silenced.
  • Lambdas need WithSummary and WithDescription. They set the same fields. When a handler has both, the XML comment wins.
  • Enum properties keep their own description. The spec puts an enum in components.schemas and points each property at it with a $ref, with the property's own description beside it. Blume shows the property's description on its row, and the enum's only where the property has none.

Make numbers and enums read right

Two lines in ConfigureHttpJsonOptions, in Program.cs below, change the schemas as much as anything else in this guide. Minimal APIs read numbers from JSON strings by default, and the document says so: without JsonNumberHandling.Strict, every integer is typed ["integer", "string"] with a regex pattern, and Blume's schema rows read "integer | string". Without JsonStringEnumConverter, enums are sent as numbers and documented as a bare integer, with no list of allowed values.

Don't give the converter a naming policy, like JsonNamingPolicy.CamelCase. The document would list scheduled as a value, but the query string binds an enum by its exact C# name, so ?status=scheduled gets a 400 while ?status=Scheduled works. With the plain converter, the spec lists the names the API accepts everywhere.

Declare the server and the bearer scheme

The generated document has no servers and no security scheme, and its title is AcmeApi | v1. Without a server, Blume's code samples and Try it panel send requests to a bare path on the docs site's own origin. Fix all three with transformers, which edit the document after ASP.NET Core builds it:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace AcmeApi.OpenApi;

// The title, public server, and tag descriptions for the whole document.
internal sealed class AcmeDocumentTransformer : IOpenApiDocumentTransformer
{
    public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
    {
        document.Info = new OpenApiInfo
        {
            Title = "Acme Messages API",
            Version = "1.0.0",
            Description = "Send transactional email and SMS from your app, schedule messages for later, and check whether each one was delivered.",
        };
        document.Servers = [new OpenApiServer { Url = "https://api.acme.example", Description = "Production" }];
        document.Tags = new HashSet<OpenApiTag>
        {
            new() { Name = "Messages", Description = "Send a message and check whether it arrived." },
            new() { Name = "Status", Description = "Check that you can reach the API." },
        };
        return Task.CompletedTask;
    }
}

// Declares the JWT bearer scheme the app registers with AddJwtBearer().
internal sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider schemes) : IOpenApiDocumentTransformer
{
    public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
    {
        if (await schemes.GetSchemeAsync(JwtBearerDefaults.AuthenticationScheme) is null)
        {
            return;
        }
        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
        document.Components.SecuritySchemes[JwtBearerDefaults.AuthenticationScheme] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            Description = "An access token, sent in the Authorization header.",
        };
    }
}

// Marks the operations that need a token, from the endpoint's own
// authorization metadata, so the docs match what the API enforces.
internal sealed class BearerRequirementTransformer : IOpenApiOperationTransformer
{
    public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
    {
        var metadata = context.Description.ActionDescriptor.EndpointMetadata;
        if (!metadata.OfType<IAuthorizeData>().Any() || metadata.OfType<IAllowAnonymous>().Any())
        {
            return Task.CompletedTask;
        }
        operation.Security =
        [
            new OpenApiSecurityRequirement
            {
                [new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme, context.Document)] = [],
            },
        ];
        operation.Responses ??= new OpenApiResponses();
        operation.Responses.TryAdd("401", new OpenApiResponse { Description = "The token is missing, expired, or invalid." });
        return Task.CompletedTask;
    }
}
  • AcmeDocumentTransformer sets the title, the public server URL, and the tags list. Blume orders its sidebar groups and overview sections by that list, and shows each tag's description on the overview.
  • BearerSecuritySchemeTransformer adds the scheme to components.securitySchemes, but only if the app registers a Bearer authentication scheme, so the document can't describe auth the app doesn't have. bearerFormat labels the credential: Blume shows "Bearer token (JWT)" in the Authorization section and the Try it panel.
  • BearerRequirementTransformer adds a security requirement, and a 401 response, to each operation whose endpoint requires authorization and doesn't allow anonymous access. Operation transformers see the endpoint's metadata, so the requirement follows RequireAuthorization() and AllowAnonymous() rather than a list you keep by hand.

.NET 10 builds on version 2 of Microsoft.OpenApi. The model types are in the Microsoft.OpenApi namespace, and a reference to a security scheme is an OpenApiSecuritySchemeReference. Samples written for .NET 9 use Microsoft.OpenApi.Models and OpenApiReference, which don't compile here.

Replace the template's Program.cs, which removes the weather forecast endpoint:

using System.Text.Json.Serialization;
using AcmeApi;
using AcmeApi.Messages;
using AcmeApi.OpenApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
    .WithOrigins("https://docs.acme.example")
    .WithMethods("GET", "POST", "DELETE")
    .WithHeaders("Authorization", "Content-Type")));
builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer<AcmeDocumentTransformer>();
    options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
    options.AddOperationTransformer<BearerRequirementTransformer>();
});
builder.Services.AddSingleton<MessageStore>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();

app.MapMessageEndpoints();
app.MapStatusEndpoints();

app.Run();

AddValidation() runs the data annotations on minimal API parameters, and AddProblemDetails() makes their errors the full application/problem+json objects the spec describes. The CORS policy lets the docs site's Try it panel call the API from a reader's browser. Now build:

dotnet build

The build writes docs-site/openapi.json. Check what Blume will use for the version, the server, and the auth:

jq '{openapi, servers, sendMessage: .paths["/messages"].post.security, getStatus: .paths["/status"].get.security, securitySchemes: .components.securitySchemes}' ../docs-site/openapi.json
{
  "openapi": "3.1.1",
  "servers": [
    {
      "url": "https://api.acme.example",
      "description": "Production"
    }
  ],
  "sendMessage": [
    {
      "Bearer": []
    }
  ],
  "getStatus": null,
  "securitySchemes": {
    "Bearer": {
      "type": "http",
      "description": "An access token, sent in the Authorization header.",
      "scheme": "bearer",
      "bearerFormat": "JWT"
    }
  }
}

.NET 10 writes OpenAPI 3.1 by default, which Blume reads as written. Get the API status has no security, so its page has no Authorization section. If a change belongs in the docs but not in the app's own document, like hiding an internal operation, put it in an overlay on the Blume side instead of a transformer.

Keep startup code out of the export

The build runs your whole Program.cs, in the Production environment unless ASPNETCORE_ENVIRONMENT says otherwise. Code there that needs a secret or a database fails the build: a missing connection string stops it with the exception and exited with code 11. The Acme API has no such code, but in an app that reads a connection string at startup, skip that code while the document is being generated:

using System.Reflection;

var builder = WebApplication.CreateBuilder(args);

// True while the build runs the app to generate the OpenAPI document.
var generatingOpenApi = Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider";
if (!generatingOpenApi)
{
    var connectionString = builder.Configuration.GetConnectionString("Messages")
        ?? throw new InvalidOperationException("ConnectionStrings:Messages is not set.");
    // ...register the database with connectionString
}

GetDocument.Insider is the tool the build uses to load your app. Give the export stand-ins rather than real credentials: describing the API should never need a production secret.

Run the API with a test token

dotnet user-jwts issues tokens that the JWT bearer handler accepts in Development. From AcmeApi/, create one, and start the API in the background:

export TOKEN=$(dotnet user-jwts create --name ada --output token)
dotnet run --launch-profile http &

The first command also adds a UserSecretsId to the project file and the token's issuer and audiences to appsettings.Development.json. The template picked a random port for the http profile; it's in Properties/launchSettings.json, 5185 here. Once the log says Now listening, send the same message without the token and with it:

curl -i http://localhost:5185/messages \
  -H "Content-Type: application/json" \
  -d '{"channel":"Email","to":"ada@example.com","template":"welcome"}'

curl -i http://localhost:5185/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"channel":"Email","to":"ada@example.com","template":"welcome"}'

The first request gets 401 Unauthorized and the second 202 Accepted, with the queued message. Everything the docs say about auth comes from the same RequireAuthorization() call the API enforces. Stop the API with kill %1.

Build the docs site

Replace the config that blume init wrote:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  title: "Acme Docs",
  description: "Guides and API reference for the Acme Messages API.",
  deployment: { site: "https://docs.acme.example" },
  navigation: {
    tabs: [
      { label: "Guides", path: "/" },
      { label: "API reference", path: "/api" },
    ],
  },
  reference: [
    openapi({
      route: "/api",
      spec: "./openapi.json",
      codeSamples: ["curl", "csharp", "js", "python"],
    }),
  ],
});
  • openapi() mounts the reference at /api. It doesn't add a header tab on its own, so the API reference tab is what puts it in the header, and it scopes the tab's sidebar to the reference.
  • codeSamples adds a C# sample, written with HttpClient, to the default cURL, JavaScript, and Python.
  • deployment.site gives the sitemap and canonical links their origin on hosts that don't tell the build its URL.

Add a script that regenerates the spec, beside the ones blume init wrote:

"scripts": {
  "spec": "dotnet build ../AcmeApi --no-incremental",
  "dev": "blume dev",
  "build": "blume build",
  "doctor": "blume doctor"
},

--no-incremental matters. The generator only runs when the build compiles a new assembly, so a plain dotnet build with no code changes leaves openapi.json as it is, even if you deleted it. build stays blume build alone, so a docs host that has Node.js but no .NET SDK can build the site from the committed spec.

Write the guides

The reference says what each operation takes and returns. Readers also need to know how to authenticate and which call comes first. Replace docs/index.mdx and add two guides:

---
title: Introduction
description: Send transactional email and SMS with the Acme Messages API, check whether they arrived, and schedule them for later.
---

The Acme Messages API sends transactional email and SMS for your app, and tells
you whether each message arrived.

- [Quickstart](/quickstart) takes you from an access token to your first delivered message.
- [Schedule a message](/schedule-a-message) sends one later, and cancels it if plans change.
- The [API reference](/api) lists every operation, generated from the API's own code.
---
title: Quickstart
description: Check that you can reach the Acme Messages API, send your first email with an access token, and check whether it was delivered.
---

Every request except [Get the API status](/api/status/get-status) needs an
access token, sent in the `Authorization` header as a bearer token. Set it in
your shell first:

```bash
export ACME_TOKEN="your-access-token"
```

## Check that you can reach the API

The status endpoint needs no token:

```bash
curl https://api.acme.example/status
```

```json
{ "acceptingMessages": true }
```

## Send a message

Send the channel, the recipient, and a template ID to
[Send a message](/api/messages/send-message):

```bash
curl https://api.acme.example/messages \
  -H "Authorization: Bearer $ACME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"channel":"Email","to":"ada@example.com","template":"welcome"}'
```

The API answers `202 Accepted` with the queued message:

```json
{
  "id": "msg_8f2k",
  "channel": "Email",
  "to": "ada@example.com",
  "status": "Queued",
  "createdAt": "2026-11-01T08:30:00Z",
  "sendAt": null
}
```

## Check whether it arrived

Pass the ID to [Get a message](/api/messages/get-message):

```bash
curl https://api.acme.example/messages/msg_8f2k \
  -H "Authorization: Bearer $ACME_TOKEN"
```

Its `status` moves from `Queued` to `Sent`, then to `Delivered` or `Failed`.

## Read an error

A request body that fails validation gets a `400` with a problem details
object, which lists the problems by field:

```json
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Template": [
      "The field Template must be a string or array type with a maximum length of '64'."
    ]
  }
}
```

A missing, expired, or invalid token gets a `401` with an empty body and a
`WWW-Authenticate: Bearer` header.
---
title: Schedule a message
description: Send an Acme message at a set time with sendAt, list the messages still waiting to go out, and cancel one before it's sent.
---

To send a message later, add `sendAt` to the body of
[Send a message](/api/messages/send-message), as an ISO 8601 date and time:

```bash
curl https://api.acme.example/messages \
  -H "Authorization: Bearer $ACME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"channel":"Sms","to":"+15550100","template":"reminder","sendAt":"2026-11-01T09:00:00Z"}'
```

The message comes back with the status `Scheduled`.

## List what's waiting

[List messages](/api/messages/list-messages) filters by status. Status values
are case-sensitive in the query string, so write `Scheduled`, not `scheduled`:

```bash
curl "https://api.acme.example/messages?status=Scheduled&limit=10" \
  -H "Authorization: Bearer $ACME_TOKEN"
```

## Cancel it

[Cancel a scheduled message](/api/messages/cancel-message) stops it from going
out, and answers `204 No Content`:

```bash
curl -X DELETE https://api.acme.example/messages/msg_8f2k \
  -H "Authorization: Bearer $ACME_TOKEN"
```

Once a message is queued or sent, it can't be canceled, and the API answers
`409 Conflict`.

The responses in them are the ones the API returned locally, with simpler IDs and timestamps and the error's traceId left out. Link to operation pages by URL, which comes from the tag and the endpoint name, so blume validate can tell you when renaming an endpoint breaks a link.

Order the operations

Inside a tag's group, the sidebar lists operations in the spec's order, path by path. To set your own order, add a meta.ts in the folder that matches the tag's route:

import { defineMeta } from "blume";

export default defineMeta({
  title: "Messages",
  order: 0,
  pages: ["send-message", "get-message", "list-messages", "cancel-message"],
});

A meta.ts you write changes only the fields it sets, and Blume fills in the rest from the spec, so the tag's name and its place in the tags list could be left out.

Build and preview

From docs-site/:

npm run spec
npm run build
npx blume validate --strict
npx blume preview

validate --strict checks every internal link, including the guides' links to operation pages, and fails on warnings too. Open the URL the preview prints and go to /api. The overview lists both tags with their descriptions, and each operation has its own page:

OperationPage
POST /messages/api/messages/send-message
GET /messages/{id}/api/messages/get-message
GET /messages/api/messages/list-messages
DELETE /messages/{id}/api/messages/cancel-message
GET /status/api/status/get-status

Send a message, for example, has its <summary> as the title and its <remarks> under it, an Authorization section for the bearer token, the request body with each property's description and limits, and the 202, 400, and 401 responses. Try it starts from the property examples and has a field for the token. Get the API status has no Authorization section. The pages point at https://api.acme.example, which doesn't exist, so Send can't succeed here; with your own API, it calls the server your transformer declares, which has to allow the docs origin through CORS.

While you work on the API, run npm run dev instead of a build. blume dev watches openapi.json, so after npm run spec the open page updates without a restart.

Commit the spec and check it in CI

Commit docs-site/openapi.json, even though the build writes it. The docs host then needs only Node.js, a pull request shows each API change the way clients see it, and other tools can read the spec straight from Git. The risk is a committed spec that no longer matches the code, so check for that in CI:

name: Docs

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  docs:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: docs-site
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: docs-site/package-lock.json
      - run: npm ci
      - name: Generate the OpenAPI document
        run: npm run spec
      - name: Check the committed spec is up to date
        run: git diff --exit-code -- openapi.json
      - name: Check links
        run: npx blume validate --strict
      - name: Build the docs
        run: npm run build
  • setup-dotnet installs the SDK that global.json asks for: with latestPatch, the newest patch in the 10.0.4xx band. Without a global.json, dotnet picks the newest SDK on the runner, preinstalled ones included.
  • npm run spec restores the NuGet packages, builds the API, and writes the spec. A fresh checkout has no obj/, so the generator always runs.
  • git diff --exit-code fails the job when the generated spec differs from the committed one.
  • validate and build check the guides' links against the new spec and build the site from it.

The generator writes the same bytes for the same code. Say a pull request adds a ReplyTo property to SendMessageRequest but leaves out the regenerated spec. The check fails and prints what the commit is missing:

@@ -440,6 +440,16 @@
             "type": "string",
             "description": "The ID of the template to send."
           },
+          "replyTo": {
+            "examples": [
+              "support@acme.example"
+            ],
+            "type": [
+              "null",
+              "string"
+            ],
+            "description": "The address replies go to, for email."
+          },
           "variables": {
             "examples": [
               {

Run npm run spec and commit the result. When a pull request renames an endpoint, the spec check passes once it's regenerated, but validate fails on every guide link to the old page, with BLUME_BROKEN_LINK and the file and line. To also catch changes that break existing clients, add oasdiff as shown in Keep API documentation in sync with backend changes in CI.

The export also runs in builds that have nothing to do with the docs, like a dotnet publish in your API's Dockerfile, where it starts your app once more and writes a stray openapi.json. Turn it off there with dotnet publish -p:OpenApiGenerateDocumentsOnBuild=false.

Deploy

The site builds to static files in docs-site/dist. Point your host at the docs-site folder:

SettingValue
Root directorydocs-site
Build commandnpm run build
Output directorydist
Node.js version22.19 or later

The build reads the committed spec and never runs .NET, so the docs deploy on their own schedule and stay up when the API is down. The deployment docs cover each host. Deploy the API with its CORS policy too, set to your real docs origin: the Try it panel sends requests from the reader's browser straight to the server in the spec.

Limitations

  • The export runs your startup code. All of Program.cs runs on every build that compiles the app, in CI too. Guard what needs secrets or a database.
  • Incremental builds skip the export. If no code changed, dotnet build doesn't rewrite the spec, so a deleted or hand-edited openapi.json stays that way until npm run spec.
  • Operations follow the spec's order. The sidebar and the overview list a tag's operations in the order the spec lists them. A meta.ts only reorders the sidebar.

Troubleshooting

openapi.json didn't change after dotnet build

The build was incremental and compiled nothing new, so the generator didn't run. Use npm run spec, which passes --no-incremental. To see the generator's own log lines, like Writing document named 'v1', build with --tl:off.

The build fails with "exited with code 11"

Your app threw while the build was starting it to generate the document. The lines above name the exception and the line in Program.cs. Guard that code with the GetDocument.Insider check shown earlier.

Pages are at post-messages and get-messages-id

The endpoints have no names, so the spec has no operation IDs. Add WithName to each, or Name on a controller's route attribute. If the old URLs are already public, add a redirect for each.

Schema rows say integer | string

The JSON options still allow numbers as strings. Set NumberHandling to JsonNumberHandling.Strict in ConfigureHttpJsonOptions, then regenerate.

The build fails with CS0234 or CS0246 on OpenAPI types

The code was written for .NET 9's Microsoft.OpenApi 1.x. using Microsoft.OpenApi.Models; fails with CS0234, because the namespace no longer exists, and an OpenApiSecurityScheme with a Reference fails with CS0117, plus CS0246 for OpenApiReference. Change the using to using Microsoft.OpenApi;, and replace the scheme and its Reference with an OpenApiSecuritySchemeReference, as in the transformers above.

Send fails in Try it, but curl works

That's CORS. The panel calls your API from the docs site's origin, and the browser blocks the response unless the API allows it. Check that WithOrigins lists the docs origin exactly, scheme and host with no trailing slash. If only requests with a missing or wrong token fail that way, UseCors() comes after UseAuthorization(): the preflight still passes, but the 401 goes out without an Access-Control-Allow-Origin header, so the browser hides it. Call UseCors() first, as above. Fix CORS errors in an API documentation playground covers reading the preflight, and Blume's proxy for an API you can't change.

The build fails with BLUME_OPENAPI_UNAVAILABLE

Blume couldn't read docs-site/openapi.json. In a fresh clone, the file has to be committed, since npm run build doesn't generate it. In blume dev, a missing spec is only a warning and the reference is left out, so a working dev server can hide it.

Next step

Add docs to your ASP.NET Core API

Run it at the root of your repository, beside your API project, then point OpenApiDocumentsDirectory at the new docs-site folder.

npx blume init docs-site --template docs --yes
Add breaking-change checks in CI

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init