Back to engineering

Engineering

API versions named by date, and limits on every answer

The Bookatu engineering team7 min read

Release 15 gives the Bookatu API dated versions and rate-limit headers. Every key is pinned to a version, a call with no key and no header gets the oldest version still served, and a deprecated version will say so on every answer. Here is the order a version is chosen in, the headers, and why a call the route refuses is now taken back off the key's count.

The short version

  • Versions are named for the day they are released. The first is 2026-10-30, and today it is the only one.
  • A call is answered in the Bookatu-Version it asks for, else the version its key or connected app is pinned to, else the oldest version still supported.
  • Every key and app made before versions existed is pinned to 2026-10-30. A new one is pinned to the version current when it is made.
  • A deprecated version's answers will carry Deprecation, Sunset and a Link to the changelog. No version is deprecated yet.
  • Unless the limiter is switched off, answers carry the RateLimit headers, and every 429 carries Retry-After.
  • A call the route refuses before its own limiter is taken back off the key's count.

An API other people build on has to change without breaking them. Release 15 adds two things to its answers to make that possible: the version each was answered in, and the limits the call is held to.

Named for the day

A version is a date, YYYY-MM-DD, the day it is released. A date sorts, needs no explanation, and tells you how old your integration is. The first, 2026-10-30, is the REST API, the MCP server and webhooks as they stood the day versions arrived. A version id must be a real calendar date, so 2026-13-01 is refused like any unknown version.

The registry is a plain list in lib/api-versions.ts, and a test holds it to the published policy: one current version, oldest first, each named for its release day, supported at least 12 months after the next one ships, with its sunset announced at least 6 months ahead.

Which version a call gets

ts
export function resolveApiVersion(input: { header: string | null | undefined; credential: string | null | undefined }): ResolvedApiVersion {
  const asked: string = (input.header ?? "").trim();
  if (asked) {
    if (isApiVersionId(asked) && isSupportedApiVersion(asked)) return { ok: true, version: asked, source: "header" };
    // ...400 UNSUPPORTED_API_VERSION, naming the supported versions
  }
  if (isSupportedApiVersion(input.credential)) return { ok: true, version: input.credential, source: "credential" };
  return { ok: true, version: oldestSupportedVersion(), source: "default" };
}

The header wins, so a developer can try a newer version on one call before moving. Without it, the credential's pin decides. The pin is written when a key is made or a connected app approved, and survives a key rotation and a token refresh. Migration 0186 gave the column a default of 2026-10-30, so every existing key is pinned to the version that describes what it was already getting.

A header naming anything else is refused before the handler runs: 400 UNSUPPORTED_API_VERSION, with the supported versions. Every answer says the version it was answered in, in Bookatu-Version.

Why the oldest, not the newest

A call with no header and no key is one to the keyless doors, such as services, times and booking. There is nothing to pin. If it got the newest version, every one of those front ends would change under its owner the day a breaking version shipped. So it gets the oldest version still served, the behaviour it was most likely written against, and moves only when that version is switched off.

A key pinned to a version since switched off is answered in the oldest one still supported, not refused. Refusing it would turn a sunset into an outage.

Deprecation, built before it is needed

A version on its way out will say so on every answer: Deprecation, an @ and a Unix time (RFC 9745); Sunset, an HTTP date (RFC 8594); and a Link to its changelog entry.

ts
export function deprecationHeaders(versionId: string): Record<string, string> {
  const v = findApiVersion(versionId);
  if (!v || v.status !== "deprecated" || !v.deprecatedAt) return {};
  const out: Record<string, string> = {
    Deprecation: `@${Math.floor(dayMs(v.deprecatedAt) / 1000)}`,
    Link: `<${changelogUrl(v.id)}>; rel="deprecation"; type="text/html"`,
  };
  if (v.sunsetAt) out.Sunset = new Date(dayMs(v.sunsetAt)).toUTCString();
  return out;
}

Today the registry holds one version and it is current, so no answer carries these yet. We built them anyway, because the first deprecation is the worst moment to find a bug in the plumbing. Tests swap in a registry with a deprecated version, check the exact strings, and check through the real envelope that a Link a route already sends is kept.

Limits on every answer

The limiter is older than release 15: a fixed window in Postgres, one atomic upsert per window, so it holds across serverless instances. What is new is that every answer says where the caller stands, across every window the call was counted against.

apiFinalize, the hook that runs last on every call, takes every window the call was counted against. It reports the one closest to its limit in RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, and all of them in RateLimit-Policy, shortest first: 30;w=60, 2000;w=86400 for a key on the Solo plan. A 429 always carries Retry-After. The names are the IETF draft fields the limiter already sent, so no client reading them breaks. The limiter enforces by default, API_RL_MODE can set it to log or off, and it fails open if its own database call fails.

Each key now has its own windows at its plan's numbers, beside its business's windows for all keys together. A Bookatu platform admin can put a live key on the raised tier, four times its plan's numbers, at its own business only. A test key gets the sandbox ceilings, lower than the cheapest plan, whatever its tier.

Giving a refusal back

To hold each key to its own windows, the platform now counts the key before the route runs. Before release 15, a route checked the key's scope and business first and reached its limiter only after, so a call refused for a missing scope cost nothing. Counted up front, it did. Review found the case: an integration making 15 mis-scoped calls a minute beside 20 good ones sat under its Solo plan's 30. Its 31st call of the minute would have been a 429, in a release whose changelog says a standard key sees no change. The hook now gives such a call back:

ts
async function uncountRefused(state: PlatformCallState, res: Response, windows: RateLimitObservation[]): Promise<RateLimitObservation[]> {
  // Only ever set when the preflight counted the key (never with API_RL_MODE=off).
  const keyId = state.countedKeyId;
  if (!keyId || state.limiterReached || res.status < 400 || res.status === 429) return windows;
  await uncountApiWindows([`rl:k:${keyId}:min`, `rl:k:${keyId}:day`]);
  return windows.map((w) =>
    w.policy === RATE_POLICY.keyMinute || w.policy === RATE_POLICY.keyDay ? { ...w, remaining: w.remaining + 1, breached: w.remaining + 1 < 0 } : w,
  );
}

The route's own limiter sets limiterReached the moment it runs. So a refusal before it is given back, a refusal after it still counts, as it always did, and a 429 is never given back. uncountApiWindows only touches a window that has not reset, and never goes below zero.

tests/integration/r15-final-rate-limits.test.ts makes 15 refused calls, then 20 good ones in the same minute, with a Solo key. All 20 answer, and the key's minute window reads 20.

Three smaller things review found

  • MCP answers sent before the server's own limiter, such as a parse error, carried no RateLimit headers. They now report the key's windows, read and not counted.
  • A raised key at its own business that the public cannot see was held to the standard ceiling. Its own business is now the URL's business, seen or not.
  • A 429 from a counter with no policy name, such as sandboxes made per person, reported another window's headers. Those counters have names now.

What we take from it

  • Pin every credential the day it is made, and give what cannot be pinned the oldest behaviour still served.
  • Build the deprecation headers before the first deprecation, and test them with a registry you make up.
  • When you move a counter earlier, give back what the old order never charged.
  • A changelog line that says nothing changes is a claim. Write the test that holds you to it.
apiversioningrate limitinghttpengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs