Skip to content
Octroi

Releases and signatures

Release ids, what a release holds, the signed manifest, checking its Ed25519 signature and file hashes, and running the conformance vectors.

Octroi publishes the data as releases. A release is a directory of files with a manifest that names each one by hash, and an Ed25519 signature over that manifest. Check the signature and the hashes, and you know you have the files Octroi published, unchanged.

Release ids

A release is named by the date it was built, YYYY-MM-DD, such as 2026-10-11. A second release on the same day adds a suffix: 2026-10-11.1, then 2026-10-11.2. Releases order by date, then by suffix, and each one is later than the one before.

A release never changes once it is published: no file is replaced and no release is deleted. A correction is a new release. That is why files are served with a year-long, immutable cache, and why a file's hash can serve as its ETag.

What a release holds

PathWhat it is
manifest.jsonThe release id, its terms, the currencies, each country and every other file, each with its sha256 and size.
manifest.json.sigThe raw 64-byte Ed25519 signature over manifest.json's bytes.
countries/<cc>.jsonOne country's law: its jurisdictions, charges, versions, rates, exemptions and evidence.
vectors/<cc>.jsonThat country's conformance vectors. See Conformance vectors.
changes.jsonEvery change since the previous release, one row each.
CHANGELOG.mdThe same changes, by country and date, to read.
schema/v1/*.jsonJSON Schemas for the country file, the manifest, the changes, the vectors, the quote request, the quote and the resolve answer.
LICENSE.txtThe licence text. The manifest names it too, so the signature covers it.

<cc> is a country id, such as es. The files a token can fetch depend on the countries it is licensed for; see Tokens and HTTP delivery.

The manifest

Top level

FieldMeaning
releaseThe release id.
schemaThe file format's version: 1.
previousThe release before this one. Absent on the first.
publishedAtWhen the release was published, as an ISO 8601 timestamp.
calculator.versionThe calculator version the release was checked against, such as 1.0.0.
currenciesEach currency the release uses, with its minor-unit exponent.
countriesOne entry per country. See below.
changes, changelog, licenseThe path, sha256 and bytes of changes.json, CHANGELOG.md and LICENSE.txt.
schemasThe same for each JSON Schema.
metaThe release's terms: its id, license (id and url), the attribution notices and the disclaimer (id and text). Every quote carries the same meta.

A country

FieldMeaning
idThe country id, such as es.
lauYearThe LAU edition the country's codes are from. A LAU place must use it.
confirmedOnThe date the country's law was last confirmed against its sources.
recheckDays, nextRecheckByThe days after confirmedOn within which the country is due to be confirmed again, and the date that gives.
coverageHow much of the country the release covers. See Coverage and unknowns.
fileThe country file's path, sha256 and bytes.
vectorsThe same for its vector file.

Verify a release

Check the signature first, then each file's hash against the manifest. Only then parse the files.

Get the public key

GET /tax-data/v1/signing-key returns the public key as an SPKI PEM. It needs no token. See Tokens and HTTP delivery.

curl -fsS "https://api.octroi.app/tax-data/v1/signing-key" -o signing-key.pem

The route serves the key of the newest release. Save it, and keep it with the releases it verifies.

With WebCrypto

This runs wherever crypto.subtle has Ed25519, such as Node, Bun and current browsers:

function pemToDer(pem: string): Uint8Array<ArrayBuffer> {
  const base64 = pem
    .replace(/-----(BEGIN|END) PUBLIC KEY-----/g, "")
    .replace(/\s+/g, "");
  return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}

export async function verifyManifest(
  manifest: Uint8Array<ArrayBuffer>,
  signature: Uint8Array<ArrayBuffer>,
  publicKeyPem: string,
): Promise<boolean> {
  const key = await crypto.subtle.importKey(
    "spki",
    pemToDer(publicKeyPem),
    { name: "Ed25519" },
    false,
    ["verify"],
  );
  return crypto.subtle.verify({ name: "Ed25519" }, key, signature, manifest);
}

export async function sha256Hex(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

Pass the manifest's bytes exactly as fetched. Parsing and re-serialising the JSON first changes the bytes, and the signature no longer matches. The next sample imports sha256Hex from this file, saved as ./verify.

With node:crypto

import { createPublicKey, verify } from "node:crypto";
import { readFileSync } from "node:fs";

const dir = "./releases/2026-10-11";
const manifest = readFileSync(`${dir}/manifest.json`);
const signature = readFileSync(`${dir}/manifest.json.sig`);
const key = createPublicKey(readFileSync("./signing-key.pem", "utf8"));

if (!verify(null, manifest, key, signature))
  throw new Error("manifest.json is not signed by this key");

Ed25519 takes no separate hash, so the algorithm is null.

Check the files

With the signature good, the manifest's hashes can be trusted. Check every file against them before you read it:

import { readFile } from "node:fs/promises";
import { manifestSchema } from "./calculator";
import { sha256Hex } from "./verify";

const dir = "./releases/2026-10-11";
const manifestBytes = new Uint8Array(await readFile(`${dir}/manifest.json`));
const manifest = manifestSchema.parse(
  JSON.parse(new TextDecoder().decode(manifestBytes)),
);
const files = [
  ...manifest.countries.flatMap(({ file, vectors }) => [file, vectors]),
  manifest.changes,
  manifest.changelog,
  manifest.license,
  ...manifest.schemas,
];
for (const { path, sha256 } of files) {
  const bytes = new Uint8Array(await readFile(`${dir}/${path}`));
  if ((await sha256Hex(bytes)) !== sha256)
    throw new Error(`${path} does not match the manifest`);
}

A token licensed for some countries only gets those countries' files; skip the others in this loop.

Conformance vectors

Each country's vector file pins what the calculator answers for that country's data: named requests, each with the answer it must give or the error it must throw. Every rate cell in the release, each rate for each type and class it matches, is reached by at least one. A release is built only when the calculator gives every vector's answer.

Run them after an upgrade, or against your own port of the calculator. A vector is {name, fn, request, response} or, for a request that must fail, {name, fn, request, error: {code}}. fn is quote or resolve.

A response holds only what the vectors pin, in a fixed order. For a quote that is the status, the place, each line's room, charge, version, currency, amounts, inPrice and nights, the price-dependent charges, the unknowns without their text, and the totals. For a resolve answer it is the date, the place, which charge versions apply now and later, and the unknowns. quoteVectorOf and resolveVectorOf cut a real answer down to that shape.

import { readFile } from "node:fs/promises";
import { isDeepStrictEqual } from "node:util";
import {
  type Place,
  type QuoteRequest,
  quote,
  quoteVectorOf,
  resolve,
  resolveVectorOf,
  TaxDataError,
  vectorFileSchema,
} from "./calculator";
import { loadRelease } from "./load";

const dir = "./releases/2026-10-11";
const release = await loadRelease(dir, ["es"]);
const file = vectorFileSchema.parse(
  JSON.parse(await readFile(`${dir}/vectors/es.json`, "utf8")),
);

let failed = 0;
for (const vector of file.vectors) {
  // quote and resolve parse their own input; an error vector's request is unchecked on purpose.
  const run = () => {
    if (vector.fn === "quote")
      return quoteVectorOf(quote(release, vector.request as QuoteRequest));
    const { place, on } = vector.request as { place: Place; on: string };
    return resolveVectorOf(resolve(release, place, on));
  };
  let ok: boolean;
  if ("error" in vector) {
    try {
      run();
      ok = false;
    } catch (error) {
      ok = error instanceof TaxDataError && error.code === vector.error.code;
    }
  } else ok = isDeepStrictEqual(run(), vector.response);
  if (!ok) {
    failed++;
    console.error(`${file.country} ${vector.name} differs`);
  }
}
console.log(`${file.vectors.length - failed} of ${file.vectors.length} passed`);

loadRelease is the loader from the quickstart.

Changes

changes.json lists what this release changed: release, previous when there is one, and changes, one row per change. A row has:

FieldMeaning
idThe release id, a colon and a number, such as 2026-10-11:1.
countryThe country id.
objectWhat changed: jurisdiction, vat, classification, charge, version or evidence.
keyIts id.
actionadded, changed, ended, removed or superseded.
effectiveFromThe date the change takes effect in law, where it has one.
pricedtrue for a VAT rate or charge version added or removed, or changed in more than its names and evidence: a change that can move an amount.
summaryOne English sentence.

CHANGELOG.md is the same list as Markdown, by country, then by the date each change takes effect.

Terms

LICENSE.txt is the licence the release is used under, and meta.license names it by id and URL. Show meta.attribution and meta.disclaimer.text wherever you show the taxes. Every quote carries the release's meta, so the terms are to hand where you render an answer.

On this page