SDKs
Types and helpers where they exist, and the raw HTTP equivalent where they do not — labelled, so you can tell which is which before you plan around it.
What exists today
Stated plainly, because a grid of language logos where half of them link to a waitlist is a roadmap presented as a product. A row marked “no library” means there is no library — not that one is imminent — and the raw HTTP path below is a complete integration rather than a sketch.
- HTTPAvailable
The API itself. Every endpoint, no wrapper, no version skew. This is the supported integration path today.
- TypeScript typesAvailable
Hand-written types for the answer contract and the calculator responses, published so a client can be type-safe without a runtime dependency.
- OpenAPI documentNot yet
Not published yet. When it is, generated clients become the recommended path and the types below become generated rather than hand-written.
- Python clientNo library
No library. Use httpx against the API — the examples on this page and in the quickstart are complete, not sketches.
- Go, Ruby, PHP, JavaNo library
No libraries. The API is plain JSON over HTTPS with bearer auth, so a client is a few dozen lines; the contract is what matters and it is documented in full.
Install and configure
The types are a development dependency and nothing more. There is no runtime to keep in step with a server version, which is deliberate: the contract is versioned by a response header, so a client that reads the header is better informed than one pinned to a library release.
# Types only. No runtime dependency, nothing to keep in step
# with a server version.
npm add --save-dev @taxorch/contract-typesimport type { AnswerContract } from "@taxorch/contract-types";
/**
* One place that knows the base URL and the key.
*
* Self-hosted and hosted expose the same routes and the same contract, so this
* is the only thing that changes between them.
*/
const TAXORCH_BASE =
process.env.TAXORCH_BASE_URL ?? "https://api.taxorch.com";
export async function ask(
question: string,
site: number,
): Promise<AnswerContract> {
const response = await fetch(`${TAXORCH_BASE}/api/v1/tax-assistant/answer`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TAXORCH_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ question, site }),
});
if (!response.ok) throw await taxorchError(response);
return (await response.json()) as AnswerContract;
}The typed contract
The four fields commented in the middle are the ones that get typed as optional by mistake. They are present on every high-risk answer and empty on a clean one, so a client that types them optional compiles fine and then throws on the first conflict in production.
/** The eight documented outcomes. There is no ninth and no "unknown". */
export type VerificationStatus =
| "verified"
| "partial"
| "needs_facts"
| "unsupported"
| "conflict"
| "stale_source"
| "human_review_required"
| "system_unavailable";
export interface MissingFact {
key: string;
/** Why the fact is needed. Show it — the question looks arbitrary without it. */
reason: string;
/** Written for a person. Use it verbatim as a field label. */
prompt: string | null;
}
export interface AnswerContract {
answer_type: string;
jurisdictions: string[];
tax_years: string[];
tax_types: string[];
coverage_status: "covered" | "beta" | "draft" | "unavailable";
law_as_of: string | null;
source_last_checked: string | null;
corpus_release_id: string | null;
facts_used: FactUsed[];
/* Present and empty on a clean answer — not optional. Typing these four as
optional is the mistake that breaks on the first interesting case. */
missing_facts: MissingFact[];
conflicts: Conflict[];
assumptions: string[];
risks: string[];
calculations?: Calculation[];
claims: Claim[];
citations: Citation[];
confidence: number;
verification_status: VerificationStatus;
human_review_required: boolean;
request_classification: string;
answer_mode: "grounded" | "general" | "partial" | "clarify" | "refuse";
next_actions: string[];
}Outcome-state helpers
A discriminated union over the 8 statuses, so the compiler refuses a client that forgot a case. That is the point of typing this contract at all: the two outcomes that are not a number are two thirds of what the API does.
import type { AnswerContract } from "@taxorch/contract-types";
/**
* The three states a caller has to render.
*
* Modelled as a discriminated union so the compiler refuses a client that
* forgot one. That is the whole value of typing this contract: the outcomes
* that are not a number are two thirds of the API.
*/
export type Outcome =
| { kind: "answer"; contract: AnswerContract }
| { kind: "ask"; contract: AnswerContract; questions: MissingFact[] }
| { kind: "escalate"; contract: AnswerContract; reason: string };
export function toOutcome(contract: AnswerContract): Outcome {
// Checked first: it is set alongside several statuses and always wins.
if (contract.human_review_required) {
return {
kind: "escalate",
contract,
reason: contract.next_actions[0] ?? "Requires human review.",
};
}
if (contract.verification_status === "verified") {
return { kind: "answer", contract };
}
if (contract.verification_status === "needs_facts") {
return { kind: "ask", contract, questions: contract.missing_facts };
}
return {
kind: "escalate",
contract,
reason: contract.next_actions[0] ?? "Outside declared coverage.",
};
}
/**
* Whether a figure may be presented as exact.
*
* Separate from the outcome, because a verified answer at draft coverage is
* still a real answer — it just must not be labelled exact.
*/
export function isExact(contract: AnswerContract): boolean {
return (
contract.verification_status === "verified" &&
(contract.coverage_status === "covered" ||
contract.coverage_status === "beta")
);
}from dataclasses import dataclass
from typing import Literal, Union
VERIFIED = "verified"
NEEDS_FACTS = "needs_facts"
EXACT_COVERAGE = {"covered", "beta"}
@dataclass(frozen=True)
class Answer:
contract: dict
@dataclass(frozen=True)
class Ask:
contract: dict
questions: list[dict]
@dataclass(frozen=True)
class Escalate:
contract: dict
reason: str
Outcome = Union[Answer, Ask, Escalate]
def to_outcome(contract: dict) -> Outcome:
"""Three states, and no branch that renders a number by default."""
if contract["human_review_required"]:
return Escalate(contract, _first_action(contract, "Requires human review."))
status = contract["verification_status"]
if status == VERIFIED:
return Answer(contract)
if status == NEEDS_FACTS:
return Ask(contract, contract["missing_facts"])
return Escalate(contract, _first_action(contract, "Outside declared coverage."))
def is_exact(contract: dict) -> bool:
"""A verified answer at draft coverage is real, but must not be called exact."""
return (
contract["verification_status"] == VERIFIED
and contract["coverage_status"] in EXACT_COVERAGE
)
def _first_action(contract: dict, fallback: str) -> str:
actions = contract.get("next_actions") or []
return actions[0] if actions else fallbackNote that isExact is separate from the outcome. A verified answer at draft coverage is a real answer that must not be labelled exact, and collapsing those two questions into one is how a planning-only figure ends up on an invoice.
Error handling
Four of these are ordinary. The 422 is the one worth writing code for: it carries a blocker list saying exactly which preconditions are unmet, so it is actionable rather than merely a rejection. And the 409 should never be retried — the target is immutable, and reopening it is a separate audited action.
export class TaxOrchError extends Error {
constructor(
readonly status: number,
message: string,
/** Present on a 422: exactly which preconditions are unmet. */
readonly blockers?: string[],
) {
super(message);
}
}
export async function taxorchError(response: Response): Promise<TaxOrchError> {
const body = await response.json().catch(() => ({}));
switch (response.status) {
case 401:
return new TaxOrchError(401, "No valid credentials.");
case 403:
// The role or the site permission. Staff-only surfaces land here.
return new TaxOrchError(403, body.detail ?? "Not permitted.");
case 409:
// Immutable target — a finalised case. Reopening is a separate,
// audited action, so do not retry this.
return new TaxOrchError(409, body.detail ?? "Record is immutable.");
case 422:
// The useful one. Render the blockers; they say what to fix.
return new TaxOrchError(
422,
body.detail ?? "Preconditions unmet.",
body.blockers,
);
case 429:
return new TaxOrchError(429, "Throttled. Back off and retry.");
default:
return new TaxOrchError(response.status, body.detail ?? "Request failed.");
}
}An abstention is not an error
It arrives as a 200 with verification_status set and no figure. If your error handler catches it, you have turned the product’s most valuable behaviour into an incident — and your retry will produce exactly the same response.
Without an SDK
For every language without a library, this is the whole protocol: bearer auth, JSON in, the answer contract out, 200 even when the system declines. The two examples below are complete integrations rather than illustrations.
# The complete integration, without any library.
#
# Bearer auth, JSON in, the answer contract out, 200 even when the
# system declines. That is the whole protocol.
curl -s https://api.taxorch.com/api/v1/tax-assistant/answer \
-H "Authorization: Bearer $TAXORCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"question": "VAT registration threshold in Ireland for 2024?", "site": 1}' \
| jq '{
status: .verification_status,
review: .human_review_required,
coverage: .coverage_status,
figure: (.calculations[0].total_tax // null),
asks: [.missing_facts[].prompt],
next: .next_actions
}'// No Go library exists. This is the equivalent, and it is the
// whole of it — the contract is the interface.
package taxorch
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type Client struct {
BaseURL string
APIKey string
HTTP *http.Client
}
func (c *Client) Answer(question string, site int) (*AnswerContract, error) {
body, err := json.Marshal(map[string]any{"question": question, "site": site})
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", c.BaseURL+"/api/v1/tax-assistant/answer", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("taxorch: %d", res.StatusCode)
}
// A 200 may still be an abstention. Branch on VerificationStatus.
var contract AnswerContract
if err := json.NewDecoder(res.Body).Decode(&contract); err != nil {
return nil, err
}
return &contract, nil
}