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
| Path | What it is |
|---|---|
manifest.json | The release id, its terms, the currencies, each country and every other file, each with its sha256 and size. |
manifest.json.sig | The raw 64-byte Ed25519 signature over manifest.json's bytes. |
countries/<cc>.json | One country's law: its jurisdictions, charges, versions, rates, exemptions and evidence. |
vectors/<cc>.json | That country's conformance vectors. See Conformance vectors. |
changes.json | Every change since the previous release, one row each. |
CHANGELOG.md | The same changes, by country and date, to read. |
schema/v1/*.json | JSON Schemas for the country file, the manifest, the changes, the vectors, the quote request, the quote and the resolve answer. |
LICENSE.txt | The 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
| Field | Meaning |
|---|---|
release | The release id. |
schema | The file format's version: 1. |
previous | The release before this one. Absent on the first. |
publishedAt | When the release was published, as an ISO 8601 timestamp. |
calculator.version | The calculator version the release was checked against, such as 1.0.0. |
currencies | Each currency the release uses, with its minor-unit exponent. |
countries | One entry per country. See below. |
changes, changelog, license | The path, sha256 and bytes of changes.json, CHANGELOG.md and LICENSE.txt. |
schemas | The same for each JSON Schema. |
meta | The 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
| Field | Meaning |
|---|---|
id | The country id, such as es. |
lauYear | The LAU edition the country's codes are from. A LAU place must use it. |
confirmedOn | The date the country's law was last confirmed against its sources. |
recheckDays, nextRecheckBy | The days after confirmedOn within which the country is due to be confirmed again, and the date that gives. |
coverage | How much of the country the release covers. See Coverage and unknowns. |
file | The country file's path, sha256 and bytes. |
vectors | The 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.pemThe 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:
| Field | Meaning |
|---|---|
id | The release id, a colon and a number, such as 2026-10-11:1. |
country | The country id. |
object | What changed: jurisdiction, vat, classification, charge, version or evidence. |
key | Its id. |
action | added, changed, ended, removed or superseded. |
effectiveFrom | The date the change takes effect in law, where it has one. |
priced | true 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. |
summary | One 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.
Resolve a place
Name a place by jurisdiction id or LAU code, and get the charges in force there on a date, with their rates, exemptions and evidence.
Coverage and unknowns
The twelve countries a release covers, how much of each, and how a quote or resolve answer says what it can't price instead of guessing.