Building hldays #11: Shipping and monetising a side-project API
Building the data was the fun part. Deciding what to charge for, and wiring that into the API, was a different kind of problem.
This post covers how hldays splits free from paid, how the plan check works in code, why paid plans go through RapidAPI for now, and what I'd like to change later.
Why charge at all? Mostly to find out whether verified municipal data is worth money to anyone besides me. A price is the most honest way to ask.
What's free and what's paid
The rule I settled on is simple:
- Free: national and regional holidays, and business-day maths computed from them.
- Paid: municipal holidays and religious/cultural observances.
The reasoning is about where the work is. National holidays for most countries are easy to find, and other APIs already give them away. Hiding them behind a paywall would make hldays worse than the alternatives for no good reason.
The municipal layer is different. For Portugal, that's 308 municipal holidays, 291 of them verified against an official source with a quote and a date. Nobody else publishes that as data, and it took the most effort by far. That's the part worth paying for.
Observances (Ramadan, Passover, Chinese New Year, Orthodox Easter and so on) went into the paid layer for a similar reason: getting them right took real work, like replacing ICU's Chinese calendar with Hong Kong Observatory tables. They never affect business days, so leaving them out doesn't make the free answers wrong.
How a request gets a plan
Everything under /v1/* goes through one Hono middleware that decides the plan before any route runs. Trimmed from src/index.ts:
const FREE_PLANS = ["BASIC"];
app.use("/v1/*", async (c, next) => {
if (c.env?.SITE_CALLER) {
c.set("plan", "paid");
return next();
}
const secret = c.env?.RAPIDAPI_PROXY_SECRET;
const proxied = c.req.header("X-RapidAPI-Proxy-Secret");
if (secret && proxied === undefined) {
c.set("plan", "free");
// ...per-IP rate limit, see below
} else {
if (secret && proxied !== secret) throw new ApiError(401, "unauthorized", "Missing or invalid credentials");
c.set("plan", FREE_PLANS.includes((c.req.header("X-RapidAPI-Subscription") ?? "").toUpperCase()) ? "free" : "paid");
}
await next();
});
There are four cases:
- The hldays.dev site calls the API through a Cloudflare service binding, which sets
SITE_CALLER. A request from outside can't set it. The site gets full data, but only exposes single-place lookups and rate-limits visitors itself. - RapidAPI forwards requests with a shared proxy secret (
X-RapidAPI-Proxy-Secret) and the caller's plan inX-RapidAPI-Subscription.BASICis free, anything else is paid. - A wrong secret gets a 401. Someone is guessing, or something is misconfigured.
- No secret at all means a direct, anonymous caller on
api.hldays.dev: free plan, whatever plan header they send.
Case 4 is the one that's easy to get wrong. The plan header is just a header: anyone can send X-RapidAPI-Subscription: PRO with curl. It only means something when it arrives together with the right proxy secret. A test in test/api.test.ts checks exactly that, using Lisbon's municipal holiday (13 June) as the marker:
const env = { RAPIDAPI_PROXY_SECRET: "s3cret", UPGRADE_URL: "https://example.com/pricing" };
const body: any = await (await get("/v1/holidays?jurisdiction=lisboa&year=2026", env, { ...ip, "X-RapidAPI-Subscription": "PRO" })).json();
expect(body.data.map((h: any) => h.date)).not.toContain("2026-06-13");
...
const proxied: any = await (await get("/v1/holidays?jurisdiction=lisboa&year=2026", env, { "X-RapidAPI-Proxy-Secret": "s3cret", "X-RapidAPI-Subscription": "PRO" })).json();
expect(proxied.data.map((h: any) => h.date)).toContain("2026-06-13");
Same request, same "PRO" header: without the secret, no municipal holiday; with it, there it is.
Locally and in tests the secret isn't set, so the plan header alone decides, and no header means full access. That keeps most tests short: app.request(url, { headers: { "X-RapidAPI-Subscription": "BASIC" } }) is all it takes to test the free path.
Gating the data, not the endpoints
Most endpoints exist on both plans. The free plan gets the same routes, with the municipal layer filtered out. The routes ask one question:
const paidPlan = (c: Context) => c.get("plan") === "paid";
and pass the answer down to the engine, so business days are also computed without municipal holidays on the free plan. Here's /v1/holidays:
const municipal = paidPlan(c);
return c.json({
jurisdiction: summary(j),
year,
data: withSources(c)(calendar.holidays(j, year, types(c), municipal)),
...(!municipal && j.level === "municipality" && { notice: municipalNotice(c) }),
});
The observance endpoints are the exception: /v1/observances and /v1/observances/on return 403 paid_plan_required on the free plan. On the date endpoints, observances come back as an empty list plus a notice saying how many were hidden.
Notices instead of silence
The part I'm happiest with: the free plan never hides data silently. If you ask for Braga without a key, you get the 13 national holidays for 2027 and a notice:
curl "https://api.hldays.dev/v1/holidays?jurisdiction=PT-M-0303&year=2027&include_sources=false"
{
"jurisdiction": { "id": "PT-M-0303", "name": "Braga", "level": "municipality", "country": "PT", "parent": "PT-03" },
"year": 2027,
"data": [
{ "date": "2027-01-01", "holiday_id": "ano-novo", "name": { "pt": "Ano Novo", "en": "New Year's Day" }, "type": "public", "status": "verified", ... },
...
],
"notice": {
"code": "municipal_requires_paid_plan",
"message": "Municipal holidays are not included on the free plan. To include them, subscribe to a paid plan: https://rapidapi.com/...",
"upgrade_url": "https://rapidapi.com/altofire93/api/hldays-a-municipal-holidays-business-days/pricing"
}
}
On a paid plan the same call adds one more record, Braga's own holiday:
{ "date": "2027-06-24", "holiday_id": "feriado-municipal", "name": { "pt": "São João Baptista" }, "type": "local", "status": "verified",
"jurisdiction": { "id": "PT-M-0303", "name": "Braga", "level": "municipality" } }
/v1/holidays/on goes one step further and counts what it hid. Asking who is off in Portugal on 24 June 2027:
curl "https://api.hldays.dev/v1/holidays/on?date=2027-06-24&country=PT&include_sources=false"
{
"date": "2027-06-24",
"country": "PT",
"data": [],
"observances": [],
"notice": {
"code": "municipal_requires_paid_plan",
"message": "Municipal holidays are not included on the free plan (34 hidden for this request). ...",
"hidden_municipal_holidays": 34,
"upgrade_url": "https://rapidapi.com/..."
}
}
An empty list with "34 hidden" is honest, and it's the best upsell I could think of. A developer testing the free plan sees exactly what they're missing. The upgrade_url comes from a [vars] entry in wrangler.toml, so it can change without touching code.
RapidAPI
Paid plans are only sold through RapidAPI today. The plans, from the README:
| Plan | Price | Requests/month | Rate limit |
|---|---|---|---|
| BASIC | free | 1,000 | 30/min |
| PRO | $9/month | 50,000 | 10/s |
| ULTRA | $29/month | 250,000 | 25/s |
Quotas are hard limits, no overage. RapidAPI handles keys, billing, quotas and the marketplace listing. I handle the data. The listing is built by importing /openapi.json, which is why the API serves OpenAPI 3.0.3 rather than 3.1: RapidAPI's importer handles 3.0 better.
The trade-offs are real, though:
- RapidAPI keeps 25% of revenue.
- It owns the customer. I never see an email address, so I can't ask a subscriber what they're using it for.
- The listing is updated by hand. I tried syncing it from CI after every deploy, then removed that the same day. Re-importing
/openapi.jsonwhen endpoints change is fine at this size.
I picked RapidAPI for the first version because it meant no billing, VAT or key management to build, so I could sell from day one. Setting it up was quick: import the spec, define the plans, set the proxy secret.
The typo I couldn't fix
The first listing's title had "Bussiness" in it, two s's, and the URL built from it ended in hldays-a-municipal-holidays-bussiness-days. RapidAPI wouldn't let me change the title of the existing listing.
So I re-created the listing with the right spelling, and changed UPGRADE_URL and the pricing link on the site to the new URL. That was easy because both live in config, in one place each. The lesson: read the title three times before you create a listing on a marketplace.
It was early enough that nothing was lost: there were no subscribers or reviews to move.
The anonymous tier
At first, any request without the proxy secret got a 401. That meant you couldn't even try the API without signing up on RapidAPI first, which is a lot to ask from someone who just wants to see if Lisbon's holiday is on 13 June.
Now api.hldays.dev answers anonymous callers on the free plan, limited to 30 requests per minute per IP. That uses Cloudflare's Workers rate-limiting binding, configured in wrangler.toml:
[[ratelimits]]
name = "ANON_LIMITER"
namespace_id = "1001"
simple = { limit = 30, period = 60 }
and checked in the middleware, keyed on the caller's IP:
const key = c.req.header("CF-Connecting-IP") ?? "unknown";
if (!(await c.env.ANON_LIMITER.limit({ key })).success) {
c.header("Retry-After", "60");
throw new ApiError(429, "rate_limited", "Free tier rate limit reached; try again in a minute." + upgradeHint(c, "For higher limits"));
}
The test for it uses a fake limiter that always says no, and checks what must still get through:
const full = limiter(false);
const env = { RAPIDAPI_PROXY_SECRET: "s3cret", ANON_LIMITER: full };
const res = await get("/v1/countries", env, ip);
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
expect((await get("/v1/countries", env, { "X-RapidAPI-Proxy-Secret": "s3cret" })).status).toBe(200);
expect((await get("/ping", env, ip)).status).toBe(200);
expect(full.keys).toHaveLength(1);
The last line is the interesting one: the limiter was asked exactly once. RapidAPI traffic and /ping never touch it, so a paying customer can't be throttled by the anonymous limit.
A few things I learned here:
- The period can only be 10 or 60 seconds. There's no "1,000 per month" with this binding. A monthly quota needs a counter somewhere, which is part of the API key plan below.
- The MCP endpoint isn't counted itself. An MCP tool call makes internal REST calls, and those are what get counted. Otherwise one agent request would count twice.
- Per-IP limits are rough. Everyone behind the same office NAT shares 30 requests a minute. For trying things out that's fine. For production use, people should get a key (today, a RapidAPI one, BASIC included).
Not everything is gated. /, /ping, /openapi.json and the brand assets stay public, so health checks and doc tools always work.
What I'd like to do next: selling directly
The plan in docs/plan-site.md is to move step by step away from depending only on RapidAPI, without dropping it:
- API keys on hldays.dev. Sign in (Supabase Auth, used for login only), create up to five keys, see usage. Keys and usage live on Cloudflare (D1 and KV), so if the auth provider has a problem, only sign-in breaks, not API calls. A free key would get the free data with higher limits than anonymous.
- Plan resolution grows one more branch.
Authorization: Bearer hld_...is checked first, then the site, then RapidAPI, then anonymous. - Later, paid plans on my own keys, through a merchant of record (Paddle, Lemon Squeezy or Polar) that collects and files VAT, with RapidAPI kept in parallel at similar prices.
None of that is built yet. Selling directly means terms, a refund policy and some business paperwork first, and it only makes sense once the site has traffic and keys have users.
As for traction: there are no paying subscribers yet.
Trade-offs
- The free/paid split is a guess. If almost nobody needs municipal data, the paid layer is a niche of a niche.
- 25% is a lot, but so is building billing, VAT handling and key management for a side project with few customers.
- Gating by filtering means the free plan can give business-day answers that are wrong for a specific municipality. The notice says so, but a client that ignores notices won't know.
If you've sold an API, through a marketplace or directly: what made you switch, or stay? I'd like to hear in the comments.
Next in the series: adding a new country, and what it takes to go from 6 to 7.