Quickstart
Four steps to a grounded answer you can put in front of a user. The fourth is the one that matters, and it is not error handling.
1. Authenticate
Every endpoint is authenticated and there is no anonymous surface. The key is tenant-scoped, so it grants exactly what the role behind it grants — a key is not a way around the RBAC matrix.
# Store the key. It is tenant-scoped, so it grants exactly what the
# role behind it grants — no more.
export TAXORCH_API_KEY="sk_live_..."
# A request with no key is refused rather than served a reduced result.
curl -s -o /dev/null -w '%{http_code}\n' \
https://api.taxorch.com/api/tax-assistant/jurisdictions/
# 401Confirm it works against something cheap. The jurisdiction list is a good first call because it tells you which countries have a deterministic engine and at what tier, which decides what the rest of your integration can claim.
curl -s https://api.taxorch.com/api/tax-assistant/jurisdictions/ \
-H "Authorization: Bearer $TAXORCH_API_KEY"{
"jurisdictions": [
{ "code": "US", "name": "United States", "authority": "IRS", "tier": "covered" },
{ "code": "UK", "name": "United Kingdom", "authority": "HMRC", "tier": "beta" },
{ "code": "DE", "name": "Germany", "authority": "BZSt", "tier": "draft" }
],
"count": 8
}2. Call a calculator
The deterministic path first, because it is the simplest thing that returns a real number and because it is where every number in every answer comes from. No model is involved in this call at all.
curl -s https://api.taxorch.com/api/tax-assistant/calculate/income/ \
-H "Authorization: Bearer $TAXORCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jurisdiction": "UK",
"tax_year": "2024/25",
"income": "60000.00",
"sub_region": "rest_of_uk",
"site": 1
}'{
"calculator": "uk_income_tax",
"calculator_version": "2024.3.1",
"parameter_dataset": "UK/2024-25",
"law_as_of": "2024-04-06",
"coverage_status": "beta",
"taxable_income": "47430.00",
"total_tax": "11432.00",
"effective_rate": "0.1905",
"marginal_rate": "0.40",
"bands": [
{ "label": "Personal allowance", "amount": "12570.00", "rate": "0.00", "tax": "0.00" },
{ "label": "Basic rate", "amount": "37700.00", "rate": "0.20", "tax": "7540.00" },
{ "label": "Higher rate", "amount": "9730.00", "rate": "0.40", "tax": "3892.00" }
],
"assumptions": [
"Rest-of-UK rates. Scotland has different income tax bands."
]
}Three things in that response are worth wiring into your interface immediately. bands is the breakdown, and showing it is the cheapest credibility you will ever ship. coverage_status is beta here, which means the figure is exact but the depth is limited — a draft status must not be rendered as exact. And assumptions tells you this is rest-of-UK, which is a different number from Scotland.
3. Ask a real question
Now the grounded path. Note that no jurisdiction or year is passed — the planner extracts them, and if it cannot, it says so rather than picking one.
curl -s https://api.taxorch.com/api/v1/tax-assistant/answer \
-H "Authorization: Bearer $TAXORCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"question": "What is the income tax on £60,000 in the UK for 2024/25?",
"site": 1
}'const response = await fetch(
"https://api.taxorch.com/api/v1/tax-assistant/answer",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TAXORCH_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
question: "What is the income tax on £60,000 in the UK for 2024/25?",
site: 1,
}),
},
);
// 200 even when the system declines to answer. Read the contract, not the code.
const answer = await response.json();import os
import httpx
response = httpx.post(
"https://api.taxorch.com/api/v1/tax-assistant/answer",
headers={"Authorization": f"Bearer {os.environ['TAXORCH_API_KEY']}"},
json={
"question": "What is the income tax on £60,000 in the UK for 2024/25?",
"site": 1,
},
timeout=60,
)
# 200 even when the system declines to answer. Read the contract, not the code.
answer = response.json()The response is the answer contract — the same object whatever the endpoint. It arrives with a 200 even when the system declined to answer, which brings us to the step that actually matters.
4. Handle all three outcomes
This is not error handling. It is the product.
There are three outcomes and no fourth: an exact cited answer, a specific question about a missing fact, or an escalation with a stated reason. Two of the three are not numbers. A client that renders only the first and shows a spinner or an error for the rest has thrown away the reason to use this API — and the 8 verification statuses exist so it does not have to.
type Outcome =
| { kind: "answer"; total: string; citations: Citation[] }
| { kind: "ask"; questions: MissingFact[] }
| { kind: "escalate"; reason: string };
/**
* There is no fourth branch, and no default that renders a number.
*
* Anything that is not `verified` and not `needs_facts` goes to a person.
* Treating the rest as "close enough to show" is the single mistake this
* contract exists to make impossible.
*/
function interpret(answer: AnswerContract): Outcome {
if (answer.human_review_required) {
return { kind: "escalate", reason: answer.next_actions[0] };
}
switch (answer.verification_status) {
case "verified":
return {
kind: "answer",
total: answer.calculations[0].total_tax,
citations: answer.citations,
};
case "needs_facts":
return { kind: "ask", questions: answer.missing_facts };
default:
return { kind: "escalate", reason: answer.next_actions[0] };
}
}def interpret(answer: dict) -> Outcome:
"""
There is no fourth branch, and no default that renders a number.
Anything that is not verified and not needs_facts goes to a person.
Treating the rest as "close enough to show" is the single mistake this
contract exists to make impossible.
"""
if answer["human_review_required"]:
return Escalate(reason=answer["next_actions"][0])
status = answer["verification_status"]
if status == "verified":
return Answer(
total=answer["calculations"][0]["total_tax"],
citations=answer["citations"],
)
if status == "needs_facts":
return Ask(questions=answer["missing_facts"])
return Escalate(reason=answer["next_actions"][0])Rendering a needs-facts reply
The response carries a prompt written for a person and a reason explaining why the question is being asked. Use both: a form field labelled with the prompt and no reason looks arbitrary, and users abandon arbitrary forms.
{
"answer_type": "calculation",
"jurisdictions": [
"GB"
],
"tax_years": [],
"tax_types": [
"individual_income"
],
"coverage_status": "beta",
"law_as_of": null,
"source_last_checked": "2024-11-03",
"corpus_release_id": "uk-2024-11-03.a41f9c",
"facts_used": [
{
"key": "employment_income",
"value": "60000.00",
"currency": "GBP"
}
],
"missing_facts": [
{
"key": "tax_year",
"reason": "No tax year was named, and the bands differ between years.",
"prompt": "Which tax year should this be calculated for?"
},
{
"key": "sub_region",
"reason": "Scotland has different income tax bands from the rest of the UK.",
"prompt": "Is the taxpayer resident in Scotland?"
}
],
"claims": [],
"citations": [],
"assumptions": [],
"risks": [],
"confidence": 0,
"verification_status": "needs_facts",
"human_review_required": false,
"request_classification": "high_risk",
"answer_mode": "clarify",
"conflicts": [],
"next_actions": [
"Answer the two questions in missing_facts and resubmit."
]
}// A needs-facts reply is a form, not an error banner.
function AskForFacts({ answer }: { answer: AnswerContract }) {
return (
<form onSubmit={resubmit}>
<p>Two things are needed before this can be calculated.</p>
{answer.missing_facts.map((fact) => (
<label key={fact.key}>
{/* The prompt is written for a person. Use it verbatim. */}
{fact.prompt}
<input name={fact.key} required />
{/* And the reason, so the question does not look arbitrary. */}
<small>{fact.reason}</small>
</label>
))}
<button type="submit">Calculate</button>
</form>
);
}Rendering an abstention
An abstention names the specific asset that is missing. Rendering “something went wrong” instead throws away the one thing that makes a refusal useful, and invites a retry that will fail identically.
{
"answer_type": "calculation",
"jurisdictions": [
"MC"
],
"tax_years": [
"2024"
],
"tax_types": [
"corporate_income"
],
"coverage_status": "unavailable",
"law_as_of": null,
"source_last_checked": null,
"corpus_release_id": null,
"facts_used": [],
"missing_facts": [
{
"key": "coverage_profile",
"reason": "No coverage profile exists for MC / 2024 / corporate_income / company.",
"prompt": null
}
],
"claims": [],
"citations": [],
"assumptions": [],
"risks": [],
"confidence": 0,
"verification_status": "unsupported",
"human_review_required": false,
"request_classification": "high_risk",
"answer_mode": "refuse",
"conflicts": [],
"next_actions": [
"This combination is outside declared coverage. Consult a professional in the jurisdiction."
]
}// An abstention is a finding, not a failure. Show the reason and the route out.
function Abstention({ answer }: { answer: AnswerContract }) {
const missing = answer.missing_facts[0];
return (
<section>
<h3>This is outside what TaxOrch can answer</h3>
{/* Name the specific thing that is missing, not "an error occurred". */}
<p>{missing?.reason}</p>
<ul>
{answer.next_actions.map((action) => (
<li key={action}>{action}</li>
))}
</ul>
</section>
);
}Next steps
- The answer contractAll twenty-two fields annotated, with the same response as a verified answer, an ask, an abstention and a conflict.Read
- API referenceThe full catalogue by functional group, with the conventions and status codes.Read
- Self-hostingRun it inside your own perimeter. Profiles, configuration and the readiness gates.Read