hldays

Building hldays #10: Docs that sell the API

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