Quickstart
Load a signed Octroi release, quote three nights at a 4-star hotel in Barcelona for two adults and a child, and read the lines, totals and evidence.
This page loads a release from disk, quotes one stay and reads the answer. It takes a few minutes and needs no network once you have the files.
You need:
- a release directory, as Tokens and HTTP delivery fetches it:
manifest.json,manifest.json.sigandcountries/es.jsonat least; - the calculator, which ships with the licence. The samples import it from
./calculator.
Load a release
The calculator works on a loaded release: the manifest's release id, terms and currencies, and each country's law. A country file holds the law plus the release id and terms; keep the four law fields.
import { readFile } from "node:fs/promises";
import {
countryFileSchema,
type LoadedRelease,
manifestSchema,
} from "./calculator";
const readJson = async (path: string): Promise<unknown> =>
JSON.parse(await readFile(path, "utf8"));
/** The countries your token is licensed for, from a release directory. */
export async function loadRelease(
dir: string,
licensed: readonly string[],
): Promise<LoadedRelease> {
const manifest = manifestSchema.parse(await readJson(`${dir}/manifest.json`));
const countries: LoadedRelease["countries"] = {};
for (const { id, file } of manifest.countries) {
if (!licensed.includes(id)) continue;
const { country, jurisdictions, charges, evidence } =
countryFileSchema.parse(await readJson(`${dir}/${file.path}`));
countries[id] = { country, jurisdictions, charges, evidence };
}
return {
release: manifest.release,
meta: manifest.meta,
currencies: manifest.currencies,
countries,
};
}manifestSchema and countryFileSchema throw on a file that isn't a valid release file. Before you trust the files, verify the signature and each file's hash.
Quote a stay
Three nights at a 4-star hotel in Barcelona, from 12 to 15 November 2026, for two adults aged 41 and 38 and a child aged 9:
import { quote } from "./calculator";
import { loadRelease } from "./load";
const release = await loadRelease("./releases/2026-10-11", ["es"]);
const answer = quote(release, {
place: { jurisdiction: "es.barcelona", type: "hotel", class: "4" },
stay: { arrival: "2026-11-12", departure: "2026-11-15" },
rooms: [{ guests: [{ age: 41 }, { age: 38 }, { age: 9 }] }],
});es.barcelona is the city's jurisdiction id. You can name the place by its LAU code instead; see Resolve a place. The type and class come from the region's classification: the Catalan tourist tax's rate depends on both.
Read the answer
This is the answer, trimmed: the second line's nights, both lines' evidence lists and the terms in meta are cut short.
{
"release": "2026-10-11",
"calculator": "1.0.0",
"status": "complete",
"place": {
"jurisdictions": ["es", "es.catalunya", "es.barcelona"],
"type": "hotel",
"class": "4"
},
"lines": [
{
"room": 0,
"charge": "es.catalunya.municipal-surcharge",
"version": "2026-04-01",
"kind": "city_tax",
"name": "Recàrrec municipal sobre l'impost sobre les estades en establiments turístics",
"nameEn": "Municipal surcharge on the tourist stay tax",
"jurisdiction": "es.catalunya",
"currency": "EUR",
"amountMinor": 3000,
"vatMinor": 0,
"nights": [
{ "date": "2026-11-12", "liable": 2, "amountMinor": 1000, "vatMinor": 0 },
{ "date": "2026-11-13", "liable": 2, "amountMinor": 1000, "vatMinor": 0 },
{ "date": "2026-11-14", "liable": 2, "amountMinor": 1000, "vatMinor": 0 }
],
"inPrice": false,
"payer": "guest",
"collectedBy": ["operator"],
"explanation": {
"code": "per_person_night",
"params": { "nights": 3, "liable": 2, "amount": "30.00 EUR", "exempt": "aged_16_or_under" },
"text": "Municipal surcharge on the tourist stay tax: 30.00 EUR over 3 night(s), at most 2 liable a night."
},
"claimable": [],
"evidence": ["ev_267460fbf469eac0", "ev_49bd7a9b78b1ae52", "ev_ac687c6c38ae608e"]
},
{
"room": 0,
"charge": "es.catalunya.tourist-tax",
"version": "2026-04-01",
"kind": "city_tax",
"name": "Impost sobre les estades en establiments turístics",
"nameEn": "Tax on stays in tourist establishments",
"jurisdiction": "es.catalunya",
"currency": "EUR",
"amountMinor": 2040,
"vatMinor": 0,
"nights": [
{ "date": "2026-11-12", "liable": 2, "amountMinor": 680, "vatMinor": 0 }
],
"inPrice": false,
"payer": "guest",
"collectedBy": ["operator"],
"explanation": {
"code": "per_person_night",
"params": { "nights": 3, "liable": 2, "amount": "20.40 EUR", "exempt": "aged_16_or_under" },
"text": "Tax on stays in tourist establishments: 20.40 EUR over 3 night(s), at most 2 liable a night."
},
"claimable": [],
"evidence": ["ev_c873a3f06bb483bd", "ev_ac687c6c38ae608e"]
}
],
"priceDependent": [],
"unknowns": [],
"totals": [{ "currency": "EUR", "addedMinor": 5040, "inPriceMinor": 0 }],
"assumptions": [],
"meta": {
"release": "2026-10-11",
"license": { "id": "tdl-1.0", "url": "https://octroi.app/licence" },
"attribution": ["LAU and NUTS codes: © European Union, Eurostat, LAU 2024 and NUTS 2024, …"],
"disclaimer": { "id": "information-not-advice-1", "text": "Information, not tax advice. …" }
}
}What it says:
statusiscomplete: every charge that applies was priced, and nothing is unknown.- Two charges apply. Catalonia's tourist tax, version
2026-04-01, is €3.40 a person a night for a 4-star hotel: 340 × 2 liable guests × 3 nights = 2040. Barcelona's municipal surcharge, version2026-04-01, is €5.00 a person a night: 500 × 2 × 3 = 3000. Both are levied under Catalan law, so both lines carry the jurisdictiones.catalunya. - The child is exempt. Each night has 2 liable guests, not 3, and
explanation.params.exemptnames the exemption,aged_16_or_under. totalsadds the lines up per currency: 5040 minor units, €50.40, added on top of the room price (inPriceis false on both lines).
Money is always in integer minor units of the line's currency. The manifest's currencies gives each currency's exponent; EUR's is 2.
Show the evidence
Each line's evidence lists the ids of the sources behind its version, rates and exemptions. The country's law in the loaded release holds the sources themselves:
for (const line of answer.lines) {
const law = release.countries[line.charge.slice(0, 2)];
const sources = new Map(law?.evidence.map((source) => [source.id, source]));
for (const id of line.evidence) console.log(line.charge, sources.get(id));
}The tourist tax's rate for a 4-star hotel rests on this one:
{
"id": "ev_c873a3f06bb483bd",
"url": "https://www.boe.es/diario_boe/txt.php?id=BOE-A-2026-6642",
"quote": "2. Hotel de 4 estrellas y 4 estrellas superior, y establecimiento o equipamiento de categoría equivalente. 3,40 7,00",
"english": "2. 4-star and 4-star superior hotel, and equivalent establishment or facility. 3.40 7.00",
"checkedOn": "2026-10-10",
"publisher": "Agencia Estatal Boletín Oficial del Estado"
}quote is the source's own words, english its translation, and checkedOn the day it was checked against the source.
Next
- Quote a stay covers the room price, every field and every status.
- Coverage and unknowns shows what a
partialanswer looks like.
Developer docs [for the tax data and its calculator]
How to load a signed Octroi release, quote a stay's accommodation and city taxes to the cent, resolve a place and fetch releases over HTTP.
Quote a stay
The quote request and every field of the answer, from lines, nights and evidence to unknowns, totals and assumptions, in integer minor units.