Building hldays #10: Docs that sell the API
---
title: Building hldays #10: Docs that sell the API
published: true
date: 2026-11-2 09:00:00 UTC
tags: api,openapi,documentation,webdev
canonical_url: hldays.dev
---
For a paid API, the docs are the shop window. Someone lands on the page, tries one thing, and decides in a couple of minutes whether to keep going. So I wanted the docs to be correct (generated, not hand-written), easy to try, and present inside the API itself, not only on a website.
This post is about how that's wired up in hldays: one list of endpoints, four places it shows up, a live demo on the home page, and responses that tell you what you're missing.
One list of endpoints
Every endpoint is described once, in src/docs.ts:
/** One documented endpoint. The index page, OpenAPI spec and route tests are all derived from this list. */
export interface Endpoint {
method?: "GET" | "POST";
path: string;
summary: string;
params: Param[];
example: string;
}
Parameters that repeat across endpoints are defined once and reused, so the wording of jurisdiction is the same everywhere:
const jurisdiction = q("jurisdiction", true, "Jurisdiction id (e.g. PT-M-1106) or name/alias (e.g. lisboa, Lisbon). Names prefer the most specific level.");
const includeSources = q("include_sources", false, "Include source/quote on each holiday (default true). false gives much smaller responses.", { type: "boolean" });
{
path: "/v1/holidays",
summary: "All holidays that apply in a jurisdiction in a year, including inherited regional and national ones.",
params: [jurisdiction, q("year", false, `Year ${MIN_YEAR}-${MAX_YEAR}. Default: current year.`, { type: "integer", minimum: MIN_YEAR, maximum: MAX_YEAR }), countryHint, types, includeSources],
example: "/v1/holidays?jurisdiction=lisboa&year=2026",
},
The year range comes from the same MIN_YEAR and MAX_YEAR constants the engine uses to reject a bad year, so the docs can't promise a range the API doesn't serve.
The example field matters more than it looks. It's a real, working request, and it's what the tests run.
Where the list shows up
1. The API's own index
GET https://api.hldays.dev/ returns the list as JSON, with the live country list and year range:
curl https://api.hldays.dev/
{
"name": "hldays",
"countries": ["BR", "DE", "GB", "GR", "PT", "US"],
"years": { "min": 2016, "max": 2099 },
"openapi": "/openapi.json",
"endpoints": [
{
"method": "GET",
"path": "/v1/holidays",
"summary": "All holidays that apply in a jurisdiction in a year, including inherited regional and national ones.",
"params": [{ "name": "jurisdiction", "required": true, "description": "Jurisdiction id (e.g. PT-M-1106) or name/alias ..." }],
"example": "/v1/holidays?jurisdiction=lisboa&year=2026"
}
]
}
Someone exploring with curl never has to leave the terminal: the root URL tells them what to call next.
2. The OpenAPI spec
/openapi.json is built from the same list on each request. Two parameters get their values filled in from the data:
parameters: e.params.map((p) =>
p.name === "country" || p.name === "code"
? { ...p, schema: { type: "string", enum: countryCodes } }
: p.name === "traditions"
? { ...p, description: `Comma-separated observance traditions: ${traditionIds.join(", ")}. Default: all.` }
: p,
),
So when a country is added, the country parameter becomes a dropdown with one more option in every tool that reads the spec, without anyone touching the docs. Live, today:
{"name":"country","in":"query","required":true,"description":"ISO 3166-1 alpha-2 code.",
"schema":{"type":"string","enum":["BR","DE","GB","GR","PT","US"]}}
The spec's info.description also explains the plans: what the free plan includes, what a notice means, and that calls straight to api.hldays.dev work without a key.
The spec is OpenAPI 3.0.3 , not 3.1. It started as 3.1; the git history has a commit called "Serve OpenAPI 3.0.3 for broader importer support (RapidAPI)" that changed one line in docs.ts, one in the README and one in a test. Not every importer handles 3.1 yet, and RapidAPI's was the one that mattered.
3. Redoc on hldays.dev/docs
The docs page is Redoc, the open source renderer, loaded from a CDN and pointed at the spec. That's the entire page:
app.get("/docs", (c) =>
c.html(html`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hldays API docs</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body style="margin:0">
<redoc spec-url="/openapi.json" hide-hostname="false"></redoc>
<script src="https://cdn.redoc.ly/redoc/v2.1.5/bundles/redoc.standalone.js"></script>
</body>
</html>`),
);
spec-url is /openapi.json on hldays.dev, not the API's. The site Worker serves a same-origin copy by passing the request through to the API over its service binding, so fetching the spec is never a cross-origin request. The logo in the Redoc sidebar comes from the spec too, via x-logo.
Redoc is read-only: no "try it" button. I'm fine with that for now, because the home page does the trying.
4. The RapidAPI listing
Paid plans are sold through RapidAPI, and its listing is created by importing the OpenAPI spec. Early on, CI pushed the spec to RapidAPI after every deploy (with a curl -X PUT to their provisioning API). I later removed that job, and now I re-import /openapi.json by hand from the RapidAPI dashboard when endpoints change.
It went out with the deploy job, for the same reason: CI stays lean, with fewer secrets and moving parts, and the endpoints rarely change.
The downside is obvious: the listing can lag behind the API until I remember to re-import it.
A test that keeps the docs honest
Because every endpoint has a working example, the test is short:
it("documents exactly the endpoints it serves", async () => {
for (const e of ENDPOINTS.filter((e) => (e.method ?? "GET") === "GET")) expect((await get(e.example)).status, e.example).toBe(200);
const index: any = await (await get("/")).json();
expect(index.endpoints.map((e: any) => e.path)).toEqual(ENDPOINTS.map((e) => e.path));
});
Every documented GET example has to return 200, and the index has to list exactly the registry. Another test checks the spec's paths against the registry and that the country enum equals the countries in the data.
That catches a documented endpoint that's broken or gone, and an example that drifted. While writing this post I noticed it didn't catch the opposite: a route added to index.ts and never documented. Hono keeps its route table in app.routes, so closing the gap was one more test:
it("has a docs entry for every /v1 route it serves", () => {
const served = app.routes.filter((r) => r.method !== "ALL" && (r.path.startsWith("/v1/") || r.path === "/ping"));
const documented = new Set(ENDPOINTS.map((e) => e.path));
expect(served.map((r) => r.path.replace(/:(\w+)/g, "{$1}")).filter((p) => !documented.has(p))).toEqual([]);
});
Hono writes path parameters as :code and OpenAPI as {code}, hence the replace.
The home page demo
The best documentation for "holidays down to the municipality" is seeing your own town. The home page at hldays.dev has a search box: type a place, pick it, and you get that year's holidays with their sources, a business-day checker, and the curl command for the same request:
$("#curl").textContent = 'curl "' + window.HLDAYS.api + "/v1/holidays?jurisdiction=" + body.jurisdiction.id + "&year=" + year + '"';
That line is the bridge from "this looks useful" to "I'm calling it". It uses the id, not the name, so the copied command is unambiguous.
The demo shows municipal holidays, which the free API plan doesn't. For Braga in 2026 the site returns 14 holidays, including São João on 24 June; the free API returns 13 and a notice. The table marks municipal rows with a tag and a line under it: "Rows marked municipal need a paid plan in the API." That's on purpose: the demo shows the product you'd pay for, and says clearly which part is paid.
To keep the demo from being a free back door to the paid data, the site only allows one place and one year at a time, only last year to two years ahead, and rate-limits each visitor. Even its errors point to the API:
curl "https://hldays.dev/lookup/holidays?id=PT-M-0303&year=2099"
{"error":{"code":"invalid_year","message":"The search covers 2025–2028; the API covers 2016–2099"}}
Responses as documentation
Most people don't read docs closely. They make a request and look at what comes back. So the responses explain themselves.
A free-plan request for a municipality carries a notice; one for a date with an observance carries an observances_notice:
curl "https://api.hldays.dev/v1/business-day?jurisdiction=PT&date=2026-03-20"
{
"business_day": true,
"holidays": [],
"observances": [],
"observances_notice": {
"code": "observances_require_paid_plan",
"message": "Religious and cultural observances are not included on the free plan (1 hidden for this date). To include them, subscribe to a paid plan: https://rapidapi.com/...",
"hidden_observances": 1,
"upgrade_url": "https://rapidapi.com/..."
}
}
Each notice has a stable code for programs, a sentence for people, a count of what was hidden, and an upgrade_url. The count is the part I like: "1 hidden for this date" is more convincing than "upgrade for more". On /v1/holidays/on for 24 June in Portugal, the free plan says 34 municipal holidays are hidden.
The URL itself is not hard-coded. It comes from UPGRADE_URL in wrangler.toml, and the code only adds it when it's set:
const upgradeUrl = (c: Context<AppEnv>) => (c.env?.UPGRADE_URL ? { upgrade_url: c.env.UPGRADE_URL } : {});
When I re-created the RapidAPI listing (the old URL spelled it "bussiness-days"), fixing every notice was one line in each Worker's wrangler.toml.
Errors follow the same shape everywhere, {"error": {"code", "message"}}, and the messages say how to fix the request: "Query parameter 'days' must be an integer between -366 and 366", or for an unsupported country, "see /v1/countries".
What I'd still like
- A "try it" console in the docs. For now, the home page search fills that gap.
- Response schemas in the spec. Right now every 200 is documented as application/json with no schema, which is the weakest part of the OpenAPI output. Importers show the parameters well and the responses not at all.
- Generated client snippets in more languages than curl.
It's still too early to know what visitors actually do with the demo or the docs.
What's the first thing you look at when you evaluate an API: the docs, a live demo, or the response of one curl?
Next in the series: shipping and monetising a side-project API, with free vs paid tiers, RapidAPI and rate limits.
hldays.dev · Docs
Are you migrating from Spectral? Redocly just got something to help you with that!
Spectral has been the default OpenAPI linter for years, and it did serve the community well. It made linting API descriptions in CI a normal thing, ensuring quality and consistency across your API documentation. Unfortunately, the recent state of its maintenance has raised security and reliability concerns to the extent that some are forking the project to continue development independently. In August 2026, SmartBear even had to explain the reasons in this blog post that describes how the July 14 supply chain attack reached Spectral.
However, there's a better option than forking Spectral: migrating your linter to Redocly CLI. It was built in the same spirit, but on a different architecture, and it is actively maintained. Let me explain why the architecture matters.
A typical Spectral rule contains the given section, which is intended to describe the target. It usually looks like a cryptic enchantment of JSONPath expressions like this:
$..[?(@ && @.type=="array")]
(and it's not even the hardest one). Can you guess what this means without asking an AI tool? I cannot. As you can imagine, writing and maintaining such rules can be quite challenging and error-prone. This is not a glitch but an architectural flaw: JSONPath matches patterns in a document tree and knows nothing about the specification.
It affects even the built-in rules. The expression above is the target of array-items from the spectral:oas ruleset, which requires every array schema to define its items type. However, it can report false positives where the same properties appear in a different context. Here is a valid OpenAPI description with an example payload:
openapi: 3.1.2
info:
title: Widgets
version: 1.0.0
paths:
/widgets:
get:
responses:
'200':
description: A form field
content:
application/json:
example:
type: array # just data, not a schema
Spectral 6.16.3 reports an error on the last line: the example payload is not a schema, but JSONPath cannot tell the difference. And this is not the only case of possible false positives.
Instead of matching paths, Redocly CLI walks a type tree defined by the specification, so a Schema is always a schema, and $refs are followed into other files. The equivalent rule from the Redocly cookbook is a few lines of YAML that say exactly that: subject Schema, where type is array, require items. It passes the description above and still reports a real type: array schema without items. This makes rules easier to understand, more precise, and less error-prone.
It's easy to add and maintain new API specifications too, and this is one of the reasons Redocly CLI supports the widest range of API standards out of the box.
The performance is also significantly better, which makes Redocly much more suitable for large-scale API projects, especially when you are running your checks in CI/CD pipelines (see OpenAPI linters: which one is the best in a real test?).
And if you've been into API documentation for some time, you probably already know that Redocly CLI has always been the best tool for multi-file setups, which are far easier to maintain than one huge file. So, chances are you already have it installed in your project for bundling API documents prior to other steps. But it also lints the whole API description from the entry point and reports problems in the file where they are, so you don't have to bundle your description before you can check it.
The Migration
If the above sounds convincing, and you've decided to change the linter, it might be an afternoon task if you're using a default ruleset. Check out the official migration guide first if this is the case. However, if you have lots of custom rules, migrating them might be challenging.
As mentioned earlier, reading and understanding the existing Spectral rules is an art form of its own. On the other hand, the Redocly rules system offers you three levels of complexity: built-in, configurable, and plugin rules. So you might ask yourself which type is right for a specific rule. If there's a built-in rule that fits your needs, it's usually the easiest and most efficient choice. If not, configurable rules allow you to define custom criteria without writing any code -- just declarative YAML in your config file. And for the most complex scenarios, plugin rules -- custom code in a plugin -- give you the flexibility to implement any specialized logic you require.
Sounds complicated? It might be at first glance, but it's actually easier than you might think, especially with the new tools Redocly just dropped.
The Skill
If you're using an AI agent, you can leverage Redocly skills, in particular redocly-lint-rules:
npx skills add https://redocly.com
We've built it with efficiency in mind, grounded in metrics. And it works even better with the new Redocly CLI inspect-node-types command, which helps you (or your LLM) explore the node types of your API description in detail, so the rule targets the right node from the start.
We ran sample migrations using different Claude models for the sake of comparison, with and without the skill and the inspect-node-types command.
> <small>Update: While I was working on this article, the repository I took as a migration sample (it's the Azure API Style Guide) was archived precisely because of a migration from Spectral. They actually decided to go with TypeSpec, but it's curious that they were also considering migration around the same time.</small>
The table lists the percentage of the rules that were migrated correctly.
| Model | Bare LLM | Skill alone | Skill and Command |
| --------- | -------- | ----------- | ----------------- |
| Haiku 4.5 | 22% | 27% | 31% |
| Sonnet 5 | 75% | 82% | 80% |
| Opus 5 | 93% | 97% | 98% |
From the figures, it's clear that Haiku was not capable of migrating the rules whether used with the skill or not. Sonnet performed a lot better, especially with the skill and the command, exploring the node types instead of guessing and retrying, but still required manual intervention in 1 of 5 cases.
The most interesting case was Opus, which showed 93% correctness even as a bare LLM. However, the quality of the rules written matters as much as the count. Built-in and configurable rules are pure YAML, whereas plugins are custom JavaScript you have to write and maintain. The chart breaks the correct rules down by type.
!AI models performance breakdown
Without the skill, the model wrote three-quarters of the correct rules as plugins. With the skill and the command, that share dropped to a quarter, so you have far less custom code to maintain.
Also, the skill actually improved around 10% of the source rules while migrating them: the resulting rules catch cases the original rules missed. This is no surprise, given how hard it is to comprehend the Spectral targeting, even for the rule authors.
Based on the impact, we baked the inspect-node-types command into the new skill so you don't have to worry about it at all -- an agent will call it when needed.
> <small>Update: While I was finishing this article, Anthropic released Opus 5.5 and Sonnet 5.5, so I re-ran the same migration with them. I didn't go through the results in the same detail, but at first glance Opus 5.5 performed about the same as Opus 5, while Sonnet 5.5 did much better and got close to Opus. The skill had the same effect as before: fewer plugins, more configurable rules.</small>
Visual Builder
Still, there might always be some corner cases that require manual intervention to ensure the rules are correctly constructed. So, if you want to tune the rules, or you just want to create one manually, Redocly just shipped a visual builder for configurable rules.
It helps you construct a rule by playing with your specific API description, so you can instantly see which nodes it matches. It is kind of similar to using Redocly's VS Code extension, where you can inspect the targets simply by hovering over them in the editor, but it lets you write a rule without even touching the keyboard.
What's next
The new redocly-lint-rules skill and visual builder are intended not only for migration. More importantly, they can help you write better rules on top of Redocly's default presets, which is the intended way to use a linter -- your API style guide should reflect your team's needs. The quality of rules matters: the skill ensures not only their correctness but also that the setup is optimized for maintenance. And if you are migrating from another linter, chances are that you can even improve the existing rules along the way.
Disclaimer: I'm a Redocly CLI maintainer
15 reactions
Mentions
Building hldays #10: Docs that sell the API --- title: Building hldays #10: Docs that sell the API published: true date: 2026-11-2 09:00:00 UTC tags: api,openapi,documentation,webdev canonical_url: hldays.dev --- For a paid API, the docs are the shop window. Someone lands on the page, tries one thing, and decides in a couple of minutes whether to keep going. So I wanted the docs to be correct (generated, not hand-written), easy to try, and present inside the API itself, not only on a website. This post is about how that's wired up in hldays: one list of endpoints, four places it shows up, a live demo on the home page, and responses that tell you what you're missing. One list of endpoints Every endpoint is described once, in src/docs.ts: /** One documented endpoint. The index page, OpenAPI spec and route tests are all derived from this list. */ export interface Endpoint { method?: "GET" | "POST"; path: string; summary: string; params: Param[]; example: string; } Parameters that repeat across endpoints are defined once and reused, so the wording of jurisdiction is the same everywhere: const jurisdiction = q("jurisdiction", true, "Jurisdiction id (e.g. PT-M-1106) or name/alias (e.g. lisboa, Lisbon). Names prefer the most specific level."); const includeSources = q("include_sources", false, "Include source/quote on each holiday (default true). false gives much smaller responses.", { type: "boolean" }); { path: "/v1/holidays", summary: "All holidays that apply in a jurisdiction in a year, including inherited regional and national ones.", params: [jurisdiction, q("year", false, `Year ${MIN_YEAR}-${MAX_YEAR}. Default: current year.`, { type: "integer", minimum: MIN_YEAR, maximum: MAX_YEAR }), countryHint, types, includeSources], example: "/v1/holidays?jurisdiction=lisboa&year=2026", }, The year range comes from the same MIN_YEAR and MAX_YEAR constants the engine uses to reject a bad year, so the docs can't promise a range the API doesn't serve. The example field matters more than it looks. It's a real, working request, and it's what the tests run. Where the list shows up 1. The API's own index GET https://api.hldays.dev/ returns the list as JSON, with the live country list and year range: curl https://api.hldays.dev/ { "name": "hldays", "countries": ["BR", "DE", "GB", "GR", "PT", "US"], "years": { "min": 2016, "max": 2099 }, "openapi": "/openapi.json", "endpoints": [ { "method": "GET", "path": "/v1/holidays", "summary": "All holidays that apply in a jurisdiction in a year, including inherited regional and national ones.", "params": [{ "name": "jurisdiction", "required": true, "description": "Jurisdiction id (e.g. PT-M-1106) or name/alias ..." }], "example": "/v1/holidays?jurisdiction=lisboa&year=2026" } ] } Someone exploring with curl never has to leave the terminal: the root URL tells them what to call next. 2. The OpenAPI spec /openapi.json is built from the same list on each request. Two parameters get their values filled in from the data: parameters: e.params.map((p) => p.name === "country" || p.name === "code" ? { ...p, schema: { type: "string", enum: countryCodes } } : p.name === "traditions" ? { ...p, description: `Comma-separated observance traditions: ${traditionIds.join(", ")}. Default: all.` } : p, ), So when a country is added, the country parameter becomes a dropdown with one more option in every tool that reads the spec, without anyone touching the docs. Live, today: {"name":"country","in":"query","required":true,"description":"ISO 3166-1 alpha-2 code.", "schema":{"type":"string","enum":["BR","DE","GB","GR","PT","US"]}} The spec's info.description also explains the plans: what the free plan includes, what a notice means, and that calls straight to api.hldays.dev work without a key. The spec is OpenAPI 3.0.3 , not 3.1. It started as 3.1; the git history has a commit called "Serve OpenAPI 3.0.3 for broader importer support (RapidAPI)" that changed one line in docs.ts, one in the README and one in a test. Not every importer handles 3.1 yet, and RapidAPI's was the one that mattered. 3. Redoc on hldays.dev/docs The docs page is Redoc, the open source renderer, loaded from a CDN and pointed at the spec. That's the entire page: app.get("/docs", (c) => c.html(html`<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>hldays API docs</title> <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> </head> <body style="margin:0"> <redoc spec-url="/openapi.json" hide-hostname="false"></redoc> <script src="https://cdn.redoc.ly/redoc/v2.1.5/bundles/redoc.standalone.js"></script> </body> </html>`), ); spec-url is /openapi.json on hldays.dev, not the API's. The site Worker serves a same-origin copy by passing the request through to the API over its service binding, so fetching the spec is never a cross-origin request. The logo in the Redoc sidebar comes from the spec too, via x-logo. Redoc is read-only: no "try it" button. I'm fine with that for now, because the home page does the trying. 4. The RapidAPI listing Paid plans are sold through RapidAPI, and its listing is created by importing the OpenAPI spec. Early on, CI pushed the spec to RapidAPI after every deploy (with a curl -X PUT to their provisioning API). I later removed that job, and now I re-import /openapi.json by hand from the RapidAPI dashboard when endpoints change. It went out with the deploy job, for the same reason: CI stays lean, with fewer secrets and moving parts, and the endpoints rarely change. The downside is obvious: the listing can lag behind the API until I remember to re-import it. A test that keeps the docs honest Because every endpoint has a working example, the test is short: it("documents exactly the endpoints it serves", async () => { for (const e of ENDPOINTS.filter((e) => (e.method ?? "GET") === "GET")) expect((await get(e.example)).status, e.example).toBe(200); const index: any = await (await get("/")).json(); expect(index.endpoints.map((e: any) => e.path)).toEqual(ENDPOINTS.map((e) => e.path)); }); Every documented GET example has to return 200, and the index has to list exactly the registry. Another test checks the spec's paths against the registry and that the country enum equals the countries in the data. That catches a documented endpoint that's broken or gone, and an example that drifted. While writing this post I noticed it didn't catch the opposite: a route added to index.ts and never documented. Hono keeps its route table in app.routes, so closing the gap was one more test: it("has a docs entry for every /v1 route it serves", () => { const served = app.routes.filter((r) => r.method !== "ALL" && (r.path.startsWith("/v1/") || r.path === "/ping")); const documented = new Set(ENDPOINTS.map((e) => e.path)); expect(served.map((r) => r.path.replace(/:(\w+)/g, "{$1}")).filter((p) => !documented.has(p))).toEqual([]); }); Hono writes path parameters as :code and OpenAPI as {code}, hence the replace. The home page demo The best documentation for "holidays down to the municipality" is seeing your own town. The home page at hldays.dev has a search box: type a place, pick it, and you get that year's holidays with their sources, a business-day checker, and the curl command for the same request: $("#curl").textContent = 'curl "' + window.HLDAYS.api + "/v1/holidays?jurisdiction=" + body.jurisdiction.id + "&year=" + year + '"'; That line is the bridge from "this looks useful" to "I'm calling it". It uses the id, not the name, so the copied command is unambiguous. The demo shows municipal holidays, which the free API plan doesn't. For Braga in 2026 the site returns 14 holidays, including São João on 24 June; the free API returns 13 and a notice. The table marks municipal rows with a tag and a line under it: "Rows marked municipal need a paid plan in the API." That's on purpose: the demo shows the product you'd pay for, and says clearly which part is paid. To keep the demo from being a free back door to the paid data, the site only allows one place and one year at a time, only last year to two years ahead, and rate-limits each visitor. Even its errors point to the API: curl "https://hldays.dev/lookup/holidays?id=PT-M-0303&year=2099" {"error":{"code":"invalid_year","message":"The search covers 2025–2028; the API covers 2016–2099"}} Responses as documentation Most people don't read docs closely. They make a request and look at what comes back. So the responses explain themselves. A free-plan request for a municipality carries a notice; one for a date with an observance carries an observances_notice: curl "https://api.hldays.dev/v1/business-day?jurisdiction=PT&date=2026-03-20" { "business_day": true, "holidays": [], "observances": [], "observances_notice": { "code": "observances_require_paid_plan", "message": "Religious and cultural observances are not included on the free plan (1 hidden for this date). To include them, subscribe to a paid plan: https://rapidapi.com/...", "hidden_observances": 1, "upgrade_url": "https://rapidapi.com/..." } } Each notice has a stable code for programs, a sentence for people, a count of what was hidden, and an upgrade_url. The count is the part I like: "1 hidden for this date" is more convincing than "upgrade for more". On /v1/holidays/on for 24 June in Portugal, the free plan says 34 municipal holidays are hidden. The URL itself is not hard-coded. It comes from UPGRADE_URL in wrangler.toml, and the code only adds it when it's set: const upgradeUrl = (c: Context<AppEnv>) => (c.env?.UPGRADE_URL ? { upgrade_url: c.env.UPGRADE_URL } : {}); When I re-created the RapidAPI listing (the old URL spelled it "bussiness-days"), fixing every notice was one line in each Worker's wrangler.toml. Errors follow the same shape everywhere, {"error": {"code", "message"}}, and the messages say how to fix the request: "Query parameter 'days' must be an integer between -366 and 366", or for an unsupported country, "see /v1/countries". What I'd still like - A "try it" console in the docs. For now, the home page search fills that gap. - Response schemas in the spec. Right now every 200 is documented as application/json with no schema, which is the weakest part of the OpenAPI output. Importers show the parameters well and the responses not at all. - Generated client snippets in more languages than curl. It's still too early to know what visitors actually do with the demo or the docs. What's the first thing you look at when you evaluate an API: the docs, a live demo, or the response of one curl? Next in the series: shipping and monetising a side-project API, with free vs paid tiers, RapidAPI and rate limits. hldays.dev · Docs
Upgrading from 2.x? The bundle is an ES module, the theme option is gone, and anonymous usage telemetry is on by default (disableTelemetry turns it off). Migration guide: redocly.com Demo: redocly.github.io Code: Redocly/redoc
Are you migrating from Spectral? Redocly just got something to help you with that! Spectral has been the default OpenAPI linter for years, and it did serve the community well. It made linting API descriptions in CI a normal thing, ensuring quality and consistency across your API documentation. Unfortunately, the recent state of its maintenance has raised security and reliability concerns to the extent that some are forking the project to continue development independently. In August 2026, SmartBear even had to explain the reasons in this blog post that describes how the July 14 supply chain attack reached Spectral. However, there's a better option than forking Spectral: migrating your linter to Redocly CLI. It was built in the same spirit, but on a different architecture, and it is actively maintained. Let me explain why the architecture matters. A typical Spectral rule contains the given section, which is intended to describe the target. It usually looks like a cryptic enchantment of JSONPath expressions like this: $..[?(@ && @.type=="array")] (and it's not even the hardest one). Can you guess what this means without asking an AI tool? I cannot. As you can imagine, writing and maintaining such rules can be quite challenging and error-prone. This is not a glitch but an architectural flaw: JSONPath matches patterns in a document tree and knows nothing about the specification. It affects even the built-in rules. The expression above is the target of array-items from the spectral:oas ruleset, which requires every array schema to define its items type. However, it can report false positives where the same properties appear in a different context. Here is a valid OpenAPI description with an example payload: openapi: 3.1.2 info: title: Widgets version: 1.0.0 paths: /widgets: get: responses: '200': description: A form field content: application/json: example: type: array # just data, not a schema Spectral 6.16.3 reports an error on the last line: the example payload is not a schema, but JSONPath cannot tell the difference. And this is not the only case of possible false positives. Instead of matching paths, Redocly CLI walks a type tree defined by the specification, so a Schema is always a schema, and $refs are followed into other files. The equivalent rule from the Redocly cookbook is a few lines of YAML that say exactly that: subject Schema, where type is array, require items. It passes the description above and still reports a real type: array schema without items. This makes rules easier to understand, more precise, and less error-prone. It's easy to add and maintain new API specifications too, and this is one of the reasons Redocly CLI supports the widest range of API standards out of the box. The performance is also significantly better, which makes Redocly much more suitable for large-scale API projects, especially when you are running your checks in CI/CD pipelines (see OpenAPI linters: which one is the best in a real test?). And if you've been into API documentation for some time, you probably already know that Redocly CLI has always been the best tool for multi-file setups, which are far easier to maintain than one huge file. So, chances are you already have it installed in your project for bundling API documents prior to other steps. But it also lints the whole API description from the entry point and reports problems in the file where they are, so you don't have to bundle your description before you can check it. The Migration If the above sounds convincing, and you've decided to change the linter, it might be an afternoon task if you're using a default ruleset. Check out the official migration guide first if this is the case. However, if you have lots of custom rules, migrating them might be challenging. As mentioned earlier, reading and understanding the existing Spectral rules is an art form of its own. On the other hand, the Redocly rules system offers you three levels of complexity: built-in, configurable, and plugin rules. So you might ask yourself which type is right for a specific rule. If there's a built-in rule that fits your needs, it's usually the easiest and most efficient choice. If not, configurable rules allow you to define custom criteria without writing any code -- just declarative YAML in your config file. And for the most complex scenarios, plugin rules -- custom code in a plugin -- give you the flexibility to implement any specialized logic you require. Sounds complicated? It might be at first glance, but it's actually easier than you might think, especially with the new tools Redocly just dropped. The Skill If you're using an AI agent, you can leverage Redocly skills, in particular redocly-lint-rules: npx skills add https://redocly.com We've built it with efficiency in mind, grounded in metrics. And it works even better with the new Redocly CLI inspect-node-types command, which helps you (or your LLM) explore the node types of your API description in detail, so the rule targets the right node from the start. We ran sample migrations using different Claude models for the sake of comparison, with and without the skill and the inspect-node-types command. > <small>Update: While I was working on this article, the repository I took as a migration sample (it's the Azure API Style Guide) was archived precisely because of a migration from Spectral. They actually decided to go with TypeSpec, but it's curious that they were also considering migration around the same time.</small> The table lists the percentage of the rules that were migrated correctly. | Model | Bare LLM | Skill alone | Skill and Command | | --------- | -------- | ----------- | ----------------- | | Haiku 4.5 | 22% | 27% | 31% | | Sonnet 5 | 75% | 82% | 80% | | Opus 5 | 93% | 97% | 98% | From the figures, it's clear that Haiku was not capable of migrating the rules whether used with the skill or not. Sonnet performed a lot better, especially with the skill and the command, exploring the node types instead of guessing and retrying, but still required manual intervention in 1 of 5 cases. The most interesting case was Opus, which showed 93% correctness even as a bare LLM. However, the quality of the rules written matters as much as the count. Built-in and configurable rules are pure YAML, whereas plugins are custom JavaScript you have to write and maintain. The chart breaks the correct rules down by type. !AI models performance breakdown Without the skill, the model wrote three-quarters of the correct rules as plugins. With the skill and the command, that share dropped to a quarter, so you have far less custom code to maintain. Also, the skill actually improved around 10% of the source rules while migrating them: the resulting rules catch cases the original rules missed. This is no surprise, given how hard it is to comprehend the Spectral targeting, even for the rule authors. Based on the impact, we baked the inspect-node-types command into the new skill so you don't have to worry about it at all -- an agent will call it when needed. > <small>Update: While I was finishing this article, Anthropic released Opus 5.5 and Sonnet 5.5, so I re-ran the same migration with them. I didn't go through the results in the same detail, but at first glance Opus 5.5 performed about the same as Opus 5, while Sonnet 5.5 did much better and got close to Opus. The skill had the same effect as before: fewer plugins, more configurable rules.</small> Visual Builder Still, there might always be some corner cases that require manual intervention to ensure the rules are correctly constructed. So, if you want to tune the rules, or you just want to create one manually, Redocly just shipped a visual builder for configurable rules. It helps you construct a rule by playing with your specific API description, so you can instantly see which nodes it matches. It is kind of similar to using Redocly's VS Code extension, where you can inspect the targets simply by hovering over them in the editor, but it lets you write a rule without even touching the keyboard. What's next The new redocly-lint-rules skill and visual builder are intended not only for migration. More importantly, they can help you write better rules on top of Redocly's default presets, which is the intended way to use a linter -- your API style guide should reflect your team's needs. The quality of rules matters: the skill ensures not only their correctness but also that the setup is optimized for maintenance. And if you are migrating from another linter, chances are that you can even improve the existing rules along the way. Disclaimer: I'm a Redocly CLI maintainer