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 Hayden Bleasel22 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 app | A Blume site | |
|---|---|---|
| Serves | The OpenAPI JSON, from the running app | A page per operation, as static files on any host |
| Available | In Development, while the app runs | Always, whether the API is up or not |
| Content | The reference | Guides and the reference, on one site with one search |
| Updates | On every request | When 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 SDKCreate 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 --yesdotnet new webapiwrites a minimal API, which is the template's default.--use-controllersgives you controllers instead, and a note below covers what changes. It already referencesMicrosoft.AspNetCore.OpenApiand callsAddOpenApi().dotnet new gitignoreignoresbin/andobj/.global.jsonpins the SDK's feature band, so CI builds the spec with the same SDK line as your machine.blume initwritesdocs-site/with adocs/content folder, ablume.config.ts, and apackage.json, then installs Blume. Its own.gitignorekeepsnode_modules/,.blume/, anddist/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.12Then 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>| Property | What it does here |
|---|---|
GenerateDocumentationFile | Turns on XML doc comments. The source generator in Microsoft.AspNetCore.OpenApi reads them into the document. |
NoWarn | Silences 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. |
OpenApiDocumentsDirectory | Writes the document into docs-site/, relative to the project file. The default is obj/. |
OpenApiGenerateDocumentsOptions | Names 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 spec | On the docs site |
|---|---|---|
.WithTags("Messages") | The operation's tag | Its sidebar group, and the URL segment /api/messages/ |
.WithName(nameof(SendMessage)) | operationId | The page URL: SendMessage becomes send-message |
<summary> on the handler | summary | The page title and sidebar label |
<remarks> | description | The paragraph under the title, and the page's meta description |
<param> | The parameter's or request body's description | The parameter row, or the text above the body's schema |
example on a <param> | The parameter's example | The path in the code samples, like /messages/msg_8f2k |
<response code="404"> | That response's description | The text beside the status code |
The return type, like Results<Ok<Message>, NotFound>, and ProducesValidationProblem() | responses, with their schemas | The Responses section and the Response panel |
required, [MaxLength], [Range], a default value | required, maxLength, minimum, maximum, default | "required", "max length 320", and "min 1 · max 100 · default: 20" in the schema rows |
<example> on a property | The property's examples | Request 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>onMessageStore storebecomes the request body's description, replacing the one onrequest. Leave service parameters undocumented, which is why CS1573 is silenced. - Lambdas need
WithSummaryandWithDescription. 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.schemasand 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;
}
}AcmeDocumentTransformersets 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.BearerSecuritySchemeTransformeradds the scheme tocomponents.securitySchemes, but only if the app registers aBearerauthentication scheme, so the document can't describe auth the app doesn't have.bearerFormatlabels the credential: Blume shows "Bearer token (JWT)" in the Authorization section and the Try it panel.BearerRequirementTransformeradds 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 followsRequireAuthorization()andAllowAnonymous()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 buildThe 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.codeSamplesadds a C# sample, written withHttpClient, to the default cURL, JavaScript, and Python.deployment.sitegives 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 previewvalidate --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:
| Operation | Page |
|---|---|
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.jsonasks for: withlatestPatch, the newest patch in the 10.0.4xx band. Without aglobal.json,dotnetpicks 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:
| Setting | Value |
|---|---|
| Root directory | docs-site |
| Build command | npm run build |
| Output directory | dist |
| Node.js version | 22.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.csruns 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 builddoesn't rewrite the spec, so a deleted or hand-editedopenapi.jsonstays that way untilnpm 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.tsonly 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 --yesA step here not working for you? Report a broken step.