Docs
A deterministic tax rules engine. Every calculation returns the figure and the ordered list of rules that produced it, each citing its provision, hashed and signed so anyone can replay it.
Quickstart
Three calls. Nothing to install, no key needed to try it.
# 1. What can this build actually do?
curl -s https://api.taxkernel.com/v1/rulesets
# 2. Calculate. Amounts are integer minor units, so $95,000 is 9500000 cents.
curl -s -X POST https://api.taxkernel.com/v1/calculate \
-H 'content-type: application/json' \
-d '{"ruleset":"au-2025-26","input":{"taxable_income_cents":9500000,"has_help_debt":true}}'
# 3. Check the certificate independently.
curl -s -X POST https://api.taxkernel.com/v1/verify \
-H 'content-type: application/json' \
-d "$(curl -s -X POST https://api.taxkernel.com/v1/calculate \
-H 'content-type: application/json' \
-d '{"input":{"taxable_income_cents":9500000}}' | jq '{certificate}')"
What determinism means here
It is a property the code is built to have, not a promise in a document.
- No model. There is no language model anywhere on the calculation path. Rules are declarative data and the engine interprets a fixed whitelist of formula shapes.
- No floating point. Money is an integer count of a currency's minor unit. Rates are integer basis points, so 16% is 1600. A rate meets an amount in exactly one function, and every rounding decision is made there.
- No clock, no randomness, no locale. The test suite reads the engine's own source and fails on a reference to
Date,Math.random,Intlor atoLocalemethod. - Versioned rules. A released ruleset version is immutable and also published to storage as canonical JSON, so a certificate issued years ago still replays after the code has moved on. A correction ships as a new version.
signedAtis outside the signature. It is the one wall-clock reading in a certificate, and it is deliberately excluded from the bytes that are hashed and signed, so a replay next year produces identical bytes.
The certificate
Returned beside every result. The fields below are the whole of it.
| Field | What it is |
|---|---|
engineVersion | The engine build that produced it. |
ruleset, version | Which rules ran, exactly. For example au-2025-26 at 1.0.0. |
rulesetHash | SHA-256 of the canonical artefact. Pins the rules themselves, not just their version string. |
input | The normalised input: every declared field with defaults filled in. This is what gets replayed. |
inputHash | SHA-256 of the canonical input. |
result | Line items, the net figure, the currency and its minor unit scale. |
trace | Every rule considered, in order: n, ruleId, cites, label, applied, amountCents, detail. A rule that did not fire is still here, with the guard that excluded it and the values that decided it. |
traceHash | SHA-256 of the canonical trace. |
signature, keyId | Ed25519 over the canonical bytes of everything above. |
signedAt | Metadata only. Not covered by the signature. |
Canonical form: object keys sorted by UTF-16 code unit, no whitespace, integers only. A float in a certificate would mean a float reached the calculation, which is itself the bug.
POST /v1/calculate
{
"ruleset": "au-2025-26", // optional, defaults to au-2025-26
"version": "1.0.0", // optional, defaults to the newest bundled version
"input": { "taxable_income_cents": 9500000, "has_help_debt": true }
}
Input fields are per jurisdiction and validated against that jurisdiction's own shape before the engine runs, so sending a UK field to the Australian ruleset is rejected by name rather than quietly ignored. GET /v1/rulesets/{id} returns the exact schema.
Two kinds of refusal, both deliberate. A 400 means the request is malformed. A 422 means the ruleset understands the request and will not answer it, because the combination is not encoded. Australian family Medicare levy thresholds are the current example: applying the single thresholds would understate the reduction, so the ruleset refuses instead.
POST /v1/verify
Send the certificate, bare or wrapped as {"certificate": ...}. Verification is a replay, not a comparison: the embedded input is run back through the rules the certificate names and every hash is recomputed.
{
"valid": false,
"mismatches": [
{ "field": "result",
"expected": "...", "actual": "...",
"detail": "Replaying the input produced a net figure of 2538800 minor units where the certificate claims 1." }
],
"checked": {
"rulesetResolved": true, "rulesetHashMatches": true, "inputHashMatches": true,
"replayMatches": false, "traceHashMatches": true, "signatureValid": false
}
}
You do not have to take our word for any of this. The browser verifier runs the identical code in your own browser.
GET /v1/rulesets
Every jurisdiction this build carries, with a status of live or scaffold, its currency and minor unit scale, its fiscal year and convention, its modules, and how many figures still require confirmation against a primary source. A scaffold is declared but not encoded, and calling it returns a refusal rather than an estimate.
One ruleset in full: its input schema, its refusals, its verification table and every rule with its citation.
The canonical JSON artefact, byte for byte, with its hash in the x-tk-ruleset-hash header. This is what the verifier fetches.
GET /v1/keys
Every public key a certificate might have been signed under, current and retired, so a certificate keeps verifying across a key rotation. The response also states plainly whether the deployment is signing with the development key committed to the repository, which is public knowledge and proves nothing about the issuer.
The rule format
A rule is data. It carries an id, the provision it encodes, a priority that fixes evaluation order, an optional guard, and a formula drawn from a fixed whitelist.
{
id: "resident_base_tax",
cites: "ITAA 1997 s 4-10; Income Tax Rates Act 1986 Sch 7 Pt I",
priority: 200,
appliesWhen: "residency == 'resident'",
formula: { type: "brackets", over: "taxable_income", bands: [
{ upToCents: 1_820_000, rateBp: 0 },
{ upToCents: 4_500_000, rateBp: 1600 },
{ upToCents: 13_500_000, rateBp: 3000 },
{ upToCents: 19_000_000, rateBp: 3700 },
{ upToCents: null, rateBp: 4500 },
]},
accumulate: { into: "tax_on_income", op: "add" },
verified: true
}
Guards are parsed, never evaluated as code. The language has comparison, boolean and integer arithmetic operators and nothing else: no property access, no calls, no way to reach anything but the declared variables. A decimal literal is a syntax error, because a rate is basis points.
Formula types available to every jurisdiction: brackets, bpOf, tiered, shadeIn, taper, sum, less, min, max, clamp, quantise, copy, const. Adding a jurisdiction that needs a new shape means adding to this list, never adding a branch to the engine. A test reads the engine's source and fails if it names any jurisdiction at all.
MCP
The engine is a remote MCP server over streamable HTTP. Two read-only tools, calculate_tax and verify_certificate, with schemas mirroring the API. The tool descriptions tell an agent to put the trace in front of its human, because a tax figure without provenance is exactly what a language model should not be asked to vouch for.
{
"mcpServers": {
"taxkernel": { "url": "https://api.taxkernel.com/mcp" }
}
}
Limits and honesty
- Rate limits are per minute, keyed on an
x-tk-keyheader. Unkeyed callers get a hard limit. v0.1 accepts any key value and logs it for metering; key issuance is not built yet. - The audit log records the ruleset version and the input and trace hashes. It does not record anyone's income.
- Australia is the encoded jurisdiction. The United Kingdom is encoded as a portability proof, not as production figures. The remaining G7 are declared scaffolds.
- Figures that could not be confirmed against a primary source are marked unverified in the manifest, counted in
/v1/rulesetsand listed in VERIFY.md. Nothing is guessed quietly. - Calculations are engine output, not tax advice.