Skip to main content

Types published, clients not yet

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.

Install
Shell
# Types only. No runtime dependency, nothing to keep in step
# with a server version.
npm add --save-dev @taxorch/contract-types
taxorch.ts
TypeScript
import 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.

contract.d.ts
TypeScript
/** 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.

outcome.ts
TypeScript
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")
  );
}

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

errors.ts
TypeScript
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.

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