Skip to main content

About ten minutes

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.

Set the key
Shell
# 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/
# 401

Confirm 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.

Check the key
Shell
curl -s https://api.taxorch.com/api/tax-assistant/jurisdictions/ \
  -H "Authorization: Bearer $TAXORCH_API_KEY"
200 — truncated to three of eight
JSON
{
  "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.

Income tax
Shell
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
  }'
200
JSON
{
  "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.

request.sh
Shell
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
  }'

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.

interpret.ts
TypeScript
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] };
  }
}

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.

200 — verification_status: needs_facts
JSON
{
  "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."
  ]
}
AskForFacts.tsx
TypeScript
// 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.

200 — verification_status: unsupported
JSON
{
  "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."
  ]
}
Abstention.tsx
TypeScript
// 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

Start reading

The quickest way to judge this is the API.

The answer contract states what every response carries, including the shape of a refusal. Nothing about it is hidden behind a sales conversation.